小程序一些功能更新

This commit is contained in:
李琦
2026-09-29 10:57:01 +08:00
parent 0cb70a7618
commit 3bb7fa8db3
96 changed files with 13890 additions and 213 deletions

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