fix(distribution): 结算幂等与退款口径加固,补关单释放与回归测试

- 结算明细加唯一键 (order_id, party_id):自动释放与手工批次并发时以唯一键冲突为准跳过该参与方,不再重复入账
- 自动释放先无锁筛出本站未入账参与方,收敛加锁范围;手工批次建批次后先锁周期内财务快照,与自动释放串行化
- 累计退款额改为「本次回调金额与已退款成功流水合计取较大值」,多笔退款与退款结果重复回调不再把口径写小
- 关单三路径(AdminClose / AdminRejectRefund 的 close 分支 / AdminSeal)补写 release_at,接线原死代码 EnsureCompletedReleaseAtTx
- 仲裁分销重算改用实际退款额(扣除押金暂扣),避免站点被多扣
- 订单列表与详情的提价分成取 min(收入, 货架加价),退款压缩加价后不再显示负数
- 新增迁移 000070(结算明细唯一键)、000071(补回填 closed 订单释放时间)
- 新增 5 个测试文件:结算幂等与展示钳制、结算 job 幂等、累计退款、关单补写释放时间、仲裁押金暂扣口径
This commit is contained in:
yml2213
2026-09-10 19:13:39 +08:00
parent e3a75fb2d7
commit 36ebe7a5f1
13 changed files with 1017 additions and 10 deletions
@@ -6,6 +6,7 @@ import (
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/distribution"
"hfb_sys/backend/internal/modules/notification"
"gorm.io/gorm"
@@ -90,6 +91,12 @@ func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uin
if err := tx.Save(order).Error; err != nil {
return err
}
// 关单同样属于可释放终态,补写释放时间,避免不退款关单的订单永不自动释放。
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil {
return err
}
}
if err := tx.Save(listing).Error; err != nil {
return err
}
@@ -498,6 +505,13 @@ func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, acti
if err := tx.Save(order).Error; err != nil {
return err
}
// 关单同样属于可释放终态,补写释放时间,避免不退款关单的订单永不自动释放;
// restore 分支订单未终结,不在此列。
if order.Status == orderStatusClosed && order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil {
return err
}
}
if err := tx.Save(listing).Error; err != nil {
return err
}
@@ -591,6 +605,12 @@ func (r *Repository) AdminSeal(ctx context.Context, adminID uint64, orderID uint
if err := tx.Save(order).Error; err != nil {
return err
}
// 关单同样属于可释放终态,补写释放时间,避免不退款关单的订单永不自动释放。
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
if err := distribution.EnsureCompletedReleaseAtTx(tx, order.ID); err != nil {
return err
}
}
if err := tx.Save(listing).Error; err != nil {
return err
}
@@ -0,0 +1,238 @@
package order
import (
"context"
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
// ensureDistributionTables 补齐分销表(测试库由迁移克隆,通常已存在,这里是幂等兜底)。
func ensureDistributionTables(t *testing.T, db *gorm.DB) {
t.Helper()
if err := database.AutoMigrateIfMissing(db,
&model.DistributionSite{},
&model.DistributionRuleVersion{},
&model.DistributionOrderFinancial{},
); err != nil {
t.Fatalf("补齐分销表失败: %v", err)
}
}
// createDistributionSiteOrderFixture 构造分销站点订单:分销站点(冷却 3 天)+ 生效规则版本 +
// 进行中订单(非终态,未结算)+ 待结算财务快照(release_at 为空)。
func createDistributionSiteOrderFixture(t *testing.T, db *gorm.DB, orderNo, siteCode, domain, phonePrefix string) (model.RentalOrder, model.DistributionOrderFinancial, model.DistributionSite) {
t.Helper()
ensureDistributionTables(t, db)
owner := model.User{Phone: phonePrefix + "01"}
renter := model.User{Phone: phonePrefix + "02"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("创建号主失败: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("创建租客失败: %v", err)
}
account := model.GameAccount{
OwnerID: owner.ID,
ServerRegion: "国服",
LoginPlatform: "steam",
Title: "关单释放测试账号",
Status: accountStatusRented,
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("创建账号失败: %v", err)
}
listing := model.RentalListing{
AccountID: account.ID,
OwnerID: owner.ID,
PriceCent: 10000,
Status: listingStatusRented,
ReviewStatus: listingReviewStatusApproved,
InTransaction: true,
}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("创建商品失败: %v", err)
}
site := model.DistributionSite{
SiteCode: siteCode,
SiteName: "关单释放站",
Domain: domain,
Status: "active",
DisplayAccountEnabled: true,
SettlementHoldDays: 3,
}
if err := db.Create(&site).Error; err != nil {
t.Fatalf("创建分销站点失败: %v", err)
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID,
Mode: "two_party_distributor_receive",
Version: 1,
Status: "active",
DistributorReceiveRatio: 35,
PlatformSellRatio: 40,
ServiceFeeValue: 25,
ServiceFeeUnit: "percent",
PaymentFeeBPS: 50,
EffectiveFrom: time.Now().Add(-time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatalf("创建规则版本失败: %v", err)
}
order := model.RentalOrder{
OrderNo: orderNo,
ListingID: listing.ID,
AccountID: account.ID,
OwnerID: owner.ID,
RenterID: renter.ID,
RentAmountCent: 10000,
OwnerRentAmountCent: 8000,
DepositAmountCent: 2000,
Status: orderStatusRenting,
HandoffStatus: handoffStatusReceived,
SettlementStatus: settlementStatusUnsettled,
DistributionSiteID: &site.ID,
DistributionRuleVersionID: &rule.ID,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
financial := model.DistributionOrderFinancial{
OrderID: order.ID,
SiteID: site.ID,
RuleVersionID: rule.ID,
GrossPaidAmountCent: 10000,
NetPaidAmountCent: 10000,
SplitBaseAmountCent: 2000,
SettlementStatus: "pending",
}
if err := db.Create(&financial).Error; err != nil {
t.Fatalf("创建分销财务快照失败: %v", err)
}
return order, financial, site
}
// TestAdminCloseWritesDistributionReleaseAt 断言分销站点订单被客服关单后,会在同一事务内
// 补写 distribution_order_financials.release_at(= settled_at + 站点 settlement_hold_days)。
// 自动释放任务要求 release_at IS NOT NULL,缺少补写时这类关单订单永远不会被结算。
func TestAdminCloseWritesDistributionReleaseAt(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db, Dependencies{
RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunding", nil
}),
})
order, financial, site := createDistributionSiteOrderFixture(t, db, "ORD-CLOSE-REL-1", "close_rel_site", "close-rel.dist.com", "138000090")
if err := repo.AdminClose(t.Context(), 9001, order.ID, AdminActionRequest{Reason: "客服核实后关单"}, AuditMeta{}); err != nil {
t.Fatalf("AdminClose() error = %v", err)
}
var savedOrder model.RentalOrder
if err := db.First(&savedOrder, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if savedOrder.Status != orderStatusClosed || savedOrder.SettlementStatus != settlementStatusClosed {
t.Fatalf("关单后订单状态 = %s/%s, want %s/%s", savedOrder.Status, savedOrder.SettlementStatus, orderStatusClosed, settlementStatusClosed)
}
if savedOrder.SettledAt == nil {
t.Fatal("关单应写入 settled_at")
}
var savedFinancial model.DistributionOrderFinancial
if err := db.First(&savedFinancial, financial.ID).Error; err != nil {
t.Fatalf("读取分销财务快照失败: %v", err)
}
if savedFinancial.ReleaseAt == nil {
t.Fatal("关单后应补写分销快照 release_at,否则该订单永远不会被自动释放结算")
}
want := savedOrder.SettledAt.AddDate(0, 0, site.SettlementHoldDays)
if !savedFinancial.ReleaseAt.Equal(want) {
t.Fatalf("release_at = %v, want %v(settled_at + %d 天)", savedFinancial.ReleaseAt, want, site.SettlementHoldDays)
}
}
// TestAdminCloseWithoutDistributionSiteIsNoop 断言非分销订单(DistributionSiteID 为空)
// 关单不受补写逻辑影响:没有分销快照也不报错,订单照常关闭,用来钉住无分销时为 no-op。
func TestAdminCloseWithoutDistributionSiteIsNoop(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db, Dependencies{
RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunding", nil
}),
})
ensureDistributionTables(t, db)
owner := model.User{Phone: "13800009501"}
renter := model.User{Phone: "13800009502"}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("创建号主失败: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("创建租客失败: %v", err)
}
account := model.GameAccount{
OwnerID: owner.ID,
ServerRegion: "国服",
LoginPlatform: "steam",
Title: "非分销关单账号",
Status: accountStatusRented,
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("创建账号失败: %v", err)
}
listing := model.RentalListing{
AccountID: account.ID,
OwnerID: owner.ID,
PriceCent: 10000,
Status: listingStatusRented,
ReviewStatus: listingReviewStatusApproved,
InTransaction: true,
}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("创建商品失败: %v", err)
}
order := model.RentalOrder{
OrderNo: "ORD-CLOSE-NODIST-1",
ListingID: listing.ID,
AccountID: account.ID,
OwnerID: owner.ID,
RenterID: renter.ID,
RentAmountCent: 10000,
OwnerRentAmountCent: 8000,
DepositAmountCent: 2000,
Status: orderStatusRenting,
HandoffStatus: handoffStatusReceived,
SettlementStatus: settlementStatusUnsettled,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
if err := repo.AdminClose(t.Context(), 9002, order.ID, AdminActionRequest{Reason: "普通订单关单"}, AuditMeta{}); err != nil {
t.Fatalf("无分销站点订单关单不应报错,got %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if saved.Status != orderStatusClosed {
t.Fatalf("订单状态 = %s, want %s", saved.Status, orderStatusClosed)
}
var snapshotCount int64
if err := db.Model(&model.DistributionOrderFinancial{}).Where("order_id = ?", order.ID).
Count(&snapshotCount).Error; err != nil {
t.Fatalf("统计分销财务快照失败: %v", err)
}
if snapshotCount != 0 {
t.Fatalf("非分销订单不应产生分销财务快照,got %d", snapshotCount)
}
}