diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index 548ef69..d22aeda 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -54,6 +54,15 @@ func (h *Handler) CreateSite(c *gin.Context) { response.Created(c, item) } +func (h *Handler) GetSiteDefaults(c *gin.Context) { + holdDays, err := h.service.DefaultSettlementHoldDays(c.Request.Context()) + if err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"default_settlement_hold_days": holdDays}) +} + func (h *Handler) UpdateSite(c *gin.Context) { id, ok := parseID(c) if !ok { diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 5288bea..998761c 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -13,6 +13,7 @@ import ( "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" listingpkg "hfb_sys/backend/internal/modules/listing" + "hfb_sys/backend/internal/modules/systemconfig" "hfb_sys/backend/internal/modules/wallet" "github.com/redis/go-redis/v9" @@ -220,6 +221,23 @@ func (r *Repository) FindSite(ctx context.Context, adminID, id uint64) (*SiteDTO return &item, nil } +// resolveDefaultSettlementHoldDays 读取系统配置里的新建站点默认结算冷却天数,缺失或异常时回落到内置默认值。 +func (r *Repository) resolveDefaultSettlementHoldDays(ctx context.Context) int { + if r == nil || r.db == nil { + return systemconfig.DefaultDistributionSettlementHoldDays + } + var row model.SystemConfig + if err := r.db.WithContext(ctx).Where("`key` = ?", systemconfig.DistributionSettlementHoldDaysKey).First(&row).Error; err != nil { + return systemconfig.DefaultDistributionSettlementHoldDays + } + return systemconfig.ParseDistributionSettlementHoldDays(row.Value) +} + +// DefaultSettlementHoldDays 暴露当前有效的默认结算冷却天数,供前端表单使用。 +func (r *Repository) DefaultSettlementHoldDays(ctx context.Context) int { + return r.resolveDefaultSettlementHoldDays(ctx) +} + func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) { row := model.DistributionSite{ SiteCode: strings.TrimSpace(req.SiteCode), SiteName: strings.TrimSpace(req.SiteName), Domain: strings.TrimSpace(req.Domain), @@ -243,6 +261,8 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si return nil, err } row.SettlementHoldDays = holdDays + } else { + row.SettlementHoldDays = r.resolveDefaultSettlementHoldDays(ctx) } if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { return nil, err @@ -1404,7 +1424,7 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (* if req.TaxRateBPS != nil { taxRate = *req.TaxRateBPS } - holdDays := 7 + holdDays := r.resolveDefaultSettlementHoldDays(ctx) if req.SettlementHoldDays != nil { normalized, err := normalizeSettlementHoldDays(*req.SettlementHoldDays) if err != nil { diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 3b6ec80..7c3078e 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -86,6 +86,12 @@ func (s *Service) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteD } return s.repo.CreateSite(ctx, req) } +func (s *Service) DefaultSettlementHoldDays(ctx context.Context) (int, error) { + if err := s.available(); err != nil { + return 0, err + } + return s.repo.DefaultSettlementHoldDays(ctx), nil +} func (s *Service) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) { if err := s.available(); err != nil { return nil, err diff --git a/backend/internal/modules/distribution/settlement_default_test.go b/backend/internal/modules/distribution/settlement_default_test.go new file mode 100644 index 0000000..3e92c07 --- /dev/null +++ b/backend/internal/modules/distribution/settlement_default_test.go @@ -0,0 +1,54 @@ +package distribution + +import ( + "testing" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/systemconfig" +) + +// TestCreateSiteUsesConfiguredDefaultSettlementHoldDays 验证新建站点未显式传值时读取系统配置的默认冷却天数。 +func TestCreateSiteUsesConfiguredDefaultSettlementHoldDays(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + if err := db.Create(&model.SystemConfig{ + Key: systemconfig.DistributionSettlementHoldDaysKey, + Value: "3", + }).Error; err != nil { + t.Fatal(err) + } + dto, err := repo.CreateSite(t.Context(), CreateSiteRequest{SiteCode: "cfg_hold", SiteName: "配置冷却站", Domain: "cfghold.dist.com"}) + if err != nil { + t.Fatalf("CreateSite err = %v", err) + } + if dto.SettlementHoldDays != 3 { + t.Fatalf("DTO settlement_hold_days = %d, want 3", dto.SettlementHoldDays) + } + var site model.DistributionSite + if err := db.Where("site_code = ?", "cfg_hold").First(&site).Error; err != nil { + t.Fatal(err) + } + if site.SettlementHoldDays != 3 { + t.Fatalf("DB settlement_hold_days = %d, want 3", site.SettlementHoldDays) + } +} + +// TestCreateSiteFallsBackWhenConfigMissing 验证系统配置缺失时回落到内置默认 7 天。 +func TestCreateSiteFallsBackWhenConfigMissing(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + dto, err := repo.CreateSite(t.Context(), CreateSiteRequest{SiteCode: "no_cfg_hold", SiteName: "兜底冷却站", Domain: "nocfghold.dist.com"}) + if err != nil { + t.Fatalf("CreateSite err = %v", err) + } + if dto.SettlementHoldDays != systemconfig.DefaultDistributionSettlementHoldDays { + t.Fatalf("DTO settlement_hold_days = %d, want %d", dto.SettlementHoldDays, systemconfig.DefaultDistributionSettlementHoldDays) + } + var site model.DistributionSite + if err := db.Where("site_code = ?", "no_cfg_hold").First(&site).Error; err != nil { + t.Fatal(err) + } + if site.SettlementHoldDays != systemconfig.DefaultDistributionSettlementHoldDays { + t.Fatalf("DB settlement_hold_days = %d, want %d", site.SettlementHoldDays, systemconfig.DefaultDistributionSettlementHoldDays) + } +} diff --git a/backend/internal/modules/systemconfig/distribution.go b/backend/internal/modules/systemconfig/distribution.go new file mode 100644 index 0000000..c06fe21 --- /dev/null +++ b/backend/internal/modules/systemconfig/distribution.go @@ -0,0 +1,43 @@ +package systemconfig + +import ( + "strconv" + "strings" +) + +// DistributionSettlementHoldDaysKey 是「新建分销站点默认结算冷却天数」的系统配置键。 +const DistributionSettlementHoldDaysKey = "distribution.default_settlement_hold_days" + +// DefaultDistributionSettlementHoldDays 是新建分销站点默认结算冷却天数的兜底值。 +const DefaultDistributionSettlementHoldDays = 7 + +func defaultDistributionSettlementHoldDaysValue() string { + return strconv.Itoa(DefaultDistributionSettlementHoldDays) +} + +// ParseDistributionSettlementHoldDays 解析结算冷却天数配置,为空、非法或越界时回落到默认 7 天。 +func ParseDistributionSettlementHoldDays(raw string) int { + days, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || days < 0 || days > 365 { + return DefaultDistributionSettlementHoldDays + } + return days +} + +// validateConfigValue 按配置键分发校验逻辑,未注册校验的键直接放行。 +func validateConfigValue(key, value string) error { + switch key { + case DistributionSettlementHoldDaysKey: + return validateDistributionSettlementHoldDays(value) + default: + return nil + } +} + +func validateDistributionSettlementHoldDays(value string) error { + days, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || days < 0 || days > 365 { + return ErrInvalidConfig + } + return nil +} diff --git a/backend/internal/modules/systemconfig/distribution_test.go b/backend/internal/modules/systemconfig/distribution_test.go new file mode 100644 index 0000000..581fa3c --- /dev/null +++ b/backend/internal/modules/systemconfig/distribution_test.go @@ -0,0 +1,66 @@ +package systemconfig + +import "testing" + +func TestParseDistributionSettlementHoldDays(t *testing.T) { + cases := []struct { + name string + raw string + want int + }{ + {name: "零天立即释放", raw: "0", want: 0}, + {name: "默认七天", raw: "7", want: 7}, + {name: "上限三百六十五天", raw: "365", want: 365}, + {name: "负数回落", raw: "-1", want: DefaultDistributionSettlementHoldDays}, + {name: "超过上限回落", raw: "366", want: DefaultDistributionSettlementHoldDays}, + {name: "空值回落", raw: " ", want: DefaultDistributionSettlementHoldDays}, + {name: "非数字回落", raw: "abc", want: DefaultDistributionSettlementHoldDays}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ParseDistributionSettlementHoldDays(tc.raw); got != tc.want { + t.Fatalf("ParseDistributionSettlementHoldDays(%q) = %d, want %d", tc.raw, got, tc.want) + } + }) + } +} + +func TestDistributionSettlementHoldDaysConfigRegistered(t *testing.T) { + value := "" + for _, item := range defaultConfigs { + if item.Key == DistributionSettlementHoldDaysKey { + value = item.Value + break + } + } + if value != defaultDistributionSettlementHoldDaysValue() { + t.Fatalf("defaultConfigs 中 %s 的默认值 = %q, want %q", DistributionSettlementHoldDaysKey, value, defaultDistributionSettlementHoldDaysValue()) + } + visible := false + for _, key := range adminVisibleConfigKeys { + if key == DistributionSettlementHoldDaysKey { + visible = true + break + } + } + if !visible { + t.Fatalf("adminVisibleConfigKeys 缺少 %s", DistributionSettlementHoldDaysKey) + } +} + +func TestValidateConfigValueDistributionSettlementHoldDays(t *testing.T) { + for _, value := range []string{"0", "7", "365"} { + if err := validateConfigValue(DistributionSettlementHoldDaysKey, value); err != nil { + t.Fatalf("validateConfigValue(%q) = %v, want nil", value, err) + } + } + for _, value := range []string{"-1", "366", "", "abc"} { + if err := validateConfigValue(DistributionSettlementHoldDaysKey, value); err != ErrInvalidConfig { + t.Fatalf("validateConfigValue(%q) = %v, want ErrInvalidConfig", value, err) + } + } + // 未注册校验的键直接放行。 + if err := validateConfigValue("handoff.owner_submit_timeout_minutes", "abc"); err != nil { + t.Fatalf("无关配置键应放行, got %v", err) + } +} diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index a5850f9..954b0c6 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -54,6 +54,7 @@ var defaultConfigs = []defaultConfig{ {Key: cooperationFeedbackConfigKey, Value: defaultCooperationFeedbackConfigValue(), Description: "首页 合作与反馈入口配置 JSON(标题/副标题/展示图片)"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"}, + {Key: DistributionSettlementHoldDaysKey, Value: defaultDistributionSettlementHoldDaysValue(), Description: "新建分销站点默认结算冷却天数(0=立即释放,最大 365)"}, {Key: "backup.schedule_enabled", Value: "true", Description: "在线备份定时任务是否启用"}, {Key: "backup.full_hour", Value: "4", Description: "每日全量备份小时(Asia/Shanghai)"}, {Key: "backup.full_minute", Value: "30", Description: "每日全量备份分钟(Asia/Shanghai)"}, @@ -65,6 +66,7 @@ var adminVisibleConfigKeys = []string{ "chat.default_support_admin_id", "chat.listing_group_welcome", "chat.renter_retention_days_after_order_end", + "distribution.default_settlement_hold_days", "handoff.owner_return_confirm_timeout_minutes", "handoff.owner_submit_timeout_minutes", "handoff.renter_confirm_timeout_minutes", @@ -140,6 +142,9 @@ func (r *Repository) FindValue(ctx context.Context, key string) (string, error) } func (r *Repository) Update(ctx context.Context, actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) { + if err := validateConfigValue(key, req.Value); err != nil { + return nil, err + } var row model.SystemConfig err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("`key` = ?", key).First(&row).Error; err != nil { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f1d98a7..6dadf1c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -638,6 +638,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/files/upload", fileHandler.Upload) adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary) adminRoutes.GET("/distribution/sites", requirePerm("distribution:site_view"), distributionHandler.ListSites) + adminRoutes.GET("/distribution/site-defaults", requirePerm("distribution:site_view"), distributionHandler.GetSiteDefaults) adminRoutes.GET("/distribution/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite) adminRoutes.POST("/distribution/sites", requirePerm("distribution:site_manage"), distributionHandler.CreateSite) adminRoutes.POST("/distribution/sites/onboard", requirePerm("distribution:site_manage"), distributionHandler.OnboardSite) diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 87b1ed2..2130739 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -322,6 +322,18 @@ export async function listSites(): Promise { return extract(resp).items ?? [] } +export interface DistributionSiteDefaults { + default_settlement_hold_days: number +} + +// fetchDistributionSiteDefaults 读取新建站点的默认结算冷却天数,供开通表单预填。 +export async function fetchDistributionSiteDefaults(): Promise { + const resp = await api.get>( + '/admin/distribution/site-defaults' + ) + return extract(resp) +} + export async function listRules(siteID?: number, mode?: string): Promise { const resp = await api.get>('/admin/distribution/rules', { params: { site_id: siteID || undefined, mode: mode || undefined }, diff --git a/distribution-admin/src/pages/Sites.tsx b/distribution-admin/src/pages/Sites.tsx index 029649b..94f7595 100644 --- a/distribution-admin/src/pages/Sites.tsx +++ b/distribution-admin/src/pages/Sites.tsx @@ -17,7 +17,7 @@ import { Descriptions, } from 'antd' import { PlusOutlined, ShopOutlined, CopyOutlined } from '@ant-design/icons' -import { listSites, onboardSite, type Site, type OnboardSitePayload, type OnboardSiteResult } from '../api' +import { listSites, onboardSite, fetchDistributionSiteDefaults, type Site, type OnboardSitePayload, type OnboardSiteResult } from '../api' export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean }) { const [items, setItems] = useState([]) @@ -25,6 +25,7 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean const [modalOpen, setModalOpen] = useState(false) const [submitting, setSubmitting] = useState(false) const [successResult, setSuccessResult] = useState(null) + const [defaultSettlementHoldDays, setDefaultSettlementHoldDays] = useState(7) const [form] = Form.useForm() const loadData = () => { @@ -37,6 +38,16 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean useEffect(() => { loadData() + fetchDistributionSiteDefaults() + .then((res) => { + const days = res?.default_settlement_hold_days + if (typeof days === 'number' && days >= 0 && days <= 365) { + setDefaultSettlementHoldDays(days) + } + }) + .catch(() => { + // 拉取失败时保持内置默认值 7 + }) }, []) const handleOpenModal = () => { @@ -46,7 +57,7 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean display_account_enabled: true, tax_enabled: false, tax_rate_bps: 100, - settlement_hold_days: 7, + settlement_hold_days: defaultSettlementHoldDays, }) setModalOpen(true) } @@ -65,7 +76,7 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean display_account_enabled: values.display_account_enabled, tax_enabled: values.tax_enabled, tax_rate_bps: values.tax_enabled ? values.tax_rate_bps : 0, - settlement_hold_days: Number(values.settlement_hold_days ?? 7), + settlement_hold_days: Number(values.settlement_hold_days ?? defaultSettlementHoldDays), } const res = await onboardSite(payload) message.success('分销站点及分销商管理员开通成功!') diff --git a/docs/分销网管理后台设计方案.md b/docs/分销网管理后台设计方案.md index 494bbd8..7b1b45c 100644 --- a/docs/分销网管理后台设计方案.md +++ b/docs/分销网管理后台设计方案.md @@ -828,4 +828,5 @@ distribution.hao858.com { 5. 兑换比例提价语义明确(比例越小售价越高,提价使分站售价增加)。 - 管理 API 覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。 - 生产与测试口径一致:结算批次入账、站点提现与冷却期自动释放不再按 `APP_ENV` 冻结(原 `liveMoneyMovementEnabled` 生产保护已移除)。 +- 新建站点的默认结算冷却天数由系统配置 `distribution.default_settlement_hold_days` 统一控制(默认 7 天,0=立即释放,最大 365),管理端可在系统配置页调整;站点级 `settlement_hold_days` 仍可单独覆盖。 - `distribution-admin` 基于 React + TypeScript + Vite + Ant Design,覆盖经营大盘、双视图资产管理、选品货架、订单中心、品牌装修与钱包明细。 diff --git a/frontend/src/features/admin/api/adminDistribution.ts b/frontend/src/features/admin/api/adminDistribution.ts index 4f91293..2f21463 100644 --- a/frontend/src/features/admin/api/adminDistribution.ts +++ b/frontend/src/features/admin/api/adminDistribution.ts @@ -119,6 +119,17 @@ export async function fetchDistributionSites() { return data.data.items ?? [] } +export interface DistributionSiteDefaults { + default_settlement_hold_days: number +} + +export async function fetchDistributionSiteDefaults() { + const { data } = await apiClient.get>( + '/admin/distribution/site-defaults' + ) + return data.data +} + export async function onboardDistributionSite(payload: OnboardDistributionSitePayload) { const { data } = await apiClient.post>( '/admin/distribution/sites/onboard', diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index 40ca9fc..5aa437b 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -4,6 +4,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { Plus, Shop, Link, DocumentCopy, Refresh, Clock } from '@element-plus/icons-vue' import { fetchDistributionSites, + fetchDistributionSiteDefaults, onboardDistributionSite, syncDistributionSiteAccounts, updateDistributionSite, @@ -16,6 +17,7 @@ import { readError } from '@/shared/utils/error' const loading = ref(false) const sites = ref([]) +const defaultSettlementHoldDays = ref(7) const onboardDialogVisible = ref(false) const submitting = ref(false) @@ -29,13 +31,13 @@ const settlementSubmitting = ref(false) const settlementForm = reactive({ site_id: 0, site_name: '', - settlement_hold_days: 7, + settlement_hold_days: defaultSettlementHoldDays.value, }) function handleOpenSettlement(row: DistributionSite) { settlementForm.site_id = row.id settlementForm.site_name = row.site_name - settlementForm.settlement_hold_days = row.settlement_hold_days ?? 7 + settlementForm.settlement_hold_days = row.settlement_hold_days ?? defaultSettlementHoldDays.value settlementDialogVisible.value = true } @@ -63,7 +65,7 @@ const onboardForm = reactive({ admin_password: '', admin_nickname: '', member_type: 'operator', - settlement_hold_days: 7, + settlement_hold_days: defaultSettlementHoldDays.value, display_account_enabled: true, tax_enabled: false, tax_rate_bps: 100, @@ -102,7 +104,7 @@ function handleOpenOnboard() { onboardForm.admin_password = '' onboardForm.admin_nickname = '' onboardForm.member_type = 'operator' - onboardForm.settlement_hold_days = 7 + onboardForm.settlement_hold_days = defaultSettlementHoldDays.value onboardForm.display_account_enabled = true onboardForm.tax_enabled = false onboardForm.tax_rate_bps = 100 @@ -171,6 +173,16 @@ async function handleSyncAccounts(row: DistributionSite) { onMounted(() => { loadSites() + fetchDistributionSiteDefaults() + .then(res => { + const days = res?.default_settlement_hold_days + if (typeof days === 'number' && days >= 0 && days <= 365) { + defaultSettlementHoldDays.value = days + } + }) + .catch(() => { + // 拉取失败时保持内置默认值 7,不弹错误提示 + }) })