修复 affiliate-dash 绑定状态一致性
This commit is contained in:
@@ -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,19 +319,17 @@ export async function submitClaimUid(
|
||||
|
||||
const taskContext = parseTaskContext(context.task)
|
||||
const previous = getClaimIdentityFromTask(context.task)
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
claimIdentity: {
|
||||
expectedUid,
|
||||
submittedAt: previous.expectedUid === expectedUid && previous.submittedAt
|
||||
? previous.submittedAt
|
||||
: now,
|
||||
source: 'claim_page',
|
||||
},
|
||||
const executorKey = String(context.task.executor_key || '').trim()
|
||||
if (executorKey === 'affiliate_dash') {
|
||||
return submitAffiliateDashClaimUid(context, expectedUid, previous, taskContext, now, token)
|
||||
}
|
||||
|
||||
let updatedTask =
|
||||
(await updateTask(context.task.id, {
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
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: '',
|
||||
@@ -343,20 +341,13 @@ export async function submitClaimUid(
|
||||
updated_at: now,
|
||||
}
|
||||
|
||||
if (previous.expectedUid !== expectedUid) {
|
||||
await createTaskEvent(
|
||||
await createClaimUidSubmittedEventIfChanged(
|
||||
context.task.id,
|
||||
'claim_uid_submitted',
|
||||
{
|
||||
previous.expectedUid,
|
||||
expectedUid,
|
||||
previousUid: previous.expectedUid || '',
|
||||
source: 'claim_page',
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
const executorKey = String(updatedTask.executor_key || '').trim()
|
||||
if (executorKey === 'kuaishou_ct_assisted' && !isKuaishouCloudMockTask(updatedTask)) {
|
||||
try {
|
||||
const prepared = await prepareFulfillmentBinding(updatedTask, {
|
||||
@@ -371,30 +362,157 @@ 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 换码。
|
||||
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(
|
||||
updatedTask.id,
|
||||
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: dashFlow.orderNo,
|
||||
bindUuid: dashFlow.bindUuid,
|
||||
orderNo: flow.orderNo,
|
||||
bindUuid: flow.bindUuid,
|
||||
gameAccount: expectedUid,
|
||||
},
|
||||
now,
|
||||
)
|
||||
} else {
|
||||
updatedTask = (await bindAffiliateDashClaimForTask(updatedTask, expectedUid)) || updatedTask
|
||||
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<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()
|
||||
|
||||
Reference in New Issue
Block a user