diff --git a/apps/backend/src/services/claim/affiliate-dash-bind-state.test.ts b/apps/backend/src/services/claim/affiliate-dash-bind-state.test.ts index 79305e7f..2825d210 100644 --- a/apps/backend/src/services/claim/affiliate-dash-bind-state.test.ts +++ b/apps/backend/src/services/claim/affiliate-dash-bind-state.test.ts @@ -8,6 +8,7 @@ import { isAffiliateDashBindSnapshotStale, isAffiliateDashSnapshotFresh, isRockAffiliateDashSku, + isRockBindUrlStale, shouldRefuseRockRebind, shouldRefreshAffiliateDashBindStateForSnapshot, } from './kuaishou-cloud-claim-service.js' @@ -272,6 +273,40 @@ test('order snapshot stays fresh for 45s and bind snapshot only for 15s', () => ) }) +test('isRockBindUrlStale only refreshes an unpaid rock qr on the same token', () => { + const now = Date.parse('2026-10-06T09:57:00.000Z') + const oldTs = Math.floor(Date.parse('2026-10-06T09:10:14.000Z') / 1000) + const freshTs = Math.floor(Date.parse('2026-10-06T09:55:00.000Z') / 1000) + const stale = { + sku: 'rock_black_tshirt', + orderNo: 'FO20261006170653afd37ce7e19', + bindUrl: `https://gp.qq.com/cp/a20241128mobile/index.html?userId=1×tamp=${oldTs}&nonce=a`, + bound: false, + bindMismatch: false, + } + assert.equal(isRockBindUrlStale(stale, now), true) + assert.equal( + isRockBindUrlStale( + { + ...stale, + bindUrl: `https://gp.qq.com/cp/a20241128mobile/index.html?timestamp=${freshTs}&nonce=b`, + }, + now, + ), + false, + ) + assert.equal(isRockBindUrlStale({ ...stale, bound: true }, now), false) + assert.equal(isRockBindUrlStale({ ...stale, bindMismatch: true }, now), false) + assert.equal(isRockBindUrlStale({ ...stale, sku: 'lewan_black' }, now), false) + assert.equal( + isRockBindUrlStale( + { ...stale, rockBindUrlCheckedAt: new Date(now - 10_000).toISOString() }, + now, + ), + false, + ) +}) + test('claim detail skips affiliate-dash sync while the snapshot is fresh', async () => { const { readFile } = await import('node:fs/promises') const src = await readFile(new URL('./kuaishou-cloud-claim-service.ts', import.meta.url), 'utf8') @@ -282,6 +317,7 @@ test('claim detail skips affiliate-dash sync while the snapshot is fresh', async assert.ok(detail.includes('flow.mock?.enabled')) assert.ok(detail.includes('isAffiliateDashSnapshotFresh(flow.lastSyncedAt')) assert.ok(detail.includes('bindFlow.lastBindSyncedAt')) + assert.ok(detail.includes('refreshRockBindUrlIfStale')) assert.ok( !detail.includes('snapshotOnly'), '新鲜快照必须直接不调用同步;snapshotOnly 仍会 updateTask', diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index f49d9e6d..302f3cb9 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -86,6 +86,7 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise= ROCK_BIND_URL_MAX_AGE_MS + } + return now - issuedAt >= ROCK_BIND_URL_MAX_AGE_MS +} + export function isRockAffiliateDashSku(sku: unknown) { return String(sku || '') .trim() @@ -720,6 +757,51 @@ async function refreshAffiliateDashBindState(task: TaskRow): Promise { + const taskContext = parseTaskContextValue(task) + const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash) + if (!isRockBindUrlStale(flow)) { + return task + } + const expectedUid = resolveAffiliateDashExpectedGameAccount(task, flow) + if (!expectedUid) { + return task + } + + const now = nowIso() + const persist = (patch: { bindUuid?: string; bindUrl?: string; qrUrl?: string }) => + updateTask(task.id, { + context_json: JSON.stringify({ + ...taskContext, + affiliateDash: { + ...flow, + bindUuid: patch.bindUuid || flow.bindUuid, + bindUrl: patch.bindUrl || flow.bindUrl, + qrUrl: patch.qrUrl || flow.qrUrl, + rockBindUrlCheckedAt: now, + }, + }), + updated_at: now, + }) + + try { + const bindResult = await bindAffiliateDashDelivery({ + orderNo: flow.orderNo, + gameAccount: expectedUid, + }) + const nextBindUrl = String(bindResult.bindUrl || '').trim() + return ( + (await persist({ + bindUuid: bindResult.bindUuid || flow.bindUuid, + bindUrl: nextBindUrl || flow.bindUrl, + qrUrl: bindResult.qrUrl || flow.qrUrl, + })) || task + ) + } catch { + return (await persist({})) || task + } +} + function readClaimIdentity(value: unknown) { const source = value && typeof value === 'object' && !Array.isArray(value) diff --git a/apps/backend/src/services/fulfillment/affiliate-dash/index.ts b/apps/backend/src/services/fulfillment/affiliate-dash/index.ts index 5e09ba2b..db44bb17 100644 --- a/apps/backend/src/services/fulfillment/affiliate-dash/index.ts +++ b/apps/backend/src/services/fulfillment/affiliate-dash/index.ts @@ -332,6 +332,8 @@ export type AffiliateDashFlow = { lastSyncedAt: unknown /** 最近一次成功拉取 bind-result 的时间。与订单状态的 lastSyncedAt 分开计。 */ lastBindSyncedAt: unknown + /** 最近一次向 0666 重取同一提货号二维码的时间,用来避免过期链接每轮都打上游。 */ + rockBindUrlCheckedAt: unknown raw: unknown /** dev-mock 标记:enabled 时所有 affiliate-dash 平台调用短路(建单/绑定/提交/同步均不请求外部) */ mock: { enabled: boolean; orderNo: string; createdAt: string } | null @@ -374,6 +376,7 @@ export function normalizeAffiliateDashFlow(value: unknown): AffiliateDashFlow { consumeStatus: String(source.consumeStatus || 'pending').trim(), lastSyncedAt: source.lastSyncedAt || null, lastBindSyncedAt: source.lastBindSyncedAt || null, + rockBindUrlCheckedAt: source.rockBindUrlCheckedAt || null, raw, mock: source.mock && typeof source.mock === 'object' diff --git a/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx b/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx index aab405c6..463f1d8f 100644 --- a/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx +++ b/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx @@ -169,50 +169,64 @@ export function AffiliateDashClaimPanel({ ) : null} {!canSubmit ? ( -
- +
- Q区用Q扫 - - {hasBindQr ? ( - 绑定二维码 - ) : ( - 绑定二维码生成中,请稍候自动刷新… - )} - - V区用V扫 - -
+ + Q区用Q扫 + + {hasBindQr ? ( + 绑定二维码 + ) : ( + 绑定二维码生成中,请稍候自动刷新… + )} + + V区用V扫 + +
+ {hasBindQr && + String(affiliateDash.sku || '') + .toLowerCase() + .startsWith('rock_') && + !rockTokenLocked ? ( + + 二维码会过期,页面会自动换成新的,请扫当前这一张 + + ) : null} + ) : ( <>