feat(claim): Rock 同一提货号过期后自动换新二维码
腾讯活动链接会过期,领取页不能一直用第一次的签名。过期后向 0666 重取同一提取码的新链接,不另发提货号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user