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 new file mode 100644 index 00000000..f6c15579 --- /dev/null +++ b/apps/backend/src/services/claim/affiliate-dash-bind-state.test.ts @@ -0,0 +1,138 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + canReuseAffiliateDashBindSnapshot, + isAffiliateDashBindSnapshotStale, +} from './kuaishou-cloud-claim-service.js' + +test('isAffiliateDashBindSnapshotStale detects bind uuid that belongs to old uid', () => { + assert.equal( + isAffiliateDashBindSnapshotStale( + { + bindUuid: 'bind-old', + bindUrl: 'https://dash.example/bind-old', + expectedGameAccount: '111', + gameAccount: '111', + }, + '222', + ), + true, + ) +}) + +test('isAffiliateDashBindSnapshotStale treats unknown bind uid as stale', () => { + assert.equal( + isAffiliateDashBindSnapshotStale( + { + bindUuid: 'bind-unknown', + bindUrl: 'https://dash.example/bind-unknown', + }, + '222', + ), + true, + ) +}) + +test('isAffiliateDashBindSnapshotStale keeps current uid bind snapshot', () => { + assert.equal( + isAffiliateDashBindSnapshotStale( + { + bindUuid: 'bind-current', + bindUrl: 'https://dash.example/bind-current', + expectedGameAccount: '222', + gameAccount: '222', + }, + '222', + ), + false, + ) +}) + +test('canReuseAffiliateDashBindSnapshot reuses pending current uid bind', () => { + assert.equal( + canReuseAffiliateDashBindSnapshot( + { + bindUuid: 'bind-current', + bindUrl: 'https://dash.example/bind-current', + expectedGameAccount: '222', + gameAccount: '222', + bound: false, + bindMismatch: false, + }, + '222', + ), + true, + ) +}) + +test('canReuseAffiliateDashBindSnapshot refuses old uid bind', () => { + assert.equal( + canReuseAffiliateDashBindSnapshot( + { + bindUuid: 'bind-old', + bindUrl: 'https://dash.example/bind-old', + expectedGameAccount: '111', + gameAccount: '111', + bound: false, + bindMismatch: false, + }, + '222', + ), + false, + ) +}) + +test('canReuseAffiliateDashBindSnapshot refuses bound snapshot without bound account', () => { + assert.equal( + canReuseAffiliateDashBindSnapshot( + { + bindUuid: 'bind-current', + bindUrl: 'https://dash.example/bind-current', + expectedGameAccount: '222', + gameAccount: '222', + bound: true, + boundAccount: '', + bindMismatch: false, + }, + '222', + ), + false, + ) +}) + +test('canReuseAffiliateDashBindSnapshot reuses verified matching bound account', () => { + assert.equal( + canReuseAffiliateDashBindSnapshot( + { + bindUuid: 'bind-current', + bindUrl: 'https://dash.example/bind-current', + expectedGameAccount: '222', + gameAccount: '222', + bound: true, + boundAccount: '222', + bindMismatch: false, + }, + '222', + ), + true, + ) +}) + +test('canReuseAffiliateDashBindSnapshot refuses mismatched bound account', () => { + assert.equal( + canReuseAffiliateDashBindSnapshot( + { + bindUuid: 'bind-current', + bindUrl: 'https://dash.example/bind-current', + expectedGameAccount: '222', + gameAccount: '222', + bound: true, + boundAccount: '333', + bindMismatch: false, + }, + '222', + ), + false, + ) +}) 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 a8d73050..86506f1e 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -319,44 +319,35 @@ export async function submitClaimUid( const taskContext = parseTaskContext(context.task) const previous = getClaimIdentityFromTask(context.task) + const executorKey = String(context.task.executor_key || '').trim() + if (executorKey === 'affiliate_dash') { + return submitAffiliateDashClaimUid(context, expectedUid, previous, taskContext, now, token) + } + const nextContext = { ...taskContext, - claimIdentity: { - expectedUid, - submittedAt: previous.expectedUid === expectedUid && previous.submittedAt - ? previous.submittedAt - : now, - source: 'claim_page', - }, + claimIdentity: buildClaimIdentityContext(previous, expectedUid, now), } - let updatedTask = - (await updateTask(context.task.id, { - context_json: JSON.stringify(nextContext), - claimed_at: context.task.claimed_at || now, - last_error: '', - updated_at: now, - })) || { - ...context.task, - context_json: JSON.stringify(nextContext), - claimed_at: context.task.claimed_at || now, - updated_at: now, - } - - if (previous.expectedUid !== expectedUid) { - await createTaskEvent( - context.task.id, - 'claim_uid_submitted', - { - expectedUid, - previousUid: previous.expectedUid || '', - source: 'claim_page', - }, - now, - ) + let updatedTask = (await updateTask(context.task.id, { + context_json: JSON.stringify(nextContext), + claimed_at: context.task.claimed_at || now, + last_error: '', + updated_at: now, + })) || { + ...context.task, + context_json: JSON.stringify(nextContext), + claimed_at: context.task.claimed_at || now, + updated_at: now, } - const executorKey = String(updatedTask.executor_key || '').trim() + await createClaimUidSubmittedEventIfChanged( + context.task.id, + previous.expectedUid, + expectedUid, + now, + ) + if (executorKey === 'kuaishou_ct_assisted' && !isKuaishouCloudMockTask(updatedTask)) { try { const prepared = await prepareFulfillmentBinding(updatedTask, { @@ -371,29 +362,156 @@ export async function submitClaimUid( } } - if (executorKey === 'affiliate_dash') { - const dashFlow = normalizeAffiliateDashFlow(parseTaskContext(updatedTask).affiliateDash) - const uidUnchanged = previous.expectedUid === expectedUid - const hasBindUuid = Boolean(String(dashFlow.bindUuid || '').trim()) - const bindMismatch = Boolean(dashFlow.bindMismatch) - if (uidUnchanged && hasBindUuid && !bindMismatch) { - // UID 未变、已有绑定二维码且当前绑定未报 mismatch:复用现有绑定,避免重复 bind - // 生成新码覆盖旧码,导致用户扫码的二维码与提交时的 bind_uuid 不一致 - // (平台 bind_verify 报“账号尚未绑定”)。绑定不一致时仍允许重新 bind 换码。 - await createTaskEvent( - updatedTask.id, - 'affiliate_dash_bind_reused', - { - orderNo: dashFlow.orderNo, - bindUuid: dashFlow.bindUuid, - gameAccount: expectedUid, - }, - now, - ) - } else { - updatedTask = (await bindAffiliateDashClaimForTask(updatedTask, expectedUid)) || updatedTask - } + return getKuaishouCloudClaimDetail(token) +} + +function buildClaimIdentityContext( + previous: ReturnType, + expectedUid: string, + now: string, +) { + return { + expectedUid, + submittedAt: + previous.expectedUid === expectedUid && previous.submittedAt ? previous.submittedAt : now, + source: 'claim_page', } +} + +async function createClaimUidSubmittedEventIfChanged( + taskId: number, + previousUid: string, + expectedUid: string, + now: string, +) { + if (previousUid === expectedUid) { + return + } + + await createTaskEvent( + taskId, + 'claim_uid_submitted', + { + expectedUid, + previousUid: previousUid || '', + source: 'claim_page', + }, + now, + ) +} + +async function submitAffiliateDashClaimUid( + context: Awaited>, + expectedUid: string, + previous: ReturnType, + taskContext: JsonObject, + now: string, + token: unknown, +): Promise { + const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash) + const claimIdentity = buildClaimIdentityContext(previous, expectedUid, now) + const baseTaskPatch = { + claimed_at: context.task.claimed_at || now, + last_error: '', + updated_at: now, + } + + if (!flow.orderNo) { + await updateTask(context.task.id, { + ...baseTaskPatch, + context_json: JSON.stringify({ + ...taskContext, + claimIdentity, + affiliateDash: clearAffiliateDashBindSnapshot(flow, expectedUid), + }), + }) + await createClaimUidSubmittedEventIfChanged( + context.task.id, + previous.expectedUid, + expectedUid, + now, + ) + return getKuaishouCloudClaimDetail(token) + } + + if (canReuseAffiliateDashBindSnapshot(flow, expectedUid)) { + const nextFlow = { + ...flow, + gameAccount: expectedUid, + expectedGameAccount: expectedUid, + bindMismatch: false, + } + await updateTask(context.task.id, { + ...baseTaskPatch, + task_status: TASK_STATUS.WAITING_BINDING, + context_json: JSON.stringify({ + ...taskContext, + claimIdentity, + affiliateDash: nextFlow, + }), + }) + await createClaimUidSubmittedEventIfChanged( + context.task.id, + previous.expectedUid, + expectedUid, + now, + ) + await createTaskEvent( + context.task.id, + 'affiliate_dash_bind_reused', + { + orderNo: flow.orderNo, + bindUuid: flow.bindUuid, + gameAccount: expectedUid, + }, + now, + ) + return getKuaishouCloudClaimDetail(token) + } + + const bindResult = await bindAffiliateDashDelivery({ + orderNo: flow.orderNo, + gameAccount: expectedUid, + }) + const nextFlow = { + ...flow, + bindUuid: bindResult.bindUuid, + bindUrl: bindResult.bindUrl, + qrUrl: bindResult.qrUrl, + gameAccount: expectedUid, + expectedGameAccount: expectedUid, + // 重新绑定:清空旧绑定状态,等待用户用新账号扫码完成新绑定。 + bound: false, + boundAccount: '', + gameChannel: '', + bindMismatch: false, + } + + await updateTask(context.task.id, { + ...baseTaskPatch, + task_status: TASK_STATUS.WAITING_BINDING, + context_json: JSON.stringify({ + ...taskContext, + claimIdentity, + affiliateDash: nextFlow, + }), + }) + await createClaimUidSubmittedEventIfChanged( + context.task.id, + previous.expectedUid, + expectedUid, + now, + ) + await createTaskEvent( + context.task.id, + 'affiliate_dash_bind_created', + { + orderNo: flow.orderNo, + bindUuid: bindResult.bindUuid, + gameAccount: expectedUid, + }, + now, + ) return getKuaishouCloudClaimDetail(token) } @@ -425,7 +543,10 @@ export async function submitAffiliateDashClaim( } const expectedGameAccount = String( - getClaimIdentityFromTask(task).expectedUid || flow.expectedGameAccount || flow.gameAccount || '', + getClaimIdentityFromTask(task).expectedUid || + flow.expectedGameAccount || + flow.gameAccount || + '', ).trim() const requestedGameAccount = String(payload.gameAccount || '').trim() const requestedBindUuid = String(payload.bindUuid || '').trim() @@ -436,7 +557,10 @@ export async function submitAffiliateDashClaim( errorCode: 'affiliate_dash_submit_missing', }) } - if (requestedGameAccount && normalizeUid(requestedGameAccount) !== normalizeUid(expectedGameAccount)) { + if ( + requestedGameAccount && + normalizeUid(requestedGameAccount) !== normalizeUid(expectedGameAccount) + ) { throw createHttpError('提交 UID 与第一步填写 UID 不一致,请先更新 UID 后重新绑定', { statusCode: 409, errorCode: 'affiliate_dash_submit_uid_changed', @@ -455,15 +579,13 @@ export async function submitAffiliateDashClaim( let latestFlow = flow if (!isMock && flow.bindUuid) { - // 提交前强制刷新绑定状态:用平台 bind-result 拉取最新 bound/绑定账号/mismatch 并写回本地快照, - // 避免本地轮询快照过期时带着旧绑定直接提交,被平台 bind_verify 拒绝或发错账号。 - // 校验未通过时直接拦截,不发起 submit(曾观察到 submit 前查询与平台 bind_verify 存在干扰, - // 拦截在本地可以避免平台侧产生 bind_verify 失败记录)。 - try { - const refreshed = (await refreshAffiliateDashBindState(task)) || task - latestFlow = normalizeAffiliateDashFlow(parseTaskContextValue(refreshed).affiliateDash) - } catch { - // 刷新失败:回退到本地最近一次快照继续校验,不阻塞提交;本地快照不满足时下方仍会拦截。 + // 提交前不再额外请求 bind-result:平台侧曾出现 GET 后 submit 的 bind_verify 异常。 + // 这里仅使用详情轮询写入的本地快照做强拦截,真正提交仍交给平台二次校验。 + if (isAffiliateDashBindSnapshotStale(flow, expectedGameAccount)) { + throw createHttpError('绑定二维码与当前 UID 不一致,请更新 UID 后重新绑定', { + statusCode: 409, + errorCode: 'affiliate_dash_bind_uid_stale', + }) } const latestBoundAccount = latestFlow.boundAccount @@ -547,56 +669,76 @@ export async function submitAffiliateDashClaim( return getKuaishouCloudClaimDetail(token) } -/** - * 提交 UID 后触发 affiliate-dash 绑定(bind),把 bind_uuid / 二维码写回上下文, - * task → waiting_binding(link_generated → waiting_binding 合法)。 - */ -async function bindAffiliateDashClaimForTask(task: TaskRow, gameAccount: string) { - const taskContext = parseTaskContextValue(task) - const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash) - if (!flow.orderNo || !gameAccount) { - return task +type AffiliateDashFlowSnapshot = ReturnType + +function resolveAffiliateDashExpectedGameAccount( + task: Partial | null | undefined, + flow: AffiliateDashFlowSnapshot, +) { + return String( + getClaimIdentityFromTask(task).expectedUid || + flow.expectedGameAccount || + flow.gameAccount || + '', + ).trim() +} + +function resolveAffiliateDashRecordedBindUid(flow: AffiliateDashFlowSnapshot) { + return normalizeUid(flow.expectedGameAccount || flow.gameAccount) +} + +export function isAffiliateDashBindSnapshotStale(flowLike: unknown, expectedUid: unknown): boolean { + const flow = normalizeAffiliateDashFlow(flowLike) + const expected = normalizeUid(expectedUid) + if (!flow.bindUuid || !expected) { + return false } - const now = nowIso() - const bindResult = await bindAffiliateDashDelivery({ - orderNo: flow.orderNo, - gameAccount, - }) - const nextFlow = { + const recordedBindUid = resolveAffiliateDashRecordedBindUid(flow) + return !recordedBindUid || recordedBindUid !== expected +} + +export function canReuseAffiliateDashBindSnapshot( + flowLike: unknown, + expectedUid: unknown, +): boolean { + const flow = normalizeAffiliateDashFlow(flowLike) + const expected = normalizeUid(expectedUid) + if ( + !expected || + !flow.bindUuid || + !(flow.bindUrl || flow.qrUrl) || + flow.bindMismatch || + isAffiliateDashBindSnapshotStale(flow, expected) + ) { + return false + } + + const boundAccount = normalizeUid(flow.boundAccount) + if (boundAccount && boundAccount !== expected) { + return false + } + + return !flow.bound || Boolean(boundAccount) +} + +function clearAffiliateDashBindSnapshot( + flow: AffiliateDashFlowSnapshot, + expectedUid: unknown, +): AffiliateDashFlowSnapshot { + const gameAccount = String(expectedUid || '').trim() + return { ...flow, - bindUuid: bindResult.bindUuid, - bindUrl: bindResult.bindUrl, - qrUrl: bindResult.qrUrl, - gameAccount, - expectedGameAccount: gameAccount, - // 重新绑定:清空旧绑定状态,等待用户用新账号扫码完成新绑定 + bindUuid: '', + bindUrl: '', + qrUrl: '', + gameAccount: gameAccount || flow.gameAccount, + expectedGameAccount: gameAccount || flow.expectedGameAccount, bound: false, boundAccount: '', gameChannel: '', bindMismatch: false, } - - const updatedTask = await updateTask(task.id, { - task_status: TASK_STATUS.WAITING_BINDING, - context_json: JSON.stringify({ - ...taskContext, - affiliateDash: nextFlow, - }), - updated_at: now, - }) - await createTaskEvent( - task.id, - 'affiliate_dash_bind_created', - { - orderNo: flow.orderNo, - bindUuid: bindResult.bindUuid, - gameAccount, - }, - now, - ) - - return updatedTask || task } /** @@ -614,24 +756,44 @@ async function refreshAffiliateDashBindState(task: TaskRow): Promise