小程序一些功能更新
This commit is contained in:
396
internal/logic/order_pay_test.go
Normal file
396
internal/logic/order_pay_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user