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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"})
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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"})
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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); 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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); 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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); 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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"})
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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}) // 明确勾选回收
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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); 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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); 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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); 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, OrderRefundOptions{Manual: true, Reason: "退款测试"})
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, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); 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)
}
// ============================================================================
// 真退款链路新增用例(重放算账 / 发货守卫 / apple 渠道 / 推送解析)
// ============================================================================
// TestAdminOrderRefund_ReplayKeepsLaterOrders 重放算账核心回归(审计场景):
// T 买 A(30d) → 到期 → 再买 B(30d)(未到期)→ 现在退 A。
// 旧实现 `GREATEST(expire - 30d, now)` 会把 B 的天数一起减掉(B 被误伤);
// 重放算账后应保持 B 的到期时间不变(退 A 不影响 B)。
func TestAdminOrderRefund_ReplayKeepsLaterOrders(t *testing.T) {
ensureTestSchema()
ctx := context.Background()
tag := newTag("rp")
// 用户当前到期 = B 的贡献(未来 30 天)
uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 30))
// A:40 天前买的 30 天会员(早已到期消耗完),本单将被退款
noA := insertDeliveredOrder(t, uid, tag+"a", consts.OrderTypeMember, g.Map{
"plan_key": "vip-month", "level_key": "v2", "duration_days": 30,
"paid_at": gtime.Now().AddDate(0, 0, -40),
})
// B:刚刚买的 30 天会员(顺延在 A 之后,是当前到期时间的来源)
noB := insertDeliveredOrder(t, uid, tag+"b", consts.OrderTypeMember, g.Map{
"plan_key": "vip-month", "level_key": "v2", "duration_days": 30,
})
defer cleanupRefundFixture(t, uid, noA, noB)
if _, err := AdminOrderRefund(ctx, noA, OrderRefundOptions{RevokeService: true, Manual: true, Reason: "退款测试"}); err != nil {
t.Fatalf("退款失败: %v", err)
}
u, _ := g.Model(consts.TableUsers).Where("id", uid).One()
expire := u["level_expire_at"].GTime()
if expire == nil || expire.IsZero() {
t.Fatalf("退 A 后 B 的会员期限不应被清掉")
}
// 关键断言:B 的有效期必须仍在未来(旧实现会把它减到当前时刻)
if !expire.After(gtime.Now()) {
t.Fatalf("退 A 不得误伤 B 的天数:expire=%s(应仍为未来 30 天附近)", expire.String())
}
// 到期时间应仍在「现在 + 29~31 天」窗口内(重放后与 B 单独存在时一致)
days := expire.Sub(gtime.Now()).Hours() / 24
if days < 29 || days > 31 {
t.Fatalf("B 的剩余天数应约 30 天,得 %.2f", days)
}
// B 单必须仍未退款
b, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", noB).One()
if b["status"].Int() != consts.OrderStatusDelivered {
t.Fatalf("B 单不应被联动退款,得 %d", b["status"].Int())
}
}
// TestDeliverOrderGuard 终态订单不可被重新发货(推送复活防护):
// 已退款(2)/已关闭(3) 的订单收到任何发货请求都必须被拒绝。
func TestDeliverOrderGuard(t *testing.T) {
ensureTestSchema()
ctx := context.Background()
tag := newTag("dg")
uid := insertRefundUser(t, tag, consts.DefaultLevelKey, nil)
noRefunded := insertDeliveredOrder(t, uid, tag+"r", consts.OrderTypeMember, g.Map{
"plan_key": "vip-month", "level_key": "v2", "duration_days": 30,
"status": consts.OrderStatusRefunded,
})
noClosed := insertDeliveredOrder(t, uid, tag+"c", consts.OrderTypeMember, g.Map{
"plan_key": "vip-month", "level_key": "v2", "duration_days": 30,
"status": consts.OrderStatusClosed,
})
defer cleanupRefundFixture(t, uid, noRefunded, noClosed)
if err := deliverOrder(ctx, noRefunded, "wx_guard_test"); err == nil {
t.Fatalf("已退款订单应拒绝发货")
}
if err := deliverOrder(ctx, noClosed, "wx_guard_test"); err == nil {
t.Fatalf("已关闭订单应拒绝发货")
}
// 用户会员期限不应被偷改
u, _ := g.Model(consts.TableUsers).Where("id", uid).One()
if u["level_expire_at"].GTime() != nil && u["level_expire_at"].GTime().After(gtime.Now()) {
t.Fatalf("守卫应阻止任何权益写入,得 %s", u["level_expire_at"].String())
}
}
// TestAdminOrderRefund_AppleChannel iOS 渠道(apple):开发者无法主动退款,
// API 退款被禁用 → 自动落入手工登记路径,且必须留原因;登记后当场落「已退款」。
func TestAdminOrderRefund_AppleChannel(t *testing.T) {
ensureTestSchema()
ctx := context.Background()
tag := newTag("ios")
uid := insertRefundUser(t, tag, "v2", gtime.Now().AddDate(0, 0, 20))
no := insertDeliveredOrder(t, uid, tag, consts.OrderTypeMember, g.Map{
"plan_key": "vip-month", "level_key": "v2", "duration_days": 30,
"pay_channel": consts.PayChannelIOS,
})
defer cleanupRefundFixture(t, uid, no)
// 无原因 → 拒绝
if _, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true}); err == nil {
t.Fatalf("apple 渠道退款未填原因应被拒绝")
}
// 带原因 → 手工登记成功,当场落退款终态
res, err := AdminOrderRefund(ctx, no, OrderRefundOptions{RevokeService: true, Reason: "App Store 已退款"})
if err != nil {
t.Fatalf("apple 渠道带原因退款应成功: %v", err)
}
if !res.Revoked || res.Status != consts.OrderStatusRefunded {
t.Fatalf("apple 退款应当场落终态,得 %+v", res)
}
row, _ := g.Model(consts.TableMemberOrders).Where("out_trade_no", no).One()
if row["refund_status"].Int() != consts.RefundStatusSuccess || row["refund_amount"].Int64() != 1000 {
t.Fatalf("退款列应落 refund_status=2 / amount=1000,得 %d/%d", row["refund_status"].Int(), row["refund_amount"].Int64())
}
if row["refund_reason"].String() != "App Store 已退款" {
t.Fatalf("退款原因应落库,得 %q", row["refund_reason"].String())
}
if row["service_revoked"].Int() != 1 {
t.Fatalf("勾选回收时应回收,得 %d", row["service_revoked"].Int())
}
}
// TestXPayNotifyEventRouting /pay/notify 事件分流解析:
// 发货事件 / 退款事件 / 未知事件的解析结果与退款成功判定。
func TestXPayNotifyEventRouting(t *testing.T) {
deliverXML := `xpay_goods_deliver_notifyT1`
refundXML := `xpay_refund_notifyT1` +
`R1xabc` +
`R1xabc1000` +
`00`
unknownXML := `xpay_something_newT1`
if ev, err := ParseNotifyEvent([]byte(deliverXML)); err != nil || ev != consts.XPayNotifyEvent {
t.Fatalf("发货事件解析错误: %v %s", err, ev)
}
if ev, err := ParseNotifyEvent([]byte(refundXML)); err != nil || ev != consts.XPayRefundNotifyEvent {
t.Fatalf("退款事件解析错误: %v %s", err, ev)
}
if ev, err := ParseNotifyEvent([]byte(unknownXML)); err != nil || ev != "xpay_something_new" {
t.Fatalf("未知事件解析错误: %v %s", err, ev)
}
n, err := ParseRefundNotify([]byte(refundXML))
if err != nil {
t.Fatalf("退款推送解析失败: %v", err)
}
if !n.RefundNotifySuccess() {
t.Fatalf("RefundStatus=0 + RespCode=0 应判定成功")
}
// 字段缺失(全 0 默认值且无退款单号/金额)→ 判不可定,不得误标成功
empty, err := ParseRefundNotify([]byte(`xpay_refund_notifyT1`))
if err != nil {
t.Fatalf("空退款推送解析失败: %v", err)
}
if empty.RefundNotifySuccess() {
t.Fatalf("无退款信息字段时不得判定成功(防字段缺失误判)")
}
}