完善虎牙已有绑定角色确认与重新扫码换绑流程
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user