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) } }