修复 affiliate-dash 绑定状态一致性

This commit is contained in:
yml2213
2026-08-07 11:40:28 +08:00
parent 17acd65f28
commit dc3fd63b04
2 changed files with 411 additions and 111 deletions
@@ -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,
)
})
@@ -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<typeof getClaimIdentityFromTask>,
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<ReturnType<typeof getClaimContext>>,
expectedUid: string,
previous: ReturnType<typeof getClaimIdentityFromTask>,
taskContext: JsonObject,
now: string,
token: unknown,
): Promise<ClaimDetailPayload> {
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_bindinglink_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<typeof normalizeAffiliateDashFlow>
function resolveAffiliateDashExpectedGameAccount(
task: Partial<TaskRow> | 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<TaskRow | n
return task
}
const expectedGameAccount = resolveAffiliateDashExpectedGameAccount(task, flow)
if (isAffiliateDashBindSnapshotStale(flow, expectedGameAccount)) {
const now = nowIso()
return (
(await updateTask(task.id, {
context_json: JSON.stringify({
...taskContext,
affiliateDash: clearAffiliateDashBindSnapshot(flow, expectedGameAccount),
}),
last_error: '绑定二维码与当前 UID 不一致,请点击更新 UID 重新生成绑定二维码',
updated_at: now,
})) || task
)
}
try {
const result = await getAffiliateDashBindResult({
orderNo: flow.orderNo,
bindUuid: flow.bindUuid,
})
const boundAccount = result.gameAccount || flow.boundAccount
const boundAccount = result.bound ? result.gameAccount || flow.boundAccount : ''
// 本地强制校验:绑定返回的游戏账号与第一步填写的 UID 不一致时置 mismatch
// (不依赖平台 mismatch 字段,防止平台未标记但实际账号不一致的情况)。
const localMismatch = Boolean(
boundAccount &&
flow.expectedGameAccount &&
normalizeUid(boundAccount) !== normalizeUid(flow.expectedGameAccount),
expectedGameAccount &&
normalizeUid(boundAccount) !== normalizeUid(expectedGameAccount),
)
const nextFlow = {
...flow,
bindUuid: result.bindUuid || flow.bindUuid,
bindUrl: result.bindUrl || flow.bindUrl,
qrUrl: result.qrUrl || flow.qrUrl,
gameAccount: expectedGameAccount || flow.gameAccount,
expectedGameAccount: expectedGameAccount || flow.expectedGameAccount,
bound: result.bound,
boundAccount,
gameChannel: result.gameChannel || flow.gameChannel,
gameChannel: result.bound ? result.gameChannel || flow.gameChannel : '',
bindMismatch: Boolean(result.mismatch) || localMismatch,
}
const now = nowIso()