diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go index 76b76c6..05ce474 100644 --- a/backend/internal/service/rock_service.go +++ b/backend/internal/service/rock_service.go @@ -15,11 +15,17 @@ import ( "gorm.io/gorm/clause" ) +type rockRefreshCacheItem struct { + response *RockRefreshResponse + cachedAt time.Time +} + // RockService 负责管理 Rock 平台的配置、库存池、采购、UID强一致风控与状态机 type RockService struct { - db *gorm.DB - client *RockClient - mu sync.Mutex + db *gorm.DB + client *RockClient + mu sync.Mutex + refreshCache sync.Map // key: upstreamTokenID string, value: rockRefreshCacheItem } func NewRockService(db *gorm.DB, client *RockClient) *RockService { @@ -426,10 +432,48 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint }, nil } - // 调上游 refresh 接口 - ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) - if err != nil { - return nil, fmt.Errorf("刷新绑定状态失败: %w", err) + // 调上游 refresh 接口(带 800ms 瞬时防抖缓存,避免自动轮询与手动高频点击撞上上游 110001 频控) + var ref *RockRefreshResponse + cacheVal, hasCache := s.refreshCache.Load(token.UpstreamTokenID) + if hasCache { + item := cacheVal.(rockRefreshCacheItem) + if time.Since(item.cachedAt) < 800*time.Millisecond { + ref = item.response + } + } + + if ref == nil { + upstreamRef, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) + if err != nil { + // 若上游返回 110001 "请求过于频繁",进行平滑降级处理,绝不报致命错误打断买家 + if strings.Contains(err.Error(), "过于频繁") || strings.Contains(err.Error(), "110001") { + if hasCache { + ref = cacheVal.(rockRefreshCacheItem).response + } else if token.Status == model.RockStatusBound { + return &CheckBindingResult{ + Bound: true, + RoleName: token.ActualRoleName, + RoleID: token.ActualRoleID, + Channel: token.ActualChannel, + ExpectedUID: token.ExpectedUID, + Message: "扫码授权成功,UID核验一致", + }, nil + } else { + return &CheckBindingResult{ + Bound: false, + Message: "正在同步微信/QQ扫码状态,请稍后再次检查", + }, nil + } + } else { + return nil, fmt.Errorf("刷新绑定状态失败: %w", err) + } + } else { + ref = upstreamRef + s.refreshCache.Store(token.UpstreamTokenID, rockRefreshCacheItem{ + response: ref, + cachedAt: time.Now(), + }) + } } // 1. 检查上游是否已绑定 (上游状态 bound 或 bound_account_id 有值) @@ -631,6 +675,8 @@ func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) erro return fmt.Errorf("仅 mismatch 状态的 Token 允许人工解冻重置") } + s.refreshCache.Delete(token.UpstreamTokenID) + // 重置回 available,清空订单关联 return s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ "status": model.RockStatusAvailable, diff --git a/docs/Rock发货平台代理接入与库存风控实施方案.md b/docs/Rock发货平台代理接入与库存风控实施方案.md index 2d6304c..2d6ced2 100644 --- a/docs/Rock发货平台代理接入与库存风控实施方案.md +++ b/docs/Rock发货平台代理接入与库存风控实施方案.md @@ -433,6 +433,22 @@ Content-Type: application/json - 断言 2:空闲库存为 0 时,已分配 Token 的订单必须 100% 允许正常发货(`CanShip = true`)。 - 包含新增用例在内的 5 项 Rock 专项测试与全量 service 测试全部 100% PASS。 +### 7.6 提货页面二次绑定禁用与上游 110001 频控防抖治理 +#### 证据分类审计表 +| 维度 | 内容详情 | 确信度 | +| :--- | :--- | :--- | +| **已知 (Known)** | 1. Rock 平台生成的二维码与 Token 属于 1:1 强绑定关系,多次调用会导致 Token 浪费或绑定覆盖;
2. 用户手动点击“检查扫码状态”时频繁触发 400 报错:`未知错误码: (110001) 抱歉,请求过于频繁,请稍后再试!谢谢!`,而自动轮询正常。 | **100% (有实测报错报文)** | +| **未知 (Unknown)** | 为什么自动轮询正常而手动点击报错? | **已探明**:自动轮询每 2.5 秒触发一次,手动点击与定时器极易并发,使 0666.pro 瞬间接收多笔相同 Token 的 refresh 请求触发 WAF 限流。 | +| **目标 (Goal)** | 1. 禁用提货页面“重新生成二维码”按钮,锁定专属通道;
2. 后端建立 800ms 瞬时防抖缓存与 110001 软降级;
3. 前端增加 3 秒倒计时冷却与友好提示。 | **100% 达成** | - +#### 落地措施: +1. **提货页防抖与单次专属锁定**(`frontend/src/pages/Delivery.tsx`): + - `bindResult` 生成后,按钮变为“专属二维码已生成”并直接处于 `disabled` 状态; + - 游戏 UID 输入框置灰禁用,防止中途修改; + - 手动点击“检查扫码状态”增加 3 秒冷却倒计时(`检查扫码状态 (3s)`),错误处理中对“过于频繁/110001”进行平滑文案提示,不再弹出大红错误框。 +2. **后端瞬时缓存与错误降级**(`backend/internal/service/rock_service.go`): + - 在 `RockService` 中维护并发安全防抖缓存 `refreshCache sync.Map`(800ms 窗口); + - 当遇到上游 110001 频控响应时,执行平滑兜底,返回正在等待扫码,不抛出 HTTP 400 异常。 +3. **验证结果**: + - 前后端静态编译构建与 5 项 Rock 单元测试全部通过。 diff --git a/frontend/src/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx index 5184935..118ab0a 100644 --- a/frontend/src/pages/Delivery.tsx +++ b/frontend/src/pages/Delivery.tsx @@ -78,6 +78,15 @@ export default function Delivery() { const [generatingImage, setGeneratingImage] = useState(false) const [copyingImage, setCopyingImage] = useState(false) const [previewModalOpen, setPreviewModalOpen] = useState(false) + const [manualCheckCooldown, setManualCheckCooldown] = useState(0) + + useEffect(() => { + if (manualCheckCooldown <= 0) return + const timer = setTimeout(() => { + setManualCheckCooldown((prev) => Math.max(0, prev - 1)) + }, 1000) + return () => clearTimeout(timer) + }, [manualCheckCooldown]) const decodedOrderNo = useMemo(() => decodeURIComponent(orderNo), [orderNo]) const deliveryAuth = useMemo(() => { @@ -225,7 +234,12 @@ export default function Delivery() { } } catch (e) { if (!silent) { - message.error(e instanceof Error ? e.message : '检查绑定状态失败') + const errStr = e instanceof Error ? e.message : String(e || '') + if (errStr.includes('频繁') || errStr.includes('110001')) { + message.info('正在同步扫码状态,请稍候...') + } else { + message.error(errStr || '检查绑定状态失败') + } } } finally { if (!silent) setCheckingBind(false) @@ -234,6 +248,12 @@ export default function Delivery() { [bindResult?.bind_uuid, decodedOrderNo, deliveryAuth] ) + const handleManualCheckBind = () => { + if (manualCheckCooldown > 0 || checkingBind) return + setManualCheckCooldown(3) + checkBindStatus(false) + } + useEffect(() => { if (!bindResult?.bind_uuid || boundInfo?.bound || submitting || submitResult) { return @@ -579,9 +599,13 @@ export default function Delivery() { name="game_account" label="游戏账号(用于接收充值)" rules={[{ required: true, message: '请输入接收道具的游戏账号 UID' }]} - extra="请务必核对游戏账号 UID,充值成功后无法撤回" + extra={bindResult ? '已锁定专属发货通道,如需修改账号请刷新页面重试' : '请务必核对游戏账号 UID,充值成功后无法撤回'} > - + @@ -591,16 +615,18 @@ export default function Delivery() { icon={} loading={binding} onClick={bindAccount} + disabled={Boolean(bindResult)} > - {bindResult ? '重新生成二维码' : '生成绑定二维码'} + {bindResult ? '专属二维码已生成' : '生成绑定二维码'} {bindResult && !boundInfo?.bound && ( )}