fix(distribution): 结算幂等与退款口径加固,补关单释放与回归测试
- 结算明细加唯一键 (order_id, party_id):自动释放与手工批次并发时以唯一键冲突为准跳过该参与方,不再重复入账 - 自动释放先无锁筛出本站未入账参与方,收敛加锁范围;手工批次建批次后先锁周期内财务快照,与自动释放串行化 - 累计退款额改为「本次回调金额与已退款成功流水合计取较大值」,多笔退款与退款结果重复回调不再把口径写小 - 关单三路径(AdminClose / AdminRejectRefund 的 close 分支 / AdminSeal)补写 release_at,接线原死代码 EnsureCompletedReleaseAtTx - 仲裁分销重算改用实际退款额(扣除押金暂扣),避免站点被多扣 - 订单列表与详情的提价分成取 min(收入, 货架加价),退款压缩加价后不再显示负数 - 新增迁移 000070(结算明细唯一键)、000071(补回填 closed 订单释放时间) - 新增 5 个测试文件:结算幂等与展示钳制、结算 job 幂等、累计退款、关单补写释放时间、仲裁押金暂扣口径
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user