package logic import ( "context" "fmt" "os" "sync" "testing" "time" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "tool-api/api/admin/v1" "tool-api/internal/consts" "tool-api/internal/model/entity" ) // ensureTestSchema 保证 member_orders 的退款相关列已存在。 // 说明:GoFrame 的 Update(Data(map)) 会**按表结构过滤掉未知列**(静默丢弃), // 故运行期迁移 Migrate() 必须先执行,service_revoked / refunded_at 才会真正落库。 var migrateOnce sync.Once func ensureTestSchema() { migrateOnce.Do(func() { Migrate(context.Background()) }) } // ============================================================================ // 订单退款 / 回收所购服务 · 集成测试(真实库,自建 fixture 并清理) // // 覆盖:会员套餐回收(减天数)、次数包回收(扣到 0 不为负)、不回收(权益保留)、 // 幂等(第二次调用不重复回收)、列表关键字命中。 // ============================================================================ func newTag(prefix string) string { return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano()) } func insertRefundUser(t *testing.T, tag, levelKey string, expire *gtime.Time) int64 { t.Helper() now := gtime.Now() id, err := g.Model(consts.TableUsers).Data(g.Map{ "openid": "refund_" + tag, "username": "refund_" + tag, "nickname": "退款测试" + tag, "level_key": levelKey, "level_expire_at": expire, // nil → 写 NULL(永久) "status": 1, "created_at": now, "updated_at": now, }).InsertAndGetId() if err != nil { t.Fatalf("插入测试用户失败: %v", err) } return id } func insertDeliveredOrder(t *testing.T, userId int64, tag string, orderType int, fields g.Map) string { t.Helper() no := "TREF" + tag now := gtime.Now() data := g.Map{ "out_trade_no": no, "wx_order_id": "wx_" + no, "user_id": userId, "openid": "refund_" + tag, "order_type": orderType, "plan_key": "", "level_key": "", "product_id": "test_product", "price_cents": 1000, "origin_price_cents": 1000, "paid_price_cents": 1000, "discount_cents": 0, "promo_kind": 0, "status": consts.OrderStatusDelivered, "pay_channel": consts.PayChannelWx, "created_at": now, "updated_at": now, "paid_at": now, "delivered_at": now, } for k, v := range fields { data[k] = v } if _, err := g.Model(consts.TableMemberOrders).Data(data).Insert(); err != nil { t.Fatalf("插入测试订单失败: %v", err) } return no } func cleanupRefundFixture(t *testing.T, uid int64, orderNos ...string) { t.Helper() for _, no := range orderNos { if _, err := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).Delete(); err != nil { t.Logf("[cleanup] 删除订单 %s 失败: %v", no, err) } } g.Model(consts.TableUserToolQuota).Where("user_id", uid).Delete() g.Model(consts.TableUsers).Where("id", uid).Delete() } // TestAdminOrderRefund_RevokeMemberService 会员套餐退款回收:减去本单天数 + 幂等。 func TestAdminOrderRefund_RevokeMemberService(t *testing.T) { ensureTestSchema() ctx := context.Background() tag := newTag("rm") uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 60)) no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, }) defer cleanupRefundFixture(t, uid, no) // 读回基线(读回存在已知时区偏移,故一律用「读回 vs 读回」比较,偏移自然抵消) baseU, _ := g.Model(consts.TableUsers).Where("id", uid).One() baseline := baseU["level_expire_at"].GTime() res, err := AdminOrderRefund(ctx, no, true) if err != nil { t.Fatalf("退款失败: %v", err) } if !res.Revoked || !res.ServiceRevoked { t.Fatalf("期望 revoke=true 且执行退款,得 %+v", res) } row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One() if row["status"].Int() != consts.OrderStatusRefunded { t.Fatalf("订单状态应为已退款(2),得 %d", row["status"].Int()) } if row["service_revoked"].Int() != 1 { t.Fatalf("service_revoked 应为 1,得 %d", row["service_revoked"].Int()) } if row["refunded_at"].GTime() == nil || row["refunded_at"].GTime().IsZero() { t.Fatalf("refunded_at 应已写入") } u, _ := g.Model(consts.TableUsers).Where("id", uid).One() newExpire := u["level_expire_at"].GTime() want := baseline.AddDate(0, 0, -30) if newExpire == nil { t.Fatalf("回收后期望仍为有限期(非 NULL)") } if diff := newExpire.Time.Sub(want.Time); diff < -2*time.Minute || diff > 2*time.Minute { t.Fatalf("到期时间应回退 30 天:期望≈%s,得 %s", want.String(), newExpire.String()) } if u["level_key"].String() != "v2" { t.Fatalf("回收后仍未到期,等级不应降级,得 %q", u["level_key"].String()) } // 幂等:再次退款返回成功但不重复回收 res2, err := AdminOrderRefund(ctx, no, true) if err != nil { t.Fatalf("幂等退款失败: %v", err) } if !res2.AlreadyRefunded || res2.Revoked { t.Fatalf("第二次应为幂等命中,得 %+v", res2) } u2, _ := g.Model(consts.TableUsers).Where("id", uid).One() if !u2["level_expire_at"].GTime().Time.Equal(newExpire.Time) { t.Fatalf("幂等调用不应重复回收:%s → %s", newExpire.String(), u2["level_expire_at"].String()) } } // TestAdminOrderRefund_RevokeMemberExpiredDowngrade 回收后已到期 → 期限置当前时刻并回落默认等级。 func TestAdminOrderRefund_RevokeMemberExpiredDowngrade(t *testing.T) { ensureTestSchema() ctx := context.Background() tag := newTag("rd") uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 10)) no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, }) defer cleanupRefundFixture(t, uid, no) if _, err := AdminOrderRefund(ctx, no, true); err != nil { t.Fatalf("退款失败: %v", err) } u, _ := g.Model(consts.TableUsers).Where("id", uid).One() expire := u["level_expire_at"].GTime() if expire == nil { t.Fatalf("回收后期限不应为 NULL(不得写成永久)") } if expire.After(gtime.Now()) { t.Fatalf("回收后应已到期,得 %s", expire.String()) } // 等级回落经 EffectiveLevelKey 收敛(不直接改写 level_key,与自然到期口径一致) eff := EffectiveLevelKey(&entity.Users{LevelKey: u["level_key"].String(), LevelExpireAt: expire}) if eff != consts.DefaultLevelKey { t.Fatalf("回收后已到期,生效等级应回落默认,得 %q", eff) } } // TestAdminOrderRefund_RevokeQuotaClamp 次数包退款回收:扣到 0 为止,不为负。 func TestAdminOrderRefund_RevokeQuotaClamp(t *testing.T) { ensureTestSchema() ctx := context.Background() tag := newTag("rq") toolKey := "refund_test_tool_" + tag uid := insertRefundUser(t, tag, consts.DefaultLevelKey, nil) no1 := insertDeliveredOrder(t, uid, tag+"a", consts.OrderTypeQuota, g.Map{ "pack_key": "pack5", "tool_key": toolKey, "times": 5, "duration_days": 365, }) no2 := insertDeliveredOrder(t, uid, tag+"b", consts.OrderTypeQuota, g.Map{ "pack_key": "pack10", "tool_key": toolKey, "times": 10, "duration_days": 365, }) defer cleanupRefundFixture(t, uid, no1, no2) now := gtime.Now() if _, err := g.Model(consts.TableUserToolQuota).Data(g.Map{ "user_id": uid, "tool_key": toolKey, "times_left": 8, "total_bought": 8, "expire_at": now.AddDate(0, 0, 365), "created_at": now, "updated_at": now, }).Insert(); err != nil { t.Fatalf("插入额度失败: %v", err) } // 第一次:8 - 5 = 3 if _, err := AdminOrderRefund(ctx, no1, true); err != nil { t.Fatalf("退款失败: %v", err) } q, _ := g.Model(consts.TableUserToolQuota).Where("user_id", uid).Where("tool_key", toolKey).One() if q["times_left"].Int() != 3 { t.Fatalf("次数回收后应剩 3,得 %d", q["times_left"].Int()) } // 第二次:3 - 10 → 夹到 0(不为负) if _, err := AdminOrderRefund(ctx, no2, true); err != nil { t.Fatalf("退款失败: %v", err) } q2, _ := g.Model(consts.TableUserToolQuota).Where("user_id", uid).Where("tool_key", toolKey).One() if q2["times_left"].Int() != 0 { t.Fatalf("次数应扣到 0 为止,得 %d", q2["times_left"].Int()) } if q2["total_bought"].Int() < q2["times_left"].Int() { t.Fatalf("total_bought 不应小于 times_left:%d < %d", q2["total_bought"].Int(), q2["times_left"].Int()) } } // TestAdminOrderRefund_PermanentMemberRevoked 永久会员(level_expire_at IS NULL)退款回收: // 用户裁定(2026-09-23)—— 管理员勾选「取消服务」时,永久授权**同样真取消、置为已过期**。 // // 关键反转点(相对旧行为「保留服务」): // 1. level_expire_at 不再为 NULL,且 ≈ 当前时刻(由 DB 侧 NOW() 写入,读写同源、无 8h 时区偏差); // 2. EffectiveLevelKey 随即回落默认等级(降级生效); // 3. level_key 字段本身**保留原值**(失效判定交给 EffectiveLevelKey,不为永久会员加特例清空)。 func TestAdminOrderRefund_PermanentMemberRevoked(t *testing.T) { ensureTestSchema() ctx := context.Background() tag := newTag("pm") uid := insertRefundUser(t, tag, "v2", nil) // level_expire_at = NULL → 永久 no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, }) defer cleanupRefundFixture(t, uid, no) before := gtime.Now() res, err := AdminOrderRefund(ctx, no, true) if err != nil { t.Fatalf("退款失败: %v", err) } if !res.Revoked || !res.ServiceRevoked { t.Fatalf("期望 revoke=true 且确已回收永久授权,得 %+v", res) } row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One() if row["status"].Int() != consts.OrderStatusRefunded { t.Fatalf("订单状态应为已退款(2),得 %d", row["status"].Int()) } if row["service_revoked"].Int() != 1 { t.Fatalf("service_revoked 应为 1,得 %d", row["service_revoked"].Int()) } u, _ := g.Model(consts.TableUsers).Where("id", uid).One() expire := u["level_expire_at"].GTime() // 关键反转点 1:永久(NULL)已被置为「当前时刻」,不再为 NULL if expire == nil || expire.IsZero() { t.Fatalf("永久会员退款回收后 level_expire_at 不应再为 NULL") } // DB 侧 NOW() 写入 + 读侧同源,容许 ±2 分钟 if d := expire.Time.Sub(before.Time); d < -2*time.Minute || d > 2*time.Minute { t.Fatalf("level_expire_at 应≈当前时刻:期望≈%s,得 %s", before.String(), expire.String()) } // 关键反转点 3:level_key 字段本身保留原值,不清空 if u["level_key"].String() != "v2" { t.Fatalf("level_key 字段应保留原值 v2,得 %q", u["level_key"].String()) } // 关键反转点 2:生效等级已回落默认(降级生效)。 // EffectiveLevelKey 内部以严格 `<` 判过期;而取消时刻由 DB 侧 NOW() 写入,因秒级截断与 // DB/进程时钟的亚秒偏差,读回值可能恰落在「当前秒」甚至略晚。故先等待进程时钟越过该时刻, // 再验证降级已生效(<= ~2s,确定性,不受 DB 时钟偏差影响)。 if wait := time.Until(expire.Time.Add(time.Second)); wait > 0 { time.Sleep(wait) } if eff := EffectiveLevelKey(&entity.Users{LevelKey: u["level_key"].String(), LevelExpireAt: expire}); eff != consts.DefaultLevelKey { t.Fatalf("永久会员被回收后生效等级应回落默认,得 %q", eff) } } // TestAdminOrderRefund_RevokeButNothingToReclaim 勾选「取消服务」但用户当前无生效会员(已过期): // service_revoked 按**实际回收结果**记录 → 0(列表不显示「已回收」,避免误导管理员)。 func TestAdminOrderRefund_RevokeButNothingToReclaim(t *testing.T) { ensureTestSchema() ctx := context.Background() tag := newTag("rn") // level_key=v2 但 level_expire_at 早于当前时刻 → 当前无生效会员(已过期) uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, -1)) no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, }) defer cleanupRefundFixture(t, uid, no) res, err := AdminOrderRefund(ctx, no, true) // 明确勾选回收 if err != nil { t.Fatalf("退款失败: %v", err) } if !res.Revoked { t.Fatalf("订单应被标记退款,得 %+v", res) } if res.ServiceRevoked { t.Fatalf("用户当前无生效会员,service_revoked 应为 false,得 %+v", res) } row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One() if row["status"].Int() != consts.OrderStatusRefunded { t.Fatalf("订单状态应为已退款(2),得 %d", row["status"].Int()) } if row["service_revoked"].Int() != 0 { t.Fatalf("无可回收的生效授权时 service_revoked 应为 0,得 %d", row["service_revoked"].Int()) } // 权益本就无效,退款回收后仍为「已过期」(不产生新的生效会员) u, _ := g.Model(consts.TableUsers).Where("id", uid).One() eff := EffectiveLevelKey(&entity.Users{ LevelKey: u["level_key"].String(), LevelExpireAt: u["level_expire_at"].GTime(), }) if eff != consts.DefaultLevelKey { t.Fatalf("已过期会员退款回收后不应变为生效,得 %q", eff) } } // TestAdminOrderRefund_NewWritesNoTZ01Drift 钉住「新代码不得引入 TZ-01」: // 退款流程新引入的 datetime 写入(订单 refunded_at / updated_at、额度 updated_at、永久会员 level_expire_at) // 一律走库内 NOW(),库读回后应≈当前墙钟(±5s),而**不是**早 8h。 func TestAdminOrderRefund_NewWritesNoTZ01Drift(t *testing.T) { ensureTestSchema() ctx := context.Background() // ---- (1) 会员单退款:订单 refunded_at / updated_at ≈ 墙钟 ---- tag := newTag("tzm") uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 60)) no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, }) defer cleanupRefundFixture(t, uid, no) before := gtime.Now() if _, err := AdminOrderRefund(ctx, no, true); err != nil { t.Fatalf("退款失败: %v", err) } row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One() refunded := row["refunded_at"].GTime() if refunded == nil || refunded.IsZero() { t.Fatalf("refunded_at 应写入且非零") } if d := refunded.Time.Sub(before.Time); d < -5*time.Second || d > 5*time.Second { t.Fatalf("refunded_at 应≈墙钟(±5s),得 %s(疑似 TZ-01 8h 偏差)", refunded.String()) } if d := row["updated_at"].GTime().Time.Sub(before.Time); d < -5*time.Second || d > 5*time.Second { t.Fatalf("订单 updated_at 应≈墙钟(±5s),得 %s(疑似 TZ-01 8h 偏差)", row["updated_at"].String()) } // ---- (2) 永久会员退款:level_expire_at 非 NULL 且不晚于库内 NOW() ---- tag2 := newTag("tzp") uid2 := insertRefundUser(t, tag2, "v2", nil) // 永久 no2 := insertDeliveredOrder(t, uid2, tag2, consts.OrderTypeMember, g.Map{ "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, }) defer cleanupRefundFixture(t, uid2, no2) before2 := gtime.Now() if _, err := AdminOrderRefund(ctx, no2, true); err != nil { t.Fatalf("退款失败: %v", err) } u2, _ := g.Model(consts.TableUsers).Where("id", uid2).One() exp := u2["level_expire_at"].GTime() if exp == nil || exp.IsZero() { t.Fatalf("永久会员回收后 level_expire_at 不应为 NULL") } // 库内判定「不晚于 NOW()」:比 Go 侧严格 `<` 更稳,规避秒级边界。 late, _ := g.DB().GetValue(ctx, "SELECT (level_expire_at <= NOW()) FROM "+consts.TableUsers+" WHERE id = ?", uid2) if !late.Bool() { t.Fatalf("永久会员回收后 level_expire_at 应不晚于库内 NOW(),得 %s", exp.String()) } if d := exp.Time.Sub(before2.Time); d < -5*time.Second || d > 5*time.Second { t.Fatalf("level_expire_at 应≈墙钟(±5s),得 %s(疑似 TZ-01 8h 偏差)", exp.String()) } // ---- (3) 次数包退款:额度 updated_at ≈ 墙钟 ---- tag3 := newTag("tzq") toolKey := "tz_tool_" + tag3 uid3 := insertRefundUser(t, tag3, consts.DefaultLevelKey, nil) no3 := insertDeliveredOrder(t, uid3, tag3, consts.OrderTypeQuota, g.Map{ "pack_key": "pack5", "tool_key": toolKey, "times": 5, "duration_days": 365, }) defer cleanupRefundFixture(t, uid3, no3) tn := gtime.Now() if _, err := g.Model(consts.TableUserToolQuota).Data(g.Map{ "user_id": uid3, "tool_key": toolKey, "times_left": 8, "total_bought": 8, "expire_at": tn.AddDate(0, 0, 365), "created_at": tn, "updated_at": tn, }).Insert(); err != nil { t.Fatalf("插入额度失败: %v", err) } beforeQ := gtime.Now() if _, err := AdminOrderRefund(ctx, no3, true); err != nil { t.Fatalf("退款失败: %v", err) } q, _ := g.Model(consts.TableUserToolQuota).Where("user_id", uid3).Where("tool_key", toolKey).One() if d := q["updated_at"].GTime().Time.Sub(beforeQ.Time); d < -5*time.Second || d > 5*time.Second { t.Fatalf("额度 updated_at 应≈墙钟(±5s),得 %s(疑似 TZ-01 8h 偏差)", q["updated_at"].String()) } } // TestAdminOrderRefund_NoRevokeKeepsService 不回收服务:仅改状态,权益保留。 func TestAdminOrderRefund_NoRevokeKeepsService(t *testing.T) { ensureTestSchema() ctx := context.Background() tag := newTag("nr") uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 60)) no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, }) defer cleanupRefundFixture(t, uid, no) baseU, _ := g.Model(consts.TableUsers).Where("id", uid).One() baseline := baseU["level_expire_at"].GTime() res, err := AdminOrderRefund(ctx, no, false) if err != nil { t.Fatalf("退款失败: %v", err) } if !res.Revoked || res.ServiceRevoked { t.Fatalf("不应回收服务,得 %+v", res) } row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One() if row["status"].Int() != consts.OrderStatusRefunded || row["service_revoked"].Int() != 0 { t.Fatalf("状态/service_revoked 不符:%d/%d", row["status"].Int(), row["service_revoked"].Int()) } u, _ := g.Model(consts.TableUsers).Where("id", uid).One() if !u["level_expire_at"].GTime().Time.Equal(baseline.Time) { t.Fatalf("不回收时会员期限应保持不变:%s → %s", baseline.String(), u["level_expire_at"].String()) } } // TestAdminOrderRefund_StatusGuard 非已发货订单退款被拒(可读业务错误,4015)。 func TestAdminOrderRefund_StatusGuard(t *testing.T) { ensureTestSchema() ctx := context.Background() tag := newTag("sg") uid := insertRefundUser(t, tag, consts.DefaultLevelKey, nil) no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, "status": consts.OrderStatusPending, }) defer cleanupRefundFixture(t, uid, no) if _, err := AdminOrderRefund(ctx, no, true); err == nil { t.Fatalf("待支付订单退款应被拒绝") } } // TestAdminOrderList_Keyword 列表关键字命中订单号,且返回商品名与用户信息。 func TestAdminOrderList_Keyword(t *testing.T) { ctx := context.Background() tag := newTag("ls") uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 60)) no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, }) defer cleanupRefundFixture(t, uid, no) res, err := AdminOrderList(ctx, &v1.OrderListReq{Keyword: no}) if err != nil { t.Fatalf("列表失败: %v", err) } if res.Total < 1 { t.Fatalf("按订单号应至少命中 1 条,得 %d", res.Total) } found := false for _, it := range res.List { if it.OutTradeNo == no { found = true if it.Nickname == "" || it.UserId != uid { t.Fatalf("应带归属用户信息,得 %+v", it) } if it.StatusText == "" { t.Fatalf("应带状态文案") } } } if !found { t.Fatalf("未在列表中找到目标订单 %s", no) } } // ============================================================================ // 供 curl 实测用的可选 fixture(默认不运行;设 SEED_REFUND=1 时插入并保留) // ============================================================================ func TestAdminOrderSeedForCurl(t *testing.T) { if os.Getenv("SEED_REFUND") != "1" { t.Skip("未设置 SEED_REFUND=1,跳过 curl fixture 播种") } tag := "curl" + fmt.Sprintf("%d", time.Now().UnixNano()) uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 60)) no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{ "plan_key": "vip-month", "level_key": "v2", "duration_days": 30, }) fmt.Printf("SEED_REFUND uid=%d out_trade_no=%s\n", uid, no) } // TestAdminOrderCleanupForCurl 清理 curl fixture(设 CLEAN_REFUND=1 + REFUND_ORDER_NO/REFUND_UID)。 func TestAdminOrderCleanupForCurl(t *testing.T) { if os.Getenv("CLEAN_REFUND") != "1" { t.Skip("未设置 CLEAN_REFUND=1,跳过 curl fixture 清理") } no := os.Getenv("REFUND_ORDER_NO") uid := os.Getenv("REFUND_UID") if no != "" { if _, err := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).Delete(); err != nil { t.Logf("删除订单失败: %v", err) } } if uid != "" { g.Model(consts.TableUserToolQuota).Where("user_id", uid).Delete() g.Model(consts.TableUsers).Where("id", uid).Delete() } fmt.Printf("CLEAN_REFUND done no=%s uid=%s\n", no, uid) }