Files
affiliate_dash/backend/internal/service/rock_release_test.go
T
zhaoxing 3ea93c0e0d fix: 修复释放回库存的上游限频误判与不可用强制释放
- 110001/请求过于频繁归为 rate_limited,冷却期直接返回剩余秒数且不再请求上游
- 释放校验区分限频、校验失败与可强制释放:仅 upstream_status=bound 允许强制
- 弹窗对限频/校验失败改为可重试或关闭,不再展示必然失败的强制释放输入
- 提交前重新校验按钮复用 checkSeq,避免重复打开弹窗
- 修正绑定刷新测试夹具的上游时间未按业务时区格式化导致的失败
2026-11-15 14:13:39 +08:00

362 lines
13 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
)
// TestCheckReleaseToken_UpstreamStatuses 覆盖释放前上游校验的 6 种结果。
func TestCheckReleaseToken_UpstreamStatuses(t *testing.T) {
db := newRockTestDB(t)
mode := "unbound"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch mode {
case "bound":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "bound",
"bound_account": "买家新角色",
"bound_account_id": "5550001",
"bind_changed_at": time.Now().In(timeutil.Location()).Add(-2 * time.Minute).Format("2006-01-02 15:04:05"),
},
})
case "redeemed":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "redeemed",
"redeemed_at": time.Now().In(timeutil.Location()).Add(-10 * time.Second).Format("2006-01-02 15:04:05"),
},
})
case "abnormal":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{"status": "abnormal"},
})
case "not_found":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"token 不存在"}`))
case "check_failed":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
case "rate_limited":
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"未知错误码: (110001) 抱歉,请求过于频繁,请稍后再试!谢谢!"}`))
default:
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{"status": "using"},
})
}
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
claimedAt := time.Now().Add(-30 * time.Minute)
cases := []struct {
name string
mode string
wantStatus string
wantCan bool
}{
{name: "unbound", mode: "unbound", wantStatus: "unbound", wantCan: true},
{name: "bound", mode: "bound", wantStatus: "bound", wantCan: false},
{name: "redeemed", mode: "redeemed", wantStatus: "redeemed", wantCan: false},
{name: "abnormal", mode: "abnormal", wantStatus: "abnormal", wantCan: false},
{name: "not_found", mode: "not_found", wantStatus: "not_found", wantCan: false},
{name: "check_failed", mode: "check_failed", wantStatus: "check_failed", wantCan: false},
{name: "rate_limited", mode: "rate_limited", wantStatus: "rate_limited", wantCan: false},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer rockUpstreamRateLimitedUntil.Store(0)
mode = tc.mode
token := model.RockToken{
UpstreamTokenID: fmt.Sprintf("release-check-%d", i),
Token: "jwt",
RedeemCode: fmt.Sprintf("CODE-CHECK-%d", i),
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: fmt.Sprintf("ORDER-CHECK-%d", i),
ExpectedUID: "5550001",
ClaimedAt: &claimedAt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
res, err := svc.CheckReleaseToken(context.Background(), token.ID)
if err != nil {
t.Fatalf("校验释放失败: %v", err)
}
if res.UpstreamStatus != tc.wantStatus || res.CanRelease != tc.wantCan {
t.Fatalf("校验结果不符: status=%s can_release=%v message=%s", res.UpstreamStatus, res.CanRelease, res.Message)
}
if res.Message == "" {
t.Fatalf("校验结果必须带提示文案")
}
if tc.wantStatus == "rate_limited" && res.RetryAfterSeconds <= 0 {
t.Fatalf("限频状态必须返回冷却剩余秒数: %+v", res)
}
})
}
}
// 冷却期内释放校验不再请求上游,直接返回可重试的限频状态。
func TestCheckReleaseToken_RateLimitCooldownSkipsUpstream(t *testing.T) {
db := newRockTestDB(t)
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
claimedAt := time.Now().Add(-30 * time.Minute)
token := model.RockToken{
UpstreamTokenID: "release-ratelimit-1",
Token: "jwt",
RedeemCode: "CODE-RATELIMIT-1",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "ORDER-RATELIMIT-1",
ClaimedAt: &claimedAt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
rockUpstreamRateLimitedUntil.Store(time.Now().Add(30 * time.Second).UnixNano())
t.Cleanup(func() { rockUpstreamRateLimitedUntil.Store(0) })
res, err := svc.CheckReleaseToken(context.Background(), token.ID)
if err != nil {
t.Fatalf("校验释放失败: %v", err)
}
if res.UpstreamStatus != "rate_limited" || res.CanRelease {
t.Fatalf("冷却期应返回 rate_limited: %+v", res)
}
if res.RetryAfterSeconds <= 0 || res.RetryAfterSeconds > int(rockRateLimitCooldown/time.Second) {
t.Fatalf("冷却剩余秒数不合理: %d", res.RetryAfterSeconds)
}
if calls.Load() != 0 {
t.Fatalf("冷却期内不应请求上游,实际调用 %d 次", calls.Load())
}
}
// TestReleaseUnboundUsingToken_RequiresForceAndKeepsStatus 验证非强制释放被上游拦截时状态不变。
func TestReleaseUnboundUsingToken_RequiresForceAndKeepsStatus(t *testing.T) {
db := newRockTestDB(t)
server := newBoundReleaseServer(t)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
claimedAt := time.Now().Add(-30 * time.Minute)
token := model.RockToken{
UpstreamTokenID: "release-force-1",
Token: "jwt",
RedeemCode: "CODE-FORCE-1",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "ORDER-FORCE-1",
ExpectedUID: "5550001",
ClaimedAt: &claimedAt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
err := svc.ReleaseUnboundUsingToken(context.Background(), token.ID, false, nil)
var forceErr *RockReleaseRequiresForceError
if !errors.As(err, &forceErr) {
t.Fatalf("非强制释放应返回 RockReleaseRequiresForceError,实际: %v", err)
}
if forceErr.Check.UpstreamStatus != "bound" || forceErr.Check.CanRelease {
t.Fatalf("校验结果不符: %+v", forceErr.Check)
}
var stored model.RockToken
if err := db.First(&stored, token.ID).Error; err != nil {
t.Fatalf("重新读取 Token 失败: %v", err)
}
if stored.Status != model.RockStatusUsing || stored.AssignedOrderNo != "ORDER-FORCE-1" {
t.Fatalf("拒绝释放后状态不应变化: status=%s order=%s", stored.Status, stored.AssignedOrderNo)
}
var logCount int64
db.Model(&model.RockTokenStatusLog{}).Where("token_id = ?", token.ID).Count(&logCount)
if logCount != 0 {
t.Fatalf("拒绝释放不应写状态日志,实际 %d 条", logCount)
}
}
// TestReleaseUnboundUsingToken_ForceClearsFieldsAndWritesLogs 验证人工强制释放清空残留字段并留下追溯记录。
func TestReleaseUnboundUsingToken_ForceClearsFieldsAndWritesLogs(t *testing.T) {
db := newRockTestDB(t)
server := newBoundReleaseServer(t)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
now := time.Now()
claimedAt := now.Add(-30 * time.Minute)
token := model.RockToken{
UpstreamTokenID: "release-force-2",
Token: "jwt",
RedeemCode: "CODE-FORCE-2",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
AssignedOrderNo: "ORDER-FORCE-2",
ExpectedUID: "5550001",
ActualRoleName: "残留角色",
ActualRoleID: "5550001",
ActualChannel: "安卓-微信",
InitialBoundSnapshot: `{"bound":true,"role_id":"5550001"}`,
UpstreamFulfillID: 98765,
MismatchAt: &now,
MismatchReason: "历史不一致",
ClaimedAt: &claimedAt,
UsingAt: &claimedAt,
BoundAt: &now,
RedeemedAt: nil,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := db.Create(&model.FulfillmentOrder{
OrderNo: "ORDER-FORCE-2",
ProductSKU: model.RockSKUBlackTshirt,
ResultData: fmt.Sprintf(`{"provider":"rock","rock_token_id":%d}`, token.ID),
}).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
actor := uint(42)
if err := svc.ReleaseUnboundUsingToken(context.Background(), token.ID, true, &actor); err != nil {
t.Fatalf("强制释放失败: %v", err)
}
var released model.RockToken
if err := db.First(&released, token.ID).Error; err != nil {
t.Fatalf("读取释放后的 Token 失败: %v", err)
}
if released.Status != model.RockStatusAvailable {
t.Fatalf("释放后应为 available,实际 %s", released.Status)
}
if released.AssignedOrderNo != "" || released.ExpectedUID != "" ||
released.ActualRoleName != "" || released.ActualRoleID != "" || released.ActualChannel != "" ||
released.InitialBoundSnapshot != "" || released.MismatchReason != "" {
t.Fatalf("释放后绑定字段未清空: %+v", released)
}
if released.MismatchAt != nil || released.UsingAt != nil || released.BoundAt != nil || released.RedeemedAt != nil {
t.Fatalf("释放后时间字段未清空: using=%v bound=%v redeemed=%v mismatch=%v",
released.UsingAt, released.BoundAt, released.RedeemedAt, released.MismatchAt)
}
if released.UpstreamFulfillID != 0 {
t.Fatalf("释放后 upstream_fulfill_id 应清空,实际 %d", released.UpstreamFulfillID)
}
var logs []model.RockTokenStatusLog
if err := db.Where("token_id = ?", token.ID).Order("id DESC").Find(&logs).Error; err != nil {
t.Fatalf("读取状态日志失败: %v", err)
}
if len(logs) != 1 {
t.Fatalf("强制释放应写 1 条状态日志,实际 %d 条", len(logs))
}
entry := logs[0]
if entry.EventType != rockTokenEventStatusChanged || entry.FromStatus != model.RockStatusUsing ||
entry.ToStatus != model.RockStatusAvailable || entry.Source != rockTokenSourceRelease || !entry.Forced {
t.Fatalf("状态日志字段不符: %+v", entry)
}
if !strings.Contains(entry.Reason, "强制释放") || !strings.Contains(entry.Reason, "bound") {
t.Fatalf("状态日志原因应包含强制标记与上游状态: %s", entry.Reason)
}
if entry.ActorUserID == nil || *entry.ActorUserID != actor {
t.Fatalf("状态日志操作人不符: %+v", entry.ActorUserID)
}
var audit model.AuditLog
if err := db.Where("action = ? AND entity_id = ?", "rock_token.release", fmt.Sprintf("%d", token.ID)).
First(&audit).Error; err != nil {
t.Fatalf("未找到释放审计日志: %v", err)
}
if audit.ActorUserID == nil || *audit.ActorUserID != actor {
t.Fatalf("审计日志操作人不符: %+v", audit.ActorUserID)
}
if !strings.Contains(audit.Metadata, `"forced":true`) || !strings.Contains(audit.Metadata, `"upstream_status":"bound"`) {
t.Fatalf("审计日志元数据不符: %s", audit.Metadata)
}
}
func TestReleaseUnboundUsingToken_ForceStillRejectsRedeemed(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "redeemed",
"redeemed_at": time.Now().In(timeutil.Location()).Add(-time.Minute).Format("2006-01-02 15:04:05"),
},
})
}))
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
now := time.Now()
usingAt := now.Add(-time.Hour)
token := model.RockToken{
UpstreamTokenID: "release-redeemed",
Token: "jwt",
RedeemCode: "CODE-RELEASED",
Color: model.RockColorBlack,
Status: model.RockStatusUsing,
UsingAt: &usingAt,
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
if err := svc.ReleaseUnboundUsingToken(context.Background(), token.ID, true, nil); err == nil {
t.Fatal("上游已发货时强制释放也必须被拒绝")
}
var stored model.RockToken
if err := db.First(&stored, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
if stored.Status != model.RockStatusUsing {
t.Fatalf("拒绝释放后状态不应变化,实际 %s", stored.Status)
}
}
// newBoundReleaseServer 返回一个「上游已扫码绑定」的 mock,用于强制释放场景。
func newBoundReleaseServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": map[string]interface{}{
"status": "bound",
"bound_account": "买家新角色",
"bound_account_id": "5550001",
"bind_changed_at": time.Now().In(timeutil.Location()).Add(-2 * time.Minute).Format("2006-01-02 15:04:05"),
},
})
}))
}