Files
zt-closet-backend/internal/service/cos_quota_test.go
T
zhengchiliu b94dd6dffb feat(order): 重构退款全流程并新增申请退款中状态
- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水
- 退款失败回退订单至已支付并恢复被取消的权益发放任务
- 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正
- 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份
- 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录
- 同步更新 README 与 Stitch 设计文档
2026-09-16 03:44:09 +08:00

194 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}