fix: 修复释放回库存的上游限频误判与不可用强制释放

- 110001/请求过于频繁归为 rate_limited,冷却期直接返回剩余秒数且不再请求上游
- 释放校验区分限频、校验失败与可强制释放:仅 upstream_status=bound 允许强制
- 弹窗对限频/校验失败改为可重试或关闭,不再展示必然失败的强制释放输入
- 提交前重新校验按钮复用 checkSeq,避免重复打开弹窗
- 修正绑定刷新测试夹具的上游时间未按业务时区格式化导致的失败
This commit is contained in:
2026-11-15 14:13:39 +08:00
parent 6a87fe5228
commit 3ea93c0e0d
7 changed files with 180 additions and 13 deletions
@@ -209,7 +209,7 @@ func TestRefreshTokenBinding_SyncsRedeemed(t *testing.T) {
"redeemed_at": now.Add(-20 * time.Second).In(timeutil.Location()).Format("2006-01-02 15:04:05"),
},
"deliveryList": []map[string]interface{}{
{"fulfillId": 4519960, "time": now.Add(-10 * time.Second).Format("2006-01-02T15:04:05.1"), "role": "安卓-微信-痛写心扉"},
{"fulfillId": 4519960, "time": now.Add(-10 * time.Second).In(timeutil.Location()).Format("2006-01-02T15:04:05.1"), "role": "安卓-微信-痛写心扉"},
},
})
}))
@@ -260,7 +260,7 @@ func TestRefreshTokenBinding_IgnoresHistoricalRedeemedAt(t *testing.T) {
"redeemed_at": historical.In(timeutil.Location()).Format("2006-01-02 15:04:05"),
},
"deliveryList": []map[string]interface{}{
{"fulfillId": 100, "time": historical.Format("2006-01-02T15:04:05.1")},
{"fulfillId": 100, "time": historical.In(timeutil.Location()).Format("2006-01-02T15:04:05.1")},
},
})
}))
@@ -36,6 +36,19 @@ func rockUpstreamRateLimitActive() bool {
return until > 0 && time.Now().UnixNano() < until
}
// rockUpstreamRateLimitRemaining 返回上游全局冷却的剩余时间,未处于冷却期时返回 0。
func rockUpstreamRateLimitRemaining() time.Duration {
until := rockUpstreamRateLimitedUntil.Load()
if until == 0 {
return 0
}
remain := time.Until(time.Unix(0, until))
if remain <= 0 {
return 0
}
return remain
}
func isRockRateLimitError(err error) bool {
if err == nil {
return false
+29 -1
View File
@@ -324,10 +324,12 @@ func extractRockFulfillID(ref *RockRefreshResponse, baseline time.Time) int64 {
// RockReleaseCheckResult 是释放回库存前的上游校验结果。
type RockReleaseCheckResult struct {
CanRelease bool `json:"can_release"`
UpstreamStatus string `json:"upstream_status"` // unbound / bound / redeemed / abnormal / not_found / check_failed
UpstreamStatus string `json:"upstream_status"` // unbound / bound / redeemed / abnormal / not_found / check_failed / rate_limited
RoleName string `json:"role_name,omitempty"`
RoleID string `json:"role_id,omitempty"`
Message string `json:"message"`
// RetryAfterSeconds 仅在上游限频(110001)时返回,提示前端冷却剩余秒数。
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
}
// RockReleaseRequiresForceError 表示上游状态不允许直接释放,需要人工强制确认。
@@ -375,6 +377,16 @@ func (s *RockService) checkReleaseToken(ctx context.Context, tokenID uint) (*mod
}, nil
}
// 全局频控冷却期内不再请求上游,直接返回可重试的限频状态。
if remain := rockUpstreamRateLimitRemaining(); remain > 0 {
seconds := int((remain + time.Second - 1) / time.Second)
return &token, &RockReleaseCheckResult{
UpstreamStatus: "rate_limited",
Message: fmt.Sprintf("上游限频冷却中,请 %d 秒后重试", seconds),
RetryAfterSeconds: seconds,
}, nil
}
ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
if err != nil {
if isRockTokenNotFoundError(err) {
@@ -383,6 +395,19 @@ func (s *RockService) checkReleaseToken(ctx context.Context, tokenID uint) (*mod
Message: "上游卡密不存在(可能已失效)",
}, nil
}
// 110001 是上游全局频控,不是 Token 状态问题,单独归为可重试状态。
if isRockRateLimitError(err) {
markRockUpstreamRateLimited()
seconds := int((rockUpstreamRateLimitRemaining() + time.Second - 1) / time.Second)
if seconds <= 0 {
seconds = int(rockRateLimitCooldown / time.Second)
}
return &token, &RockReleaseCheckResult{
UpstreamStatus: "rate_limited",
Message: fmt.Sprintf("上游请求过于频繁(110001),请 %d 秒后重试", seconds),
RetryAfterSeconds: seconds,
}, nil
}
return &token, &RockReleaseCheckResult{
UpstreamStatus: "check_failed",
Message: "确认上游状态失败: " + err.Error(),
@@ -456,6 +481,9 @@ func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint
if !check.CanRelease && !force {
return &RockReleaseRequiresForceError{Check: *check}
}
if check.UpstreamStatus == "rate_limited" {
return errors.New("上游限频(110001),请稍后重新校验通过后再释放")
}
switch check.UpstreamStatus {
case "redeemed", "abnormal", "not_found", "check_failed":
return fmt.Errorf("上游状态为 %s,禁止释放回库存", check.UpstreamStatus)
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
@@ -49,6 +50,9 @@ func TestCheckReleaseToken_UpstreamStatuses(t *testing.T) {
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"},
@@ -73,9 +77,11 @@ func TestCheckReleaseToken_UpstreamStatuses(t *testing.T) {
{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),
@@ -101,10 +107,58 @@ func TestCheckReleaseToken_UpstreamStatuses(t *testing.T) {
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)