397 lines
14 KiB
Go
397 lines
14 KiB
Go
package logic
|
||
|
||
import (
|
||
"context"
|
||
"strconv"
|
||
"sync"
|
||
"testing"
|
||
|
||
"github.com/gogf/gf/v2/database/gdb"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/os/gtime"
|
||
|
||
"tool-api/internal/consts"
|
||
)
|
||
|
||
// ============================================================================
|
||
// 订单两步式单测(T01.6)
|
||
//
|
||
// 分层:
|
||
// - 纯函数(不碰 DB)—— 任何环境 `go test ./...` 都能跑(TestOrderTargetKey / TestVoucherAppliedMessage)。
|
||
// - 真实库集成测试 —— 由 IT_DB 门控(与 softdelete_it_test.go 同一约定):
|
||
// $env:IT_DB=1; go test ./internal/logic/ -run TestOrder -count=1 -v
|
||
// 覆盖:并发同券恰好 1 单成功、更换优惠幂等、reused 语义、关闭幂等 + 释放占用券、
|
||
// 发货只认本地订单行(改 attach.ProductId 不影响发货,S4)。
|
||
//
|
||
// 复用种子数据(plan vip-month / pack quota_50 / tool img-compress)以避免依赖各表 NOT NULL 列细节;
|
||
// 探测数据一律带 __probe 前缀并按固定 userId 清理。
|
||
// ============================================================================
|
||
|
||
// ===== 纯函数 =====
|
||
|
||
func TestOrderTargetKey(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
o *orderSnapshot
|
||
want string
|
||
}{
|
||
{"会员取 plan_key", &orderSnapshot{OrderType: consts.OrderTypeMember, PlanKey: "vip-month", PackKey: "quota_50"}, "vip-month"},
|
||
{"次数包取 pack_key", &orderSnapshot{OrderType: consts.OrderTypeQuota, PlanKey: "", PackKey: "quota_50"}, "quota_50"},
|
||
}
|
||
for _, c := range cases {
|
||
if got := orderTargetKey(c.o); got != c.want {
|
||
t.Errorf("%s: orderTargetKey=%q want %q", c.name, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestVoucherAppliedMessage(t *testing.T) {
|
||
cases := map[int]string{
|
||
consts.PromoKindNone: "已清除优惠",
|
||
consts.PromoKindCode: "优惠码已应用",
|
||
consts.PromoKindCoupon: "优惠券已应用",
|
||
}
|
||
for kind, want := range cases {
|
||
if got := voucherAppliedMessage(kind); got != want {
|
||
t.Errorf("voucherAppliedMessage(%d)=%q want %q", kind, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 真实库集成测试 =====
|
||
|
||
// requireOrderIT 打开 IT 门控并跑一次幂等迁移,确保 member_orders 价格列 / user_coupons 锁列齐备。
|
||
func requireOrderIT(t *testing.T) context.Context {
|
||
t.Helper()
|
||
requireIT(t)
|
||
ctx := context.Background()
|
||
Migrate(ctx)
|
||
return ctx
|
||
}
|
||
|
||
// probeUserId 固定探测用户 id(极大值,避开真实数据;便于跨运行清理)。
|
||
const probeUserId = int64(900000000000000001)
|
||
|
||
// orderProbe 集成测试的探测数据句柄。
|
||
type orderProbe struct {
|
||
userId int64
|
||
planKey string
|
||
packKey string
|
||
toolKey string
|
||
code string
|
||
}
|
||
|
||
func (p *orderProbe) cleanup(ctx context.Context) {
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM member_orders WHERE user_id=?", p.userId)
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM user_coupons WHERE user_id=?", p.userId)
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM user_tool_quota WHERE user_id=?", p.userId)
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM promo_codes WHERE code=?", p.code)
|
||
_, _ = g.DB().Exec(ctx, "DELETE FROM users WHERE id=?", p.userId)
|
||
}
|
||
|
||
// setupOrderProbe 建立探测用户 + 探测优惠码(套餐 / 次数包 / 工具复用种子数据)。
|
||
func setupOrderProbe(ctx context.Context, t *testing.T) *orderProbe {
|
||
t.Helper()
|
||
p := &orderProbe{
|
||
userId: probeUserId,
|
||
planKey: "vip-month", // 种子会员套餐
|
||
packKey: "quota_50", // 种子次数包
|
||
toolKey: "img-compress", // 种子工具
|
||
code: "__PROBE_IPROMO__",
|
||
}
|
||
// 先清理历史残留(上次运行中断可能留下)
|
||
p.cleanup(ctx)
|
||
|
||
if _, err := g.Model(consts.TableUsers).Data(g.Map{
|
||
"id": probeUserId, "openid": "__probe_iopenid__", "nickname": "probe-order",
|
||
"level_key": consts.DefaultLevelKey, "status": 1,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).Insert(); err != nil {
|
||
t.Fatalf("insert probe user: %v", err)
|
||
}
|
||
if _, err := g.Model(consts.TablePromoCodes).Data(g.Map{
|
||
"code": p.code, "product_id": "admin_1", "price_cents": 1,
|
||
"scope": consts.PromoScopeAll, "scope_keys": "[]", "max_uses": 0,
|
||
"used_count": 0, "per_user_limit": 0, "status": 1,
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).Insert(); err != nil {
|
||
t.Fatalf("insert probe promo: %v", err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
// applyInTx 在事务内对某订单应用凭证(复用生产函数 applyVoucherToOrder)。
|
||
func applyInTx(ctx context.Context, t *testing.T, userId int64, outTradeNo, code string, couponId int64) (*Voucher, error) {
|
||
t.Helper()
|
||
var voucher *Voucher
|
||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||
row, err := tx.Model(consts.TableMemberOrders).Where("out_trade_no", outTradeNo).LockUpdate().One()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if row.IsEmpty() {
|
||
return errOrderInvalid("probe order missing: " + outTradeNo)
|
||
}
|
||
o := orderSnapshotFromRecord(row)
|
||
voucher, err = applyVoucherToOrder(ctx, tx, userId, o, code, couponId)
|
||
return err
|
||
})
|
||
return voucher, err
|
||
}
|
||
|
||
// createMemberOrder 建一笔探测会员订单。
|
||
func createMemberOrder(ctx context.Context, t *testing.T, p *orderProbe) *createOrderResult {
|
||
t.Helper()
|
||
res, err := createOrder(ctx, p.userId, consts.OrderTypeMember, "vip_month_990", 990,
|
||
g.Map{"plan_key": p.planKey},
|
||
g.Map{
|
||
"order_type": consts.OrderTypeMember,
|
||
"plan_key": p.planKey,
|
||
"level_key": "v2",
|
||
"duration_days": 30,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("createOrder(member): %v", err)
|
||
}
|
||
return res
|
||
}
|
||
|
||
// TestOrderCreateReused 同商品连续建单 → 待支付单恒为 1 条(reused=true)。
|
||
func TestOrderCreateReused(t *testing.T) {
|
||
ctx := requireOrderIT(t)
|
||
p := setupOrderProbe(ctx, t)
|
||
defer p.cleanup(ctx)
|
||
|
||
r1 := createMemberOrder(ctx, t, p)
|
||
if r1.Reused {
|
||
t.Fatalf("首次建单不应 reused")
|
||
}
|
||
r2 := createMemberOrder(ctx, t, p)
|
||
if !r2.Reused {
|
||
t.Fatalf("第二次建单应 reused")
|
||
}
|
||
if r2.OutTradeNo != r1.OutTradeNo {
|
||
t.Fatalf("reused 应返回同一单号:%s vs %s", r1.OutTradeNo, r2.OutTradeNo)
|
||
}
|
||
n, err := g.Model(consts.TableMemberOrders).
|
||
Where("user_id", p.userId).Where("status", consts.OrderStatusPending).Count()
|
||
if err != nil {
|
||
t.Fatalf("count pending: %v", err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("待支付单应恒为 1 条,实得 %d", n)
|
||
}
|
||
}
|
||
|
||
// TestOrderApplyPromoCodeThenClear 应用优惠码(占用 + 价格快照)→ 幂等重放 → 清除回原价 + 计数回退。
|
||
func TestOrderApplyPromoCodeThenClear(t *testing.T) {
|
||
ctx := requireOrderIT(t)
|
||
p := setupOrderProbe(ctx, t)
|
||
defer p.cleanup(ctx)
|
||
res := createMemberOrder(ctx, t, p)
|
||
|
||
// 1) 应用优惠码
|
||
v, err := applyInTx(ctx, t, p.userId, res.OutTradeNo, p.code, 0)
|
||
if err != nil {
|
||
t.Fatalf("apply promo: %v", err)
|
||
}
|
||
if v.PromoKind != consts.PromoKindCode || v.PaidPriceCents != 1 || v.OriginPriceCents != 990 {
|
||
t.Fatalf("应用优惠码价格快照异常: %+v", v)
|
||
}
|
||
row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", res.OutTradeNo).One()
|
||
if row["paid_price_cents"].Int64() != 1 || row["promo_kind"].Int() != consts.PromoKindCode {
|
||
t.Fatalf("订单行未写价格/凭证快照: paid=%d kind=%d",
|
||
row["paid_price_cents"].Int64(), row["promo_kind"].Int())
|
||
}
|
||
if used, _ := g.Model(consts.TablePromoCodes).Where("code", p.code).Value("used_count"); used.Int() != 1 {
|
||
t.Fatalf("优惠码 used_count 应为 1,实得 %d", used.Int())
|
||
}
|
||
|
||
// 2) 幂等重放:再次应用同一码,used_count 仍为 1(先释放再占用)
|
||
if _, err = applyInTx(ctx, t, p.userId, res.OutTradeNo, p.code, 0); err != nil {
|
||
t.Fatalf("重复应用同一码应幂等: %v", err)
|
||
}
|
||
if used, _ := g.Model(consts.TablePromoCodes).Where("code", p.code).Value("used_count"); used.Int() != 1 {
|
||
t.Fatalf("重复应用后 used_count 应仍为 1,实得 %d", used.Int())
|
||
}
|
||
|
||
// 3) 清除(两空):价格回原价、凭证列清 0、used_count 回退
|
||
if _, err = applyInTx(ctx, t, p.userId, res.OutTradeNo, "", 0); err != nil {
|
||
t.Fatalf("清除优惠: %v", err)
|
||
}
|
||
row, _ = g.Model(consts.TableMemberOrders).Where("out_trade_no", res.OutTradeNo).One()
|
||
if row["paid_price_cents"].Int64() != 990 || row["promo_kind"].Int() != consts.PromoKindNone {
|
||
t.Fatalf("清除后价格未回原价: paid=%d kind=%d",
|
||
row["paid_price_cents"].Int64(), row["promo_kind"].Int())
|
||
}
|
||
if used, _ := g.Model(consts.TablePromoCodes).Where("code", p.code).Value("used_count"); used.Int() != 0 {
|
||
t.Fatalf("清除后 used_count 应回退为 0,实得 %d", used.Int())
|
||
}
|
||
}
|
||
|
||
// TestOrderCloseReleasesCoupon 关闭待支付单:status=3 且占用中的免单券被释放,且关闭幂等。
|
||
func TestOrderCloseReleasesCoupon(t *testing.T) {
|
||
ctx := requireOrderIT(t)
|
||
p := setupOrderProbe(ctx, t)
|
||
defer p.cleanup(ctx)
|
||
res := createMemberOrder(ctx, t, p)
|
||
|
||
// 占用一张免单券
|
||
couponId, err := g.Model(consts.TableUserCoupons).Data(g.Map{
|
||
"coupon_id": 0, "user_id": p.userId, "coupon_title": "探测免单券",
|
||
"coupon_type": consts.CouponTypeFreebie, "kind": consts.CouponKindFreebie,
|
||
"status": consts.UserCouponStatusUnused, "source": consts.UserCouponSourceAdmin,
|
||
"expire_at": gtime.Now().AddDate(1, 0, 0),
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).InsertAndGetId()
|
||
if err != nil {
|
||
t.Fatalf("insert probe coupon: %v", err)
|
||
}
|
||
if _, err = applyInTx(ctx, t, p.userId, res.OutTradeNo, "", couponId); err != nil {
|
||
t.Fatalf("apply coupon: %v", err)
|
||
}
|
||
if st, _ := g.Model(consts.TableUserCoupons).Where("id", couponId).Value("status"); st.Int() != consts.UserCouponStatusUsed {
|
||
t.Fatalf("券应被占用,实得 status=%d", st.Int())
|
||
}
|
||
|
||
// 关闭(幂等两次)
|
||
if err = closeOrder(ctx, res.OutTradeNo); err != nil {
|
||
t.Fatalf("closeOrder: %v", err)
|
||
}
|
||
if err = closeOrder(ctx, res.OutTradeNo); err != nil {
|
||
t.Fatalf("重复关闭应幂等: %v", err)
|
||
}
|
||
row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", res.OutTradeNo).One()
|
||
if row["status"].Int() != consts.OrderStatusClosed {
|
||
t.Fatalf("订单应已关闭,实得 status=%d", row["status"].Int())
|
||
}
|
||
cRow, _ := g.Model(consts.TableUserCoupons).Where("id", couponId).One()
|
||
if cRow["status"].Int() != consts.UserCouponStatusUnused {
|
||
t.Fatalf("关单后占用券应被释放,实得 status=%d", cRow["status"].Int())
|
||
}
|
||
}
|
||
|
||
// TestOrderDeliverIgnoresAttach 发货只认本地订单行(S4):篡改 attach.ProductId 不影响发货。
|
||
func TestOrderDeliverIgnoresAttach(t *testing.T) {
|
||
ctx := requireOrderIT(t)
|
||
p := setupOrderProbe(ctx, t)
|
||
defer p.cleanup(ctx)
|
||
|
||
res, err := createOrder(ctx, p.userId, consts.OrderTypeQuota, "quota_50", 490,
|
||
g.Map{"pack_key": p.packKey, "tool_key": p.toolKey},
|
||
g.Map{
|
||
"order_type": consts.OrderTypeQuota,
|
||
"pack_key": p.packKey,
|
||
"tool_key": p.toolKey,
|
||
"times": 10,
|
||
"duration_days": 365,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("createOrder(quota): %v", err)
|
||
}
|
||
// 篡改 attach:塞入一个假 productId(若发货错误地依赖 attach,则要么发错道具、要么失败)
|
||
if _, err = g.Model(consts.TableMemberOrders).Where("out_trade_no", res.OutTradeNo).Data(g.Map{
|
||
"attach": `{"userId":` + strconv.FormatInt(p.userId, 10) + `,"productId":"HACKED_PRODUCT"}`,
|
||
}).Update(); err != nil {
|
||
t.Fatalf("tamper attach: %v", err)
|
||
}
|
||
|
||
if err = deliverOrder(ctx, res.OutTradeNo, "wx_probe_1"); err != nil {
|
||
t.Fatalf("deliverOrder: %v", err)
|
||
}
|
||
row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", res.OutTradeNo).One()
|
||
if row["status"].Int() != consts.OrderStatusDelivered {
|
||
t.Fatalf("应发货成功,实得 status=%d", row["status"].Int())
|
||
}
|
||
// 发货依据本地订单行的 tool_key / times → 付费额度 +10(与 attach 无关)
|
||
q, _ := g.Model(consts.TableUserToolQuota).
|
||
Where("user_id", p.userId).Where("tool_key", p.toolKey).One()
|
||
if q.IsEmpty() || q["times_left"].Int() != 10 {
|
||
t.Fatalf("发货未按本地订单行发放额度:%+v", q)
|
||
}
|
||
}
|
||
|
||
// TestOrderCouponConcurrentOneWins 并发两张订单抢同一张免单券 → 恰好 1 单成功,另一单 4011。
|
||
func TestOrderCouponConcurrentOneWins(t *testing.T) {
|
||
ctx := requireOrderIT(t)
|
||
p := setupOrderProbe(ctx, t)
|
||
defer p.cleanup(ctx)
|
||
|
||
// 两张不同商品的订单(不同复用键 → 保证是两笔单)
|
||
orderA := createMemberOrder(ctx, t, p)
|
||
orderB, err := createOrder(ctx, p.userId, consts.OrderTypeQuota, "quota_50", 490,
|
||
g.Map{"pack_key": p.packKey, "tool_key": p.toolKey},
|
||
g.Map{
|
||
"order_type": consts.OrderTypeQuota,
|
||
"pack_key": p.packKey,
|
||
"tool_key": p.toolKey,
|
||
"times": 10,
|
||
"duration_days": 365,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("createOrder(quota): %v", err)
|
||
}
|
||
|
||
// 同一张免单券
|
||
couponId, err := g.Model(consts.TableUserCoupons).Data(g.Map{
|
||
"coupon_id": 0, "user_id": p.userId, "coupon_title": "并发探测券",
|
||
"coupon_type": consts.CouponTypeFreebie, "kind": consts.CouponKindFreebie,
|
||
"status": consts.UserCouponStatusUnused, "source": consts.UserCouponSourceAdmin,
|
||
"expire_at": gtime.Now().AddDate(1, 0, 0),
|
||
"created_at": gtime.Now(), "updated_at": gtime.Now(),
|
||
}).InsertAndGetId()
|
||
if err != nil {
|
||
t.Fatalf("insert probe coupon: %v", err)
|
||
}
|
||
|
||
orders := []string{orderA.OutTradeNo, orderB.OutTradeNo}
|
||
errs := make([]error, 2)
|
||
start := make(chan struct{})
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < 2; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
<-start
|
||
errs[i] = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||
row, err := tx.Model(consts.TableMemberOrders).
|
||
Where("out_trade_no", orders[i]).LockUpdate().One()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
o := orderSnapshotFromRecord(row)
|
||
_, err = applyVoucherToOrder(ctx, tx, p.userId, o, "", couponId)
|
||
return err
|
||
})
|
||
}(i)
|
||
}
|
||
close(start)
|
||
wg.Wait()
|
||
|
||
success := 0
|
||
for i, e := range errs {
|
||
if e == nil {
|
||
success++
|
||
continue
|
||
}
|
||
if codeOf(e) != consts.CodeCouponUnavailable {
|
||
t.Errorf("第 %d 单失败方应为 4011,实得 code=%d err=%v", i, codeOf(e), e)
|
||
}
|
||
}
|
||
if success != 1 {
|
||
t.Fatalf("并发同券应恰好 1 单成功,实得 %d 单", success)
|
||
}
|
||
// 券只对应 1 个订单号,且处于占用态
|
||
cRow, _ := g.Model(consts.TableUserCoupons).Where("id", couponId).One()
|
||
if cRow["status"].Int() != consts.UserCouponStatusUsed || cRow["used_order_no"].String() == "" {
|
||
t.Fatalf("券应被恰好一单占用,实得 status=%d order=%q",
|
||
cRow["status"].Int(), cRow["used_order_no"].String())
|
||
}
|
||
// 恰有一笔订单写入了券
|
||
n, _ := g.Model(consts.TableMemberOrders).
|
||
Where("user_id", p.userId).Where("user_coupon_id", couponId).Count()
|
||
if n != 1 {
|
||
t.Fatalf("应恰有 1 笔订单占用该券,实得 %d", n)
|
||
}
|
||
}
|