feat(distribution): 默认结算冷却天数改为系统配置驱动

新建站点的默认冷却期原先硬编码 7 天(OnboardSite 字面量、DB 列默认、两套前端表单),
改一处要同时动三处。现统一到系统配置键 distribution.default_settlement_hold_days:

- systemconfig 注册该键(默认 7,0=立即释放,0-365 校验),纳入 admin 可见配置
- distribution 建站两条路径(CreateSite 未传值、OnboardSite 缺省)读取该配置,缺失或非法回落 7
- 新增 GET /admin/distribution/site-defaults 暴露当前有效默认值
- Vue / React 站点表单改为运行时拉取,拉取失败保持 7
- DB 列默认值保留作为兜底,站点级 settlement_hold_days 仍可单独覆盖
This commit is contained in:
yml2213
2026-09-10 18:54:23 +08:00
parent dbe9aaabfe
commit e3a75fb2d7
13 changed files with 259 additions and 8 deletions
@@ -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 {
@@ -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 {
@@ -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
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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 {