Files
qitongxue-api/internal/logic/order_pay_test.go
2026-09-29 10:57:01 +08:00

397 lines
14 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 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)
}
}