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
+193
View File
@@ -0,0 +1,193 @@
package service
import (
"context"
"testing"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/gorm"
)
// setupQuotaTest 固定免费额度配置为内置默认值(Cos 20 / AI 3 / 分享 10 / 衣柜 5),保证断言确定。
func setupQuotaTest(t *testing.T) sqlmock.Sqlmock {
t.Helper()
old := global.UserDefaultQuotaSetting
global.UserDefaultQuotaSetting = nil
t.Cleanup(func() { global.UserDefaultQuotaSetting = old })
return setupOrderQuotaTest(t)
}
func TestEffectiveQuotaMemberGrantsNotStacked(t *testing.T) {
mock := setupQuotaTest(t)
// Cos 永久额度:免费 20 已用 0;会员包同额两份只计一份,加量包/体验额度仍叠加。
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
grants := []mysqlmodel.QuotaGrant{
memberGrantFixture(1, 100, 10), // 月度包:生效份(同额时保留正在使用的一份)
memberGrantFixture(2, 100, 0), // 年度包:不叠加,不计入
otherGrantFixture(3, QuotaGrantSourceAddon, 5, 2), // 加量包:叠加
otherGrantFixture(4, QuotaGrantSourceTrial, 7, 0), // 体验额度:叠加
}
eff := effectiveQuotaFromGrants(context.Background(), 7, QuotaTypeCos, grants)
// 免费 20 + 会员包 100 + 加量包 5 + 体验 7 = 132;已用 0 + 10 + 2 + 0 = 12。
if eff.Total != 132 || eff.Used != 12 || eff.Remain != 120 {
t.Fatalf("cos quota = %+v, want total=132 used=12 remain=120", eff)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestEffectiveQuotaDailyGrantsNotStacked(t *testing.T) {
mock := setupQuotaTest(t)
// AI 每日额度:免费 3,当日已用 1,会员包同额两份只计一份,广告激励仍叠加。
mock.ExpectQuery("SELECT .* FROM `user_daily_usages`").WillReturnRows(sqlmock.NewRows([]string{"ai_recommend_count"}).AddRow(1))
grants := []mysqlmodel.QuotaGrant{
memberGrantFixture(1, 50, 0),
memberGrantFixture(2, 50, 0),
otherGrantFixture(3, QuotaGrantSourceAdReward, 10, 0),
}
eff := effectiveQuotaFromGrants(context.Background(), 7, QuotaTypeAI, grants)
// 免费 3 + 会员包 50 + 广告 10 = 63;当日已用 1。
if eff.Total != 63 || eff.Used != 1 || eff.Remain != 62 {
t.Fatalf("ai quota = %+v, want total=63 used=1 remain=62", eff)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestConsumeCosQuotaOnlyConsumesEffectiveMemberGrant(t *testing.T) {
mock := setupQuotaTest(t)
future := nowUnix() + 86400
// 有效凭证:加量包(先到期先用)→ 月度包(生效份)→ 年度包(未生效,不得扣减)。
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectBegin()
mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).
AddRow(3, QuotaGrantSourceAddon, 10, 0, future).
AddRow(1, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 加量包 +10
mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 月度包 +5(年度包不参与扣减)
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).
AddRow(3, QuotaGrantSourceAddon, 10, 10, future).
AddRow(1, QuotaGrantSourceMember, 100, 35, 0).
AddRow(2, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(9, 1))
mock.ExpectCommit()
if err := consumeCosQuota(context.Background(), 7, 15, 123, "上传衣物"); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRefundCosQuotaOnlyRefundsEffectiveMemberGrant(t *testing.T) {
mock := setupQuotaTest(t)
mock.ExpectBegin()
// 免费额度已用 5,不足整体回退 10。
mock.ExpectExec("UPDATE .*`user_quotas`.*").WillReturnResult(sqlmock.NewResult(0, 0))
// 非会员包凭证无已用。
mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}))
// 会员包凭证:月度包为生效份(used=30),年度包未生效。
mock.ExpectQuery("SELECT .* FROM `quota_grants`.*FOR UPDATE").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("UPDATE .*`quota_grants`.*").WillReturnResult(sqlmock.NewResult(0, 1)) // 月度包 -10
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(5))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaGrantSourceMember, 100, 20, 0).
AddRow(2, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(9, 1))
mock.ExpectCommit()
vipExpire := nowUnix() + 86400
if err := global.MysqlEngine.WithContext(context.Background()).Transaction(func(tx *gorm.DB) error {
return refundCosQuotaTx(context.Background(), tx, 7, vipExpire, 10, 123, "上传失败回退")
}); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestBuildQuotaRevokeFlowsSkipsNonEffectiveMemberGrant(t *testing.T) {
mock := setupQuotaTest(t)
// 订单凭证是年度包(未生效),月度包(生效份)来自其他订单:
// 吊销年度包不改变有效剩余,不应写回收流水。
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0).
AddRow(2, QuotaTypeCos, QuotaGrantSourceMember, 100, 0, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
flows, err := NewOrderService().buildQuotaRevokeFlows(context.Background(), &mysqlmodel.Order{OrderNo: "O1", UserID: 7})
if err != nil {
t.Fatal(err)
}
if len(flows) != 0 {
t.Fatalf("want no revoke flows, got %d", len(flows))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestBuildQuotaRevokeFlowsRecordsEffectiveGrantRevoke(t *testing.T) {
mock := setupQuotaTest(t)
// 订单凭证即生效份(月度包 100/30):吊销前有效剩余 20+70=90,吊销后仅剩免费 20,
// 回收流水变更量应为 -70。
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
mock.ExpectQuery("SELECT .* FROM `quota_grants`").
WillReturnRows(sqlmock.NewRows([]string{"id", "quota_type", "source", "amount", "used", "expire_time"}).
AddRow(1, QuotaTypeCos, QuotaGrantSourceMember, 100, 30, 0))
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
flows, err := NewOrderService().buildQuotaRevokeFlows(context.Background(), &mysqlmodel.Order{OrderNo: "O2", UserID: 7})
if err != nil {
t.Fatal(err)
}
if len(flows) != 1 {
t.Fatalf("want 1 revoke flow, got %d", len(flows))
}
f := flows[0]
if f.ChangeNum != -70 || f.OldValue != 90 || f.NewValue != 20 || f.QuotaType != QuotaTypeCos {
t.Fatalf("flow = %+v, want change=-70 old=90 new=20", f)
}
}