diff --git a/apps/backend/src/routes/customer-fulfillment.ts b/apps/backend/src/routes/customer-fulfillment.ts index 2f1c6ada..ba8ed107 100644 --- a/apps/backend/src/routes/customer-fulfillment.ts +++ b/apps/backend/src/routes/customer-fulfillment.ts @@ -68,7 +68,7 @@ router.get( router.post( '/:orderNo/tasks/:taskId/binding/start', writeLimit, - createRouteHandler((req) => startPublicBinding(req.params.orderNo, req.params.taskId), { + createRouteHandler((req) => startPublicBinding(req.params.orderNo, req.params.taskId, req.body), { successMessage: '绑定会话已创建', errorMessage: '开始账号绑定失败', scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/start]', diff --git a/apps/backend/src/services/semi-auto/huya-connector-client.test.ts b/apps/backend/src/services/semi-auto/huya-connector-client.test.ts index 98dc1795..4bc68322 100644 --- a/apps/backend/src/services/semi-auto/huya-connector-client.test.ts +++ b/apps/backend/src/services/semi-auto/huya-connector-client.test.ts @@ -44,3 +44,30 @@ test('创建登录会话按真实 Connector 协议发送蛇形字段', async () globalThis.fetch = originalFetch } }) + +test('重新扫码换绑会向 Connector 传递 force_rebind', async () => { + const originalFetch = globalThis.fetch + let requestBody: Record = {} + globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body || '{}')) as Record + return new Response( + JSON.stringify({ + sessionId: 'session-1', + status: 'qr_pending', + qrImage: 'data:image/png;base64,AA==', + qrExpiresAt: '2026-11-09T00:00:00Z', + bindGameAccount: '', + bindGameRole: '', + bindGameChannel: '', + message: '请扫码换绑', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + }) as typeof fetch + try { + await connector.startHuyaBinding('session-1', 'external', { forceRebind: true }) + assert.deepEqual(requestBody, { force_rebind: true }) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/apps/backend/src/services/semi-auto/huya-connector-client.ts b/apps/backend/src/services/semi-auto/huya-connector-client.ts index b55943a9..cdf5ea30 100644 --- a/apps/backend/src/services/semi-auto/huya-connector-client.ts +++ b/apps/backend/src/services/semi-auto/huya-connector-client.ts @@ -38,6 +38,8 @@ export type ConnectorBindingSession = { bindGameAccount: string bindGameRole: string bindGameChannel: string + hasExistingBinding?: boolean + rebindRequested?: boolean message: string } @@ -219,11 +221,17 @@ export async function getHuyaLoginSession( export async function startHuyaBinding( sessionId: string, mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(), + options: { forceRebind?: boolean } = {}, ): Promise { - if (mode === 'embedded') return huyaNativeConnector.startBinding(sessionId) + if (mode === 'embedded') { + return huyaNativeConnector.startBinding( + sessionId, + options.forceRebind ? { forceRebind: true } : {}, + ) + } return callConnector( `/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`, - { method: 'POST', body: {} }, + { method: 'POST', body: { force_rebind: Boolean(options.forceRebind) } }, mode, ) } diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts index cbe25add..52f0bb8f 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts @@ -34,6 +34,7 @@ import { buildBindUrls, getLiveLinkMiniQrcode, getLiveLinkQrcodeStatus, + hasExistingHuyaBinding, HuyaQrRole, HuyaRpcClient, webBehavior, @@ -60,6 +61,10 @@ type NativeBindState = { account: string role: string channel: string + hasExistingBinding: boolean + rebindRequested: boolean + rebindScanned: boolean + baselineRoleKey: string message: string } @@ -111,6 +116,10 @@ function defaultBindingState(): NativeBindState { account: '', role: '', channel: '', + hasExistingBinding: false, + rebindRequested: false, + rebindScanned: false, + baselineRoleKey: '', message: '', } } @@ -141,6 +150,10 @@ function parseRole(response: HuyaBindResponse | null): { return { account, role, channel } } +function bindingRoleKey(role: { account: string; role: string; channel: string }): string { + return [role.account, role.role, role.channel].join('\u0000') +} + function configValues() { const huya = runtimeConfig.semiAuto?.huya const bindActId = extractInt(huya?.bindActId || 9271) @@ -286,7 +299,10 @@ export class HuyaNativeConnector { }) } - async startBinding(sessionId: string): Promise { + async startBinding( + sessionId: string, + options: { forceRebind?: boolean } = {}, + ): Promise { return this.withLock(sessionId, async () => { const session = this.requireAuthenticatedSession(sessionId) const { bindActId, outerActId, roomPid } = configValues() @@ -298,8 +314,13 @@ export class HuyaNativeConnector { outerActId, }) const role = parseRole(roleResponse) - if (role.role) { - this.markRoleReady(session, role) + const hasExistingBinding = hasExistingHuyaBinding(roleResponse) + session.binding.hasExistingBinding = hasExistingBinding + if (role.role && !options.forceRebind) { + this.markRoleReady(session, role, hasExistingBinding && !session.binding.rebindRequested) + if (session.binding.rebindRequested) { + session.binding.message = '已识别待换绑角色,请确认换绑' + } return this.bindingView(session) } if (!roleResponse || roleResponse.status !== 200) { @@ -350,11 +371,18 @@ export class HuyaNativeConnector { errorCode: 'huya_bind_qrcode_failed', }) } + if (options.forceRebind) { + session.binding.rebindRequested = true + session.binding.rebindScanned = false + session.binding.baselineRoleKey = bindingRoleKey(role) + } session.binding.status = 'qr_pending' session.binding.qrImage = mini.image session.binding.qrToken = mini.token session.binding.expiresAtMs = this.now() + BINDING_QR_TTL_MS - session.binding.message = '请使用虎牙 App 扫码绑定游戏角色' + session.binding.message = options.forceRebind + ? '请使用虎牙 App 扫码选择需要换绑的游戏角色' + : '请使用虎牙 App 扫码绑定游戏角色' return this.bindingView(session) }) } @@ -363,7 +391,8 @@ export class HuyaNativeConnector { return this.withLock(sessionId, async () => { const session = this.requireAuthenticatedSession(sessionId) const { bindActId, outerActId } = configValues() - if (session.binding.status === 'qr_pending' && session.binding.qrToken) { + let qrCompleted = false + if (['qr_pending', 'scanned'].includes(session.binding.status) && session.binding.qrToken) { const qrStatus = await getLiveLinkQrcodeStatus(session.binding.qrToken, { fetchImpl: this.fetchImpl, timeoutMs: this.rpcTimeoutMs, @@ -378,8 +407,15 @@ export class HuyaNativeConnector { session.binding.status = 'scanned' session.binding.message = '已扫码,等待绑定完成' } + if (qrStatus.isScan || qrStatus.isCompleted) { + session.binding.rebindScanned = true + } + qrCompleted = qrStatus.isCompleted } } + if (session.binding.rebindRequested && !session.binding.rebindScanned) { + return this.bindingView(session) + } const rpc = new HuyaRpcClient({ fetchImpl: this.fetchImpl, timeoutMs: this.rpcTimeoutMs }) const response = await rpc.checkUserBindGameAccount({ uid: session.uid, @@ -393,8 +429,30 @@ export class HuyaNativeConnector { return this.bindingView(session) } const role = parseRole(response) + const hasExistingBinding = hasExistingHuyaBinding(response) + session.binding.hasExistingBinding = hasExistingBinding if (role.role) { - this.markRoleReady(session, role) + const rebindRequested = session.binding.rebindRequested + const roleKey = bindingRoleKey(role) + const changedRole = + !session.binding.baselineRoleKey || roleKey !== session.binding.baselineRoleKey + if (rebindRequested && !changedRole) { + if (qrCompleted) { + session.binding.rebindRequested = false + session.binding.rebindScanned = false + session.binding.baselineRoleKey = '' + this.markRoleReady(session, role, hasExistingBinding) + session.binding.message = '扫码后仍识别到原绑定角色,请确认使用或重新换绑' + } else { + session.binding.status = 'scanned' + session.binding.message = '已扫码,正在等待换绑角色生效' + } + return this.bindingView(session) + } + this.markRoleReady(session, role, hasExistingBinding && !rebindRequested) + if (rebindRequested) { + session.binding.message = '已识别换绑角色,请确认换绑' + } } else if (!['scanned', 'expired', 'failed'].includes(session.binding.status)) { session.binding.status = 'qr_pending' session.binding.message = '等待扫码绑定游戏角色' @@ -421,21 +479,43 @@ export class HuyaNativeConnector { errorCode: 'huya_bind_role_not_ready', }) } - const confirmed = await rpc.confirmBindActAccount({ - uid: session.uid, - cookie: session.cookie, - bindActId, - outerActId, - }) - if (!confirmed || confirmed.status !== 200) { - throw createHttpError(confirmed?.msg || '确认虎牙绑定失败', { - statusCode: 502, - errorCode: 'huya_bind_confirm_failed', + const rebindRequested = session.binding.rebindRequested + if ( + rebindRequested && + session.binding.baselineRoleKey && + bindingRoleKey(role) === session.binding.baselineRoleKey + ) { + throw createHttpError('换绑角色尚未生效,请稍后重试', { + statusCode: 409, + errorCode: 'huya_bind_rebind_not_ready', }) } - this.markRoleReady(session, role) + const useExistingBinding = hasExistingHuyaBinding(response) && !rebindRequested + if (!useExistingBinding) { + const confirmed = await rpc.confirmBindActAccount({ + uid: session.uid, + cookie: session.cookie, + bindActId, + outerActId, + }) + if (!confirmed || confirmed.status !== 200) { + throw createHttpError(confirmed?.msg || '确认虎牙绑定失败', { + statusCode: 409, + errorCode: 'huya_bind_confirm_failed', + }) + } + } + this.markRoleReady(session, role, useExistingBinding) session.binding.status = 'confirmed' - session.binding.message = '虎牙角色绑定已确认' + session.binding.hasExistingBinding = true + session.binding.rebindRequested = false + session.binding.rebindScanned = false + session.binding.baselineRoleKey = '' + session.binding.message = useExistingBinding + ? '当前虎牙账号已有绑定角色,已确认直接使用' + : rebindRequested + ? '虎牙角色换绑已确认' + : '虎牙角色绑定已确认' return this.bindingView(session) }) } @@ -566,12 +646,15 @@ export class HuyaNativeConnector { private markRoleReady( session: NativeSession, role: { account: string; role: string; channel: string }, + existingBinding = false, ): void { session.binding.status = 'role_ready' session.binding.account = role.account session.binding.role = role.role session.binding.channel = role.channel - session.binding.message = '绑定角色已识别,请确认绑定' + session.binding.message = existingBinding + ? '当前账号已有绑定角色,请确认直接使用' + : '绑定角色已识别,请确认绑定' } private requireSession(sessionIdInput: string): NativeSession { @@ -648,6 +731,8 @@ export class HuyaNativeConnector { bindGameAccount: binding.account, bindGameRole: binding.role, bindGameChannel: binding.channel, + hasExistingBinding: binding.hasExistingBinding, + rebindRequested: binding.rebindRequested, message: binding.message, } } diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts index b8f7b7c9..3f7e45c8 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts @@ -5,7 +5,7 @@ import test from 'node:test' import { buildHuyaLoginContext, ensureHuyaWebCookieIdentity, x64hash128 } from './cookie.js' import { HuyaNativeConnector } from './huya-native-connector.js' -import { parseBindStruct } from './huya-rpc.js' +import { hasExistingHuyaBinding, parseBindStruct } from './huya-rpc.js' const BIND_RESP_HEX = '0a0100c816002a0001100120013a060016002602717136004612e6b3aae6958fe6849fe' + @@ -45,6 +45,18 @@ test('真实虎牙绑定响应尾部新增 ZERO 字段仍可解析', () => { assert.equal(response.accountData.gameRole.platName, '苹果(iOS)') }) +test('已绑定账号和角色时直接使用当前绑定,不再发起换绑', () => { + const response = parseBindStruct(Buffer.from(BIND_RESP_HEX, 'hex')) + assert.equal(hasExistingHuyaBinding(response), true) + + response.accountData.isBindRole = 0 + assert.equal(hasExistingHuyaBinding(response), false) + + response.accountData.isBindRole = 1 + response.status = 500 + assert.equal(hasExistingHuyaBinding(response), false) +}) + test('内置 Connector 完成扫码、CK 兑换和一次性票据且不泄露 CK', async () => { const requests: Array<{ url: string; body: string; headers: Record }> = [] let stage = 0 diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-rpc.ts b/apps/backend/src/services/semi-auto/huya-native/huya-rpc.ts index f362a39e..440c55c9 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-rpc.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-rpc.ts @@ -283,6 +283,12 @@ export type HuyaBindResponse = { changeBindDay: number } +/** 已绑定账号和角色时无需再次调用换绑接口,当前角色可直接确认使用。 */ +export function hasExistingHuyaBinding(response: HuyaBindResponse | null): boolean { + if (!response || response.status !== 200) return false + return Boolean(response.accountData.isBindAccount && response.accountData.isBindRole) +} + export type HuyaConfirmResponse = { status: number; msg: string } export type HuyaLiveLinkResponse = { status: number diff --git a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts index 693158cd..63bb2179 100644 --- a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts +++ b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts @@ -690,7 +690,11 @@ export async function pollPublicLogin(orderNoInput: unknown, taskIdInput: unknow } } -export async function startPublicBinding(orderNoInput: unknown, taskIdInput: unknown) { +export async function startPublicBinding( + orderNoInput: unknown, + taskIdInput: unknown, + input: { forceRebind?: unknown } = {}, +) { const orderNo = normalizeOrderNo(orderNoInput) const taskId = normalizeId(taskIdInput) const task = await loadTaskForOrder(orderNo, taskId) @@ -706,7 +710,9 @@ export async function startPublicBinding(orderNoInput: unknown, taskIdInput: unk errorCode: 'semi_auto_login_required', }) } - const session = await startHuyaBinding(task.connector_session_id, task.connector_mode) + const session = await startHuyaBinding(task.connector_session_id, task.connector_mode, { + forceRebind: input.forceRebind === true, + }) const binding = await updateBindingTask(task, session, 'customer') const updated = await loadTask(task.id) return { task: mapTask(updated), binding } diff --git a/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx b/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx index 87a1267a..f28fc0e2 100644 --- a/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx +++ b/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx @@ -54,7 +54,9 @@ export default function CustomerFulfillmentPage() { const [loginInfo, setLoginInfo] = useState(null) const [bindingInfo, setBindingInfo] = useState(null) const [loginQrImage, setLoginQrImage] = useState('') + const [bindingActionLoading, setBindingActionLoading] = useState(false) const startingRef = useRef(false) + const hydratingBindingRef = useRef(false) const selectedTask = useMemo( () => tasks.find((task) => task.id === selectedTaskId) || tasks[0] || null, @@ -182,6 +184,34 @@ export default function CustomerFulfillmentPage() { return () => window.clearInterval(timer) }, [message, orderNo, selectedTask?.bindingStatus, selectedTask?.id, selectedTask?.loginStatus]) + useEffect(() => { + if (!selectedTask || !orderNo || bindingInfo || hydratingBindingRef.current) return + if ( + selectedTask.loginStatus !== 'authenticated' || + selectedTask.bindingStatus !== 'role_ready' + ) { + return + } + hydratingBindingRef.current = true + void pollCustomerBinding(orderNo, selectedTask.id) + .then((response) => { + setTasks((current) => + current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), + ) + if (response.data.binding) setBindingInfo(response.data.binding) + }) + .catch(() => undefined) + .finally(() => { + hydratingBindingRef.current = false + }) + }, [ + bindingInfo, + orderNo, + selectedTask?.bindingStatus, + selectedTask?.id, + selectedTask?.loginStatus, + ]) + async function handleStartLogin() { if (!selectedTask) return setLoading(true) @@ -201,6 +231,7 @@ export default function CustomerFulfillmentPage() { async function handleConfirmBinding() { if (!selectedTask) return + setBindingActionLoading(true) try { const response = await confirmCustomerBinding(orderNo, selectedTask.id) setTasks((current) => @@ -210,6 +241,26 @@ export default function CustomerFulfillmentPage() { message.success('绑定已确认,等待客服处理') } catch (error) { message.error(error instanceof Error ? error.message : '确认绑定失败') + } finally { + setBindingActionLoading(false) + } + } + + async function handleStartRebind() { + if (!selectedTask) return + setBindingActionLoading(true) + try { + const response = await startCustomerBinding(orderNo, selectedTask.id, { + forceRebind: true, + }) + setTasks((current) => + current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), + ) + setBindingInfo(response.data.binding) + } catch (error) { + message.error(error instanceof Error ? error.message : '创建换绑二维码失败') + } finally { + setBindingActionLoading(false) } } @@ -265,6 +316,14 @@ export default function CustomerFulfillmentPage() { const bindingQrVisible = selectedTask?.loginStatus === 'authenticated' && ['qr_pending', 'scanned', 'role_ready'].includes(selectedTask.bindingStatus) + const bindingQrPending = ['qr_pending', 'scanned'].includes(selectedTask?.bindingStatus || '') + const bindingRole = bindingInfo?.bindGameRole || selectedTask?.bindGameRole || '' + const bindingAccount = bindingInfo?.bindGameAccount || selectedTask?.bindGameAccount || '' + const bindingChannel = bindingInfo?.bindGameChannel || selectedTask?.bindGameChannel || '' + const roleChoiceRequired = + selectedTask?.bindingStatus === 'role_ready' && + bindingInfo?.hasExistingBinding === true && + bindingInfo?.rebindRequested !== true return (
@@ -374,32 +433,66 @@ export default function CustomerFulfillmentPage() { ) : null} {bindingQrVisible ? ( - + - {bindingInfo?.qrImage ? ( + {bindingQrPending && bindingInfo?.qrImage ? ( 游戏角色绑定二维码 - ) : ( + ) : bindingQrPending ? ( - )} + ) : null} {bindingInfo?.message || config.bindingInstructions || '请使用虎牙 App 扫码完成游戏角色绑定。'} - {bindingInfo?.bindGameRole ? ( + {bindingRole ? ( ) : null} {selectedTask.bindingStatus === 'role_ready' ? ( - + + {roleChoiceRequired ? ( + + 请确认这是本次需要的游戏角色;角色不正确时请重新扫码换绑。 + + ) : null} + + + {roleChoiceRequired ? ( + + ) : null} + + ) : null} diff --git a/apps/frontend/src/services/customer-fulfillment.ts b/apps/frontend/src/services/customer-fulfillment.ts index 6aeea706..8e1cf980 100644 --- a/apps/frontend/src/services/customer-fulfillment.ts +++ b/apps/frontend/src/services/customer-fulfillment.ts @@ -31,10 +31,14 @@ export function pollCustomerLogin(orderNo: string, taskId: number) { ) } -export function startCustomerBinding(orderNo: string, taskId: number) { +export function startCustomerBinding( + orderNo: string, + taskId: number, + options: { forceRebind?: boolean } = {}, +) { return apiPost<{ task: CustomerSemiAutoTask; binding: CustomerBindingInfo }>( `/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/binding/start`, - {}, + options.forceRebind ? { forceRebind: true } : {}, ) } diff --git a/apps/frontend/src/types/customer-fulfillment.ts b/apps/frontend/src/types/customer-fulfillment.ts index e469a440..3dd4b6c1 100644 --- a/apps/frontend/src/types/customer-fulfillment.ts +++ b/apps/frontend/src/types/customer-fulfillment.ts @@ -41,5 +41,7 @@ export type CustomerBindingInfo = { bindGameAccount: string bindGameRole: string bindGameChannel: string + hasExistingBinding?: boolean + rebindRequested?: boolean message: string } diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index 57e686c4..ba9770f0 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,7 +3,7 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 2.9 +version: 2.10 updated_at: 2026-11-09 --- @@ -295,6 +295,9 @@ manual fulfillment 只有需要完全脱离虎牙接口联调页面时才显式选择 `mock`。 - 虎牙二维码登录有效期 `5` 分钟;登录成功后的 CK 会话有效期 `24` 小时;角色绑定二维码 `5` 分钟; 浏览器一次性注入票据 `2` 分钟且消费后立即失效。重新登录前必须撤销旧 Connector 会话。 +- 虎牙账号已经绑定游戏角色时,客户必须确认当前角色是否为本次需要的角色:选择“使用当前角色”时 + 不再调用虎牙换绑接口;选择“角色不对,重新扫码换绑”时重新生成绑定二维码,并在确认新角色时 + 调用换绑接口。未绑定账号和角色时直接走确认绑定。换绑冷却、未到可换绑时间等上游业务拒绝必须原样提示客户。 - 浏览器扩展复用 live-hub-py 的 `web/extension/huya-exchange-helper`,不新增第二个扩展; 扩展只允许消费一次性注入票据,页面不得直接读取 CK。 - 客服完成半自动任务必须与 `confirmManualFulfillment` 在同一数据库事务中提交: