fix: 修复释放回库存的上游限频误判与不可用强制释放
- 110001/请求过于频繁归为 rate_limited,冷却期直接返回剩余秒数且不再请求上游 - 释放校验区分限频、校验失败与可强制释放:仅 upstream_status=bound 允许强制 - 弹窗对限频/校验失败改为可重试或关闭,不再展示必然失败的强制释放输入 - 提交前重新校验按钮复用 checkSeq,避免重复打开弹窗 - 修正绑定刷新测试夹具的上游时间未按业务时区格式化导致的失败
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -822,6 +822,8 @@ export interface RockReleaseCheckResult {
|
||||
role_name?: string
|
||||
role_id?: string
|
||||
message: string
|
||||
// 仅上游限频(110001)时返回,冷却剩余秒数
|
||||
retry_after_seconds?: number
|
||||
}
|
||||
|
||||
export interface RockTokenStatusLog {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert, Descriptions, Grid, Input, Modal, Space, Tag, Typography, message } from 'antd'
|
||||
import { Alert, Button, Descriptions, Grid, Input, Modal, Space, Tag, Typography, message } from 'antd'
|
||||
import { ExclamationCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
rockApi,
|
||||
@@ -36,6 +36,8 @@ export function ReleaseTokenModal({
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState('')
|
||||
const [forceText, setForceText] = useState('')
|
||||
// 重新校验触发器:上游限频或校验失败后无需关闭弹窗即可重试。
|
||||
const [checkSeq, setCheckSeq] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !token) return
|
||||
@@ -61,16 +63,30 @@ export function ReleaseTokenModal({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, token])
|
||||
}, [open, token, checkSeq])
|
||||
|
||||
if (!token) return null
|
||||
|
||||
const needForce = Boolean(check && !check.can_release)
|
||||
// 只有「上游已绑定」属于人工可确认后强制释放的范围;
|
||||
// 已发货、失效、卡密不存在、校验失败、限频即使强制也不能放行。
|
||||
const forceAllowed = check?.upstream_status === 'bound'
|
||||
const blocked = Boolean(check && !check.can_release && !forceAllowed)
|
||||
const rateLimited = check?.upstream_status === 'rate_limited'
|
||||
// 校验直接失败(如本地状态已变化)时只允许关闭,避免出现灰掉的释放按钮。
|
||||
const closeOnly = blocked || Boolean(checkError)
|
||||
const forceConfirmed = forceText.trim() === FORCE_CONFIRM_TEXT
|
||||
const canSubmit =
|
||||
Boolean(check) && !loadingCheck && !submitting && (!needForce || forceConfirmed)
|
||||
Boolean(check) &&
|
||||
!loadingCheck &&
|
||||
!submitting &&
|
||||
(Boolean(check?.can_release) || (forceAllowed && forceConfirmed))
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (closeOnly) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
setSubmitError('')
|
||||
@@ -103,16 +119,22 @@ export function ReleaseTokenModal({
|
||||
width={isMobile ? 'calc(100vw - 24px)' : 640}
|
||||
style={isMobile ? { top: 16 } : undefined}
|
||||
onCancel={submitting ? undefined : onCancel}
|
||||
onOk={handleSubmit}
|
||||
onOk={closeOnly ? onCancel : handleSubmit}
|
||||
okText={
|
||||
submitting
|
||||
? '释放中…'
|
||||
: needForce
|
||||
? '确认强制释放'
|
||||
: '确认释放回库存'
|
||||
: closeOnly
|
||||
? '关闭'
|
||||
: needForce
|
||||
? '确认强制释放'
|
||||
: '确认释放回库存'
|
||||
}
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: needForce, disabled: !canSubmit, loading: submitting }}
|
||||
okButtonProps={{
|
||||
danger: needForce && forceAllowed,
|
||||
disabled: closeOnly ? false : !canSubmit,
|
||||
loading: submitting,
|
||||
}}
|
||||
confirmLoading={submitting}
|
||||
destroyOnHidden
|
||||
>
|
||||
@@ -173,7 +195,54 @@ export function ReleaseTokenModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{needForce && check && (
|
||||
{rateLimited && check && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="上游限频,暂时无法校验"
|
||||
description={
|
||||
<Space direction="vertical" size={6}>
|
||||
<span>{check.message}</span>
|
||||
<span>
|
||||
这是上游全局频控(110001),不是 Token 状态异常;冷却结束后重新校验即可,
|
||||
限频期间无法释放回库存。
|
||||
</span>
|
||||
<Button size="small" onClick={() => setCheckSeq((n) => n + 1)} loading={loadingCheck}>
|
||||
重新校验
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{blocked && check && !rateLimited && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
message={`红色警告:${rockUpstreamStatusLabel(check.upstream_status)},禁止释放回库存`}
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<span>{check.message}</span>
|
||||
{roleText && (
|
||||
<span>
|
||||
上游绑定角色:<Text strong>{roleText}</Text>
|
||||
</span>
|
||||
)}
|
||||
{check.upstream_status === 'check_failed' && (
|
||||
<>
|
||||
<span>该状态即使输入「强制释放」也不会放行,请先恢复上游连通性后重新校验。</span>
|
||||
<Button size="small" onClick={() => setCheckSeq((n) => n + 1)} loading={loadingCheck}>
|
||||
重新校验
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{needForce && forceAllowed && check && (
|
||||
<>
|
||||
<Alert
|
||||
type="error"
|
||||
@@ -212,7 +281,7 @@ export function ReleaseTokenModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{!needForce && !checkError && check && (
|
||||
{check?.can_release && !checkError && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ROCK_UPSTREAM_STATUS_LABELS: Record<string, string> = {
|
||||
abnormal: '上游已上报失效',
|
||||
not_found: '上游卡密不存在',
|
||||
check_failed: '上游状态校验失败',
|
||||
rate_limited: '上游限频',
|
||||
}
|
||||
|
||||
export function rockUpstreamStatusLabel(status?: string): string {
|
||||
|
||||
Reference in New Issue
Block a user