完善虎牙已有绑定角色确认与重新扫码换绑流程

This commit is contained in:
yml2213
2026-11-10 13:20:51 +08:00
parent a9937ef8c6
commit 08c3834a1c
11 changed files with 287 additions and 41 deletions
@@ -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]',
@@ -44,3 +44,30 @@ test('创建登录会话按真实 Connector 协议发送蛇形字段', async ()
globalThis.fetch = originalFetch
}
})
test('重新扫码换绑会向 Connector 传递 force_rebind', async () => {
const originalFetch = globalThis.fetch
let requestBody: Record<string, unknown> = {}
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
requestBody = JSON.parse(String(init?.body || '{}')) as Record<string, unknown>
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
}
})
@@ -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<ConnectorBindingSession> {
if (mode === 'embedded') return huyaNativeConnector.startBinding(sessionId)
if (mode === 'embedded') {
return huyaNativeConnector.startBinding(
sessionId,
options.forceRebind ? { forceRebind: true } : {},
)
}
return callConnector<ConnectorBindingSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`,
{ method: 'POST', body: {} },
{ method: 'POST', body: { force_rebind: Boolean(options.forceRebind) } },
mode,
)
}
@@ -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<ConnectorBindingSession> {
async startBinding(
sessionId: string,
options: { forceRebind?: boolean } = {},
): Promise<ConnectorBindingSession> {
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,
}
}
@@ -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<string, string> }> = []
let stage = 0
@@ -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
@@ -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 }
@@ -54,7 +54,9 @@ export default function CustomerFulfillmentPage() {
const [loginInfo, setLoginInfo] = useState<CustomerLoginInfo | null>(null)
const [bindingInfo, setBindingInfo] = useState<CustomerBindingInfo | null>(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 (
<div className="customer-fulfillment-page">
@@ -374,32 +433,66 @@ export default function CustomerFulfillmentPage() {
) : null}
{bindingQrVisible ? (
<Card title="第二步:扫码绑定游戏角色">
<Card
title={
selectedTask.bindingStatus === 'role_ready'
? '第二步:确认游戏角色'
: '第二步:扫码绑定游戏角色'
}
>
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
{bindingInfo?.qrImage ? (
{bindingQrPending && bindingInfo?.qrImage ? (
<Image src={bindingInfo.qrImage} alt="游戏角色绑定二维码" width={260} />
) : (
) : bindingQrPending ? (
<Spin tip="生成绑定二维码..." />
)}
) : null}
<Text type="secondary">
{bindingInfo?.message ||
config.bindingInstructions ||
'请使用虎牙 App 扫码完成游戏角色绑定。'}
</Text>
{bindingInfo?.bindGameRole ? (
{bindingRole ? (
<Alert
type="success"
type={roleChoiceRequired ? 'warning' : 'success'}
showIcon
message={`已识别角色:${bindingInfo.bindGameRole}`}
description={[bindingInfo.bindGameAccount, bindingInfo.bindGameChannel]
.filter(Boolean)
.join(' · ')}
message={
roleChoiceRequired
? `当前已绑定角色:${bindingRole}`
: `已识别角色:${bindingRole}`
}
description={[bindingAccount, bindingChannel].filter(Boolean).join(' · ')}
/>
) : null}
{selectedTask.bindingStatus === 'role_ready' ? (
<Button type="primary" onClick={() => void handleConfirmBinding()}>
确认绑定角色
</Button>
<Space direction="vertical" align="center" size={10}>
{roleChoiceRequired ? (
<Text type="secondary">
请确认这是本次需要的游戏角色;角色不正确时请重新扫码换绑。
</Text>
) : null}
<Space wrap>
<Button
type="primary"
loading={bindingActionLoading}
onClick={() => void handleConfirmBinding()}
>
{roleChoiceRequired
? '使用当前角色'
: bindingInfo?.rebindRequested
? '确认换绑角色'
: '确认绑定角色'}
</Button>
{roleChoiceRequired ? (
<Button
danger
loading={bindingActionLoading}
onClick={() => void handleStartRebind()}
>
角色不对,重新扫码换绑
</Button>
) : null}
</Space>
</Space>
) : null}
</Space>
</Card>
@@ -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 } : {},
)
}
@@ -41,5 +41,7 @@ export type CustomerBindingInfo = {
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
hasExistingBinding?: boolean
rebindRequested?: boolean
message: string
}