From 3ea93c0e0d5f3272bb0e88e9c4c198ee951e2f36 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sun, 15 Nov 2026 14:13:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=87=8A=E6=94=BE?= =?UTF-8?q?=E5=9B=9E=E5=BA=93=E5=AD=98=E7=9A=84=E4=B8=8A=E6=B8=B8=E9=99=90?= =?UTF-8?q?=E9=A2=91=E8=AF=AF=E5=88=A4=E4=B8=8E=E4=B8=8D=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E9=87=8A=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 110001/请求过于频繁归为 rate_limited,冷却期直接返回剩余秒数且不再请求上游 - 释放校验区分限频、校验失败与可强制释放:仅 upstream_status=bound 允许强制 - 弹窗对限频/校验失败改为可重试或关闭,不再展示必然失败的强制释放输入 - 提交前重新校验按钮复用 checkSeq,避免重复打开弹窗 - 修正绑定刷新测试夹具的上游时间未按业务时区格式化导致的失败 --- .../service/rock_binding_refresh_test.go | 4 +- backend/internal/service/rock_delivery_log.go | 13 +++ backend/internal/service/rock_redeem.go | 30 ++++++- backend/internal/service/rock_release_test.go | 54 +++++++++++ frontend/src/api/index.ts | 2 + .../pages/platformRock/ReleaseTokenModal.tsx | 89 ++++++++++++++++--- frontend/src/pages/platformRock/types.ts | 1 + 7 files changed, 180 insertions(+), 13 deletions(-) diff --git a/backend/internal/service/rock_binding_refresh_test.go b/backend/internal/service/rock_binding_refresh_test.go index 253022a..81008c9 100644 --- a/backend/internal/service/rock_binding_refresh_test.go +++ b/backend/internal/service/rock_binding_refresh_test.go @@ -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")}, }, }) })) diff --git a/backend/internal/service/rock_delivery_log.go b/backend/internal/service/rock_delivery_log.go index 76e3a63..6f73560 100644 --- a/backend/internal/service/rock_delivery_log.go +++ b/backend/internal/service/rock_delivery_log.go @@ -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 diff --git a/backend/internal/service/rock_redeem.go b/backend/internal/service/rock_redeem.go index e16c9c4..4e1beab 100644 --- a/backend/internal/service/rock_redeem.go +++ b/backend/internal/service/rock_redeem.go @@ -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) diff --git a/backend/internal/service/rock_release_test.go b/backend/internal/service/rock_release_test.go index 5f09b6d..eaf11a5 100644 --- a/backend/internal/service/rock_release_test.go +++ b/backend/internal/service/rock_release_test.go @@ -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) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 910a419..453dae6 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -822,6 +822,8 @@ export interface RockReleaseCheckResult { role_name?: string role_id?: string message: string + // 仅上游限频(110001)时返回,冷却剩余秒数 + retry_after_seconds?: number } export interface RockTokenStatusLog { diff --git a/frontend/src/pages/platformRock/ReleaseTokenModal.tsx b/frontend/src/pages/platformRock/ReleaseTokenModal.tsx index f8484e0..641fa5d 100644 --- a/frontend/src/pages/platformRock/ReleaseTokenModal.tsx +++ b/frontend/src/pages/platformRock/ReleaseTokenModal.tsx @@ -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 && ( + + {check.message} + + 这是上游全局频控(110001),不是 Token 状态异常;冷却结束后重新校验即可, + 限频期间无法释放回库存。 + + + + } + /> + )} + + {blocked && check && !rateLimited && ( + } + message={`红色警告:${rockUpstreamStatusLabel(check.upstream_status)},禁止释放回库存`} + description={ + + {check.message} + {roleText && ( + + 上游绑定角色:{roleText} + + )} + {check.upstream_status === 'check_failed' && ( + <> + 该状态即使输入「强制释放」也不会放行,请先恢复上游连通性后重新校验。 + + + )} + + } + /> + )} + + {needForce && forceAllowed && check && ( <> )} - {!needForce && !checkError && check && ( + {check?.can_release && !checkError && ( = { abnormal: '上游已上报失效', not_found: '上游卡密不存在', check_failed: '上游状态校验失败', + rate_limited: '上游限频', } export function rockUpstreamStatusLabel(status?: string): string {