feat(order): 重构退款全流程并新增申请退款中状态

- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
This commit is contained in:
2026-09-16 03:44:09 +08:00
parent 89b59c7b91
commit b94dd6dffb
29 changed files with 2141 additions and 158 deletions
+113
View File
@@ -0,0 +1,113 @@
package service
import (
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
)
// memberGrantFixture 构造会员包来源(source=1)额度凭证。
func memberGrantFixture(id, amount, used int64) mysqlmodel.QuotaGrant {
return mysqlmodel.QuotaGrant{ID: id, Source: QuotaGrantSourceMember, Amount: amount, Used: used}
}
// otherGrantFixture 构造非会员包来源(加量包/广告/体验)额度凭证。
func otherGrantFixture(id int64, source int8, amount, used int64) mysqlmodel.QuotaGrant {
return mysqlmodel.QuotaGrant{ID: id, Source: source, Amount: amount, Used: used}
}
func TestPickEffectiveMemberGrant(t *testing.T) {
for _, tc := range []struct {
name string
grants []mysqlmodel.QuotaGrant
wantID int64 // 0 表示期望无生效份(nil)
}{
{
name: "月度年度同额只取一份且保留正在使用的份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 30), memberGrantFixture(2, 100, 0)},
wantID: 1,
},
{
name: "额度不同时取额度最大的一份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 0), memberGrantFixture(2, 200, 0)},
wantID: 2,
},
{
name: "旧包用尽后顺延到新购的一份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 100), memberGrantFixture(2, 100, 0)},
wantID: 2,
},
{
name: "同额同用量时优先新购",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 0), memberGrantFixture(2, 100, 0)},
wantID: 2,
},
{
name: "无会员包凭证时返回 nil",
grants: []mysqlmodel.QuotaGrant{otherGrantFixture(1, QuotaGrantSourceAddon, 1000, 0), otherGrantFixture(2, QuotaGrantSourceTrial, 100, 0)},
wantID: 0,
},
{
name: "会员包全部用尽时返回 nil",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 100), memberGrantFixture(2, 50, 50)},
wantID: 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := pickEffectiveMemberGrant(tc.grants)
if tc.wantID == 0 {
if got != nil {
t.Fatalf("want nil, got id=%d", got.ID)
}
return
}
if got == nil || got.ID != tc.wantID {
t.Fatalf("want id=%d, got %+v", tc.wantID, got)
}
})
}
}
func TestPickMemberGrantForRefund(t *testing.T) {
for _, tc := range []struct {
name string
grants []mysqlmodel.QuotaGrant
handled map[int64]bool
wantID int64
}{
{
name: "优先回退当前生效份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 40), memberGrantFixture(2, 100, 10)},
wantID: 1,
},
{
name: "生效份未消耗时复活刚被扣尽的份",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 100), memberGrantFixture(2, 100, 0)},
wantID: 1,
},
{
name: "已处理的份不重复回退",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 40), memberGrantFixture(2, 100, 10)},
handled: map[int64]bool{1: true},
wantID: 2,
},
{
name: "无可退额度时返回 nil",
grants: []mysqlmodel.QuotaGrant{memberGrantFixture(1, 100, 0)},
wantID: 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := pickMemberGrantForRefund(tc.grants, tc.handled)
if tc.wantID == 0 {
if got != nil {
t.Fatalf("want nil, got id=%d", got.ID)
}
return
}
if got == nil || got.ID != tc.wantID {
t.Fatalf("want id=%d, got %+v", tc.wantID, got)
}
})
}
}