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("无退款信息字段时不得判定成功(防字段缺失误判)") } }