691 lines
29 KiB
Go
691 lines
29 KiB
Go
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 := `<xml><Event>xpay_goods_deliver_notify</Event><OutTradeNo>T1</OutTradeNo></xml>`
|
||
refundXML := `<xml><Event>xpay_refund_notify</Event><OutTradeNo>T1</OutTradeNo>` +
|
||
`<OutRefundNo>R1xabc</OutRefundNo>` +
|
||
`<RefundInfo><OutRefundNo>R1xabc</OutRefundNo><RefundFee>1000</RefundFee>` +
|
||
`<RefundStatus>0</RefundStatus><RefundRespCode>0</RefundRespCode></RefundInfo></xml>`
|
||
unknownXML := `<xml><Event>xpay_something_new</Event><OutTradeNo>T1</OutTradeNo></xml>`
|
||
|
||
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(`<xml><Event>xpay_refund_notify</Event><OutTradeNo>T1</OutTradeNo></xml>`))
|
||
if err != nil {
|
||
t.Fatalf("空退款推送解析失败: %v", err)
|
||
}
|
||
if empty.RefundNotifySuccess() {
|
||
t.Fatalf("无退款信息字段时不得判定成功(防字段缺失误判)")
|
||
}
|
||
}
|