diff --git a/apps/backend/src/services/claim/claim-identity.test.ts b/apps/backend/src/services/claim/claim-identity.test.ts index adbc5c46..17125cfc 100644 --- a/apps/backend/src/services/claim/claim-identity.test.ts +++ b/apps/backend/src/services/claim/claim-identity.test.ts @@ -22,6 +22,21 @@ test('assertValidClaimUid rejects empty value', () => { assert.throws(() => assertValidClaimUid(''), /请输入游戏 UID/) }) +test('assertValidClaimUid rejects non-digit uid', () => { + assert.throws(() => assertValidClaimUid('abc123'), /仅支持纯数字/) + assert.throws(() => assertValidClaimUid('166909-256'), /仅支持纯数字/) + assert.throws(() => assertValidClaimUid('166909_256'), /仅支持纯数字/) +}) + +test('assertValidClaimUid rejects 91 order no (16 digits) as uid', () => { + // 91 单号 2621801687456831 为 16 位纯数字,超出 UID 上限,防止误填 + assert.throws(() => assertValidClaimUid('2621801687456831'), /长度不能超过 15/) +}) + +test('assertValidClaimUid accepts uid within 15 digits', () => { + assert.equal(assertValidClaimUid('123456789012345'), '123456789012345') +}) + test('isClaimUidMatched compares normalized values', () => { assert.equal(isClaimUidMatched('166909256', '166909256'), true) assert.equal(isClaimUidMatched('166909256', '999'), false) diff --git a/apps/backend/src/services/claim/claim-identity.ts b/apps/backend/src/services/claim/claim-identity.ts index e771b752..587b4c27 100644 --- a/apps/backend/src/services/claim/claim-identity.ts +++ b/apps/backend/src/services/claim/claim-identity.ts @@ -15,7 +15,8 @@ export type { ClaimIdentityPayload, } from '../../types/claim-identity.js' -const CLAIM_UID_MAX_LENGTH = 64 +// 游戏 UID(角色编号)长度上限:91 单号为 16 位纯数字,防止用户把 91 单号误当 UID 提交。 +const CLAIM_UID_MAX_LENGTH = 15 export function normalizeClaimUid(value: unknown): string { return String(value || '') @@ -39,8 +40,8 @@ export function assertValidClaimUid(value: unknown): string { }) } - if (!/^[A-Za-z0-9_\-]+$/.test(uid)) { - throw createHttpError('UID 仅支持字母、数字、下划线和中划线', { + if (!/^[0-9]+$/.test(uid)) { + throw createHttpError('UID 仅支持纯数字', { statusCode: 400, errorCode: 'claim_uid_invalid', }) 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 aa17589d..a8d73050 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -372,7 +372,27 @@ export async function submitClaimUid( } if (executorKey === 'affiliate_dash') { - updatedTask = (await bindAffiliateDashClaimForTask(updatedTask, expectedUid)) || updatedTask + 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) @@ -434,11 +454,18 @@ export async function submitAffiliateDashClaim( const gameAccount = expectedGameAccount let latestFlow = flow - // 提交阶段不再实时调用 affiliate-dash bind-result。 - // 平台日志显示:正常流程在 submit 前额外触发一次 bind-result 后,delivery/submit 会在平台侧 - // bind_verify 阶段偶发/稳定返回“账号尚未绑定”。这里改为使用详情轮询已写入的本地绑定快照, - // 避免 submit 前的额外平台查询改变或干扰平台发货校验链路。 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 { + // 刷新失败:回退到本地最近一次快照继续校验,不阻塞提交;本地快照不满足时下方仍会拦截。 + } + const latestBoundAccount = latestFlow.boundAccount const latestMismatch = Boolean(latestFlow.bindMismatch) || diff --git a/apps/frontend/src/pages/claim/ClaimPage.tsx b/apps/frontend/src/pages/claim/ClaimPage.tsx index 5152db15..8bc6893d 100644 --- a/apps/frontend/src/pages/claim/ClaimPage.tsx +++ b/apps/frontend/src/pages/claim/ClaimPage.tsx @@ -161,6 +161,11 @@ export default function ClaimPage() { }, [loadDetail, resolveNextPollDelay, snapshot, stopPolling]) async function handleSubmitUid() { + // 防并发:提交中忽略重复触发(按钮 loading + 回车 Enter 都可能连续触发) + if (submittingUid) { + return + } + const uid = uidInput.trim() if (!uid) { showError('请输入游戏 UID') diff --git a/apps/frontend/src/pages/claim/ClaimUidStep.tsx b/apps/frontend/src/pages/claim/ClaimUidStep.tsx index 79d7c4fa..9c12c3ff 100644 --- a/apps/frontend/src/pages/claim/ClaimUidStep.tsx +++ b/apps/frontend/src/pages/claim/ClaimUidStep.tsx @@ -18,7 +18,7 @@ export function ClaimUidStep({ 第 1 步:填写游戏编号(数字的那个)
- 请填写游戏内角色编号(纯数字),提交后继续绑定或领取。 + 请填写游戏内角色编号(纯数字,不超过 15 位),提交后继续绑定或领取。