feat(claim): Rock 同一提货号过期后自动换新二维码

腾讯活动链接会过期,领取页不能一直用第一次的签名。过期后向 0666 重取同一提取码的新链接,不另发提货号。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-06 18:02:17 +08:00
co-authored by Cursor
parent f6e6d110a8
commit 9d981c253f
4 changed files with 177 additions and 42 deletions
@@ -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&timestamp=${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',
@@ -86,6 +86,7 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise<Claim
task = (await refreshAffiliateDashBindState(task)) || task
}
}
task = (await refreshRockBindUrlIfStale(task)) || task
}
return buildClaimDetailPayload({
@@ -228,7 +229,7 @@ async function submitAffiliateDashClaimUid(
return getKuaishouCloudClaimDetail(token)
}
if (canReuseAffiliateDashBindSnapshot(flow, expectedUid)) {
if (canReuseAffiliateDashBindSnapshot(flow, expectedUid) && !isRockBindUrlStale(flow)) {
const nextFlow = {
...flow,
gameAccount: expectedUid,
@@ -482,6 +483,42 @@ function resolveAffiliateDashRecordedBindUid(flow: AffiliateDashFlowSnapshot) {
export const ROCK_CLAIM_TOKEN_LOCKED_MESSAGE =
'该订单提货链接已绑定失败,不能更换新的提货号,请联系客服处理'
/** 腾讯活动链接按生成时间签名,超时后同一提货号要重新取一条新二维码。 */
export const ROCK_BIND_URL_MAX_AGE_MS = 8 * 60 * 1000
export function rockBindUrlIssuedAtMs(bindUrl: unknown): number | null {
const match = /[?&]timestamp=(\d{10,13})(?:&|$)/.exec(String(bindUrl || ''))
if (!match) {
return null
}
const value = Number(match[1])
if (!Number.isFinite(value)) {
return null
}
return value < 1e12 ? value * 1000 : value
}
/** Rock 扫码前的二维码过期后要刷新链接,不能因此再领一个提货号。 */
export function isRockBindUrlStale(flowLike: unknown, now = Date.now()): boolean {
const flow = normalizeAffiliateDashFlow(flowLike)
if (!isRockAffiliateDashSku(flow.sku) || !flow.orderNo || !flow.bindUrl) {
return false
}
if (flow.bound || flow.bindMismatch || flow.mock?.enabled) {
return false
}
const checkedAt = Date.parse(String(flow.rockBindUrlCheckedAt || ''))
if (Number.isFinite(checkedAt) && now - checkedAt < 60_000) {
return false
}
const issuedAt = rockBindUrlIssuedAtMs(flow.bindUrl)
if (issuedAt == null) {
const syncedAt = Date.parse(String(flow.lastBindSyncedAt || ''))
return !Number.isFinite(syncedAt) || now - syncedAt >= 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<TaskRow | n
}
}
async function refreshRockBindUrlIfStale(task: TaskRow): Promise<TaskRow | null> {
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)
@@ -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'
@@ -169,50 +169,64 @@ export function AffiliateDashClaimPanel({
) : null}
{!canSubmit ? (
<div
className="claim-bind-area"
style={{
marginBottom: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 20,
}}
>
<span
<>
<div
className="claim-bind-area"
style={{
color: '#ff4d4f',
writingMode: 'vertical-rl',
textOrientation: 'upright',
fontSize: 17,
fontWeight: 600,
letterSpacing: 8,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 20,
}}
>
Q区用Q扫
</span>
{hasBindQr ? (
<img
src={qrCodeDataUrl}
alt="绑定二维码"
style={{ width: 220, height: 220, borderRadius: 8 }}
/>
) : (
<Typography.Text type="secondary">绑定二维码生成中,请稍候自动刷新…</Typography.Text>
)}
<span
style={{
color: '#ff4d4f',
writingMode: 'vertical-rl',
textOrientation: 'upright',
fontSize: 17,
fontWeight: 600,
letterSpacing: 8,
}}
>
V区用V扫
</span>
</div>
<span
style={{
color: '#ff4d4f',
writingMode: 'vertical-rl',
textOrientation: 'upright',
fontSize: 17,
fontWeight: 600,
letterSpacing: 8,
}}
>
Q区用Q扫
</span>
{hasBindQr ? (
<img
src={qrCodeDataUrl}
alt="绑定二维码"
style={{ width: 220, height: 220, borderRadius: 8 }}
/>
) : (
<Typography.Text type="secondary">绑定二维码生成中,请稍候自动刷新…</Typography.Text>
)}
<span
style={{
color: '#ff4d4f',
writingMode: 'vertical-rl',
textOrientation: 'upright',
fontSize: 17,
fontWeight: 600,
letterSpacing: 8,
}}
>
V区用V扫
</span>
</div>
{hasBindQr &&
String(affiliateDash.sku || '')
.toLowerCase()
.startsWith('rock_') &&
!rockTokenLocked ? (
<Typography.Text
type="secondary"
style={{ display: 'block', margin: '-8px 0 16px', textAlign: 'center' }}
>
二维码会过期,页面会自动换成新的,请扫当前这一张
</Typography.Text>
) : null}
</>
) : (
<>
<Button