feat(distribution): 移除生产环境资金冻结,测试与生产口径统一

liveMoneyMovementEnabled 按 APP_ENV 在生产冻结结算入账、站点提现与自动释放,
与测试环境行为不一致,且该开关在 APP_ENV 缺失时为 fail-open。本次整体移除:

- 删除 liveMoneyMovementEnabled 与 ErrLiveMoneyMovementFrozen
- MarkSettlementBatch / ApplySiteWithdrawal / AutoReleaseDueSettlements 去掉守卫
- handler 去掉 403 settlement_frozen 映射
- 测试改为断言生产环境结算入账与提现正常执行
This commit is contained in:
yml2213
2026-09-10 18:44:52 +08:00
parent 768902f2f7
commit dbe9aaabfe
5 changed files with 19 additions and 28 deletions
@@ -787,8 +787,6 @@ func writeError(c *gin.Context, err error) {
response.BadRequest(c, "请选择要加价的商品") response.BadRequest(c, "请选择要加价的商品")
case errors.Is(err, ErrInvalidListingStatus): case errors.Is(err, ErrInvalidListingStatus):
response.BadRequest(c, "商品展示状态不正确") response.BadRequest(c, "商品展示状态不正确")
case errors.Is(err, ErrLiveMoneyMovementFrozen):
response.Error(c, http.StatusForbidden, "settlement_frozen", "分销结算与提现已冻结,待分成口径确认后再开放")
case errors.Is(err, ErrSiteAccessDenied): case errors.Is(err, ErrSiteAccessDenied):
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点") response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点")
case errors.Is(err, ErrDependencyUnavailable): case errors.Is(err, ErrDependencyUnavailable):
@@ -29,9 +29,6 @@ func (r *Repository) AutoReleaseDueSettlements(ctx context.Context) (int, error)
if r == nil || r.db == nil { if r == nil || r.db == nil {
return 0, ErrDependencyUnavailable return 0, ErrDependencyUnavailable
} }
if !liveMoneyMovementEnabled() {
return 0, ErrLiveMoneyMovementFrozen
}
now := time.Now() now := time.Now()
var orderIDs []uint64 var orderIDs []uint64
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
@@ -7,7 +7,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"time" "time"
@@ -41,7 +40,6 @@ var (
ErrAdminDisabled = errors.New("admin disabled") ErrAdminDisabled = errors.New("admin disabled")
ErrDuplicateSiteCodeOrDomain = errors.New("distribution site code or domain already exists") ErrDuplicateSiteCodeOrDomain = errors.New("distribution site code or domain already exists")
ErrDuplicateUsername = errors.New("username already exists") ErrDuplicateUsername = errors.New("username already exists")
ErrLiveMoneyMovementFrozen = errors.New("distribution settlement and withdrawal frozen until split rules are verified")
ErrTooManySyncListings = errors.New("too many listings to sync") ErrTooManySyncListings = errors.New("too many listings to sync")
ErrSyncListingNotFound = errors.New("sync listing not found") ErrSyncListingNotFound = errors.New("sync listing not found")
ErrEmptyListingIDs = errors.New("listing ids required") ErrEmptyListingIDs = errors.New("listing ids required")
@@ -54,11 +52,6 @@ var settlementReadyOrderStatuses = []string{"completed", "closed"}
const maxSyncListingIDs = 200 const maxSyncListingIDs = 200
func liveMoneyMovementEnabled() bool {
env := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV")))
return env != "production" && env != "prod"
}
// normalizeSettlementHoldDays 校验站点结算冷却天数,允许 0(订单完成即释放)到 365 天。 // normalizeSettlementHoldDays 校验站点结算冷却天数,允许 0(订单完成即释放)到 365 天。
func normalizeSettlementHoldDays(days int) (int, error) { func normalizeSettlementHoldDays(days int) (int, error) {
if days < 0 || days > 365 { if days < 0 || days > 365 {
@@ -1021,9 +1014,6 @@ func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64,
} }
func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) { func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
if !liveMoneyMovementEnabled() {
return nil, ErrLiveMoneyMovementFrozen
}
var row model.DistributionSettlementBatch var row model.DistributionSettlementBatch
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
@@ -2056,9 +2046,6 @@ func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64)
} }
func (r *Repository) ApplySiteWithdrawal(ctx context.Context, adminID, siteID uint64, req ApplySiteWithdrawalRequest) (*SiteWithdrawalDTO, error) { func (r *Repository) ApplySiteWithdrawal(ctx context.Context, adminID, siteID uint64, req ApplySiteWithdrawalRequest) (*SiteWithdrawalDTO, error) {
if !liveMoneyMovementEnabled() {
return nil, ErrLiveMoneyMovementFrozen
}
if siteID == 0 { if siteID == 0 {
return nil, ErrSiteNotFound return nil, ErrSiteNotFound
} }
@@ -1038,16 +1038,16 @@ func TestSettlementBatchSplitsThreePartyBySite(t *testing.T) {
} }
} }
func TestLiveMoneyMovementFrozenInProduction(t *testing.T) { func TestMoneyMovementNotFrozenInProduction(t *testing.T) {
t.Setenv("APP_ENV", "production") t.Setenv("APP_ENV", "production")
db := distributionTestDB(t) db := distributionTestDB(t)
repo := NewRepository(db) repo := NewRepository(db)
site := model.DistributionSite{SiteCode: "frozen", SiteName: "冻结站", Domain: "frozen.example.com", Status: "active"} site := model.DistributionSite{SiteCode: "prod", SiteName: "生产口径站", Domain: "prod.example.com", Status: "active"}
if err := db.Create(&site).Error; err != nil { if err := db.Create(&site).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
batch := model.DistributionSettlementBatch{ batch := model.DistributionSettlementBatch{
SiteID: site.ID, BatchNo: "B-FROZEN", PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now(), SiteID: site.ID, BatchNo: "B-PROD", PeriodStart: time.Now().Add(-time.Hour), PeriodEnd: time.Now(),
TotalAmountCent: 100, Status: "draft", TotalAmountCent: 100, Status: "draft",
} }
if err := db.Create(&batch).Error; err != nil { if err := db.Create(&batch).Error; err != nil {
@@ -1055,7 +1055,7 @@ func TestLiveMoneyMovementFrozenInProduction(t *testing.T) {
} }
// 结算明细的 order_id / party_id 都带外键,必须先落库对应的订单与参与方。 // 结算明细的 order_id / party_id 都带外键,必须先落库对应的订单与参与方。
order := model.RentalOrder{ order := model.RentalOrder{
OrderNo: "ORD-FROZEN", ListingID: 1, AccountID: 1, OwnerID: 1, RenterID: 1, OrderNo: "ORD-PROD", ListingID: 1, AccountID: 1, OwnerID: 1, RenterID: 1,
Status: "completed", SettlementMode: "platform_managed", Status: "completed", SettlementMode: "platform_managed",
} }
if err := db.Create(&order).Error; err != nil { if err := db.Create(&order).Error; err != nil {
@@ -1073,13 +1073,22 @@ func TestLiveMoneyMovementFrozenInProduction(t *testing.T) {
}).Error; err != nil { }).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != ErrLiveMoneyMovementFrozen { // 生产与测试口径一致:结算入账与站点提现不再按 APP_ENV 冻结。
t.Fatalf("settle err = %v", err) settled, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID)
if err != nil {
t.Fatalf("生产环境结算入账不应被冻结: %v", err)
} }
if _, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{ if settled.Status != "settled" {
t.Fatalf("batch status = %s, want settled", settled.Status)
}
wd, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{
SiteID: site.ID, AmountCent: 1, AccountType: "alipay", AccountName: "x", AccountNo: "1", SiteID: site.ID, AmountCent: 1, AccountType: "alipay", AccountName: "x", AccountNo: "1",
}); err != ErrLiveMoneyMovementFrozen { })
t.Fatalf("withdraw err = %v", err) if err != nil {
t.Fatalf("生产环境站点提现不应被冻结: %v", err)
}
if wd.AmountCent != 1 || wd.Status != "pending" {
t.Fatalf("unexpected withdrawal: %+v", wd)
} }
} }
+1 -1
View File
@@ -827,5 +827,5 @@ distribution.hao858.com {
4. 分站货架自主提价溢价(`shelf_markup_cent`)100% 独享归卖方分站,不参与三方切分; 4. 分站货架自主提价溢价(`shelf_markup_cent`)100% 独享归卖方分站,不参与三方切分;
5. 兑换比例提价语义明确(比例越小售价越高,提价使分站售价增加)。 5. 兑换比例提价语义明确(比例越小售价越高,提价使分站售价增加)。
- 管理 API 覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。 - 管理 API 覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。
- 生产环境默认保持结算入账与站点提现冻结(`liveMoneyMovementEnabled`),待全链路财务验收后开放。 - 生产与测试口径一致:结算批次入账、站点提现与冷却期自动释放不再按 `APP_ENV` 冻结(原 `liveMoneyMovementEnabled` 生产保护已移除)。
- `distribution-admin` 基于 React + TypeScript + Vite + Ant Design,覆盖经营大盘、双视图资产管理、选品货架、订单中心、品牌装修与钱包明细。 - `distribution-admin` 基于 React + TypeScript + Vite + Ant Design,覆盖经营大盘、双视图资产管理、选品货架、订单中心、品牌装修与钱包明细。