完善虎牙已有绑定角色确认与重新扫码换绑流程
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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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` 在同一数据库事务中提交:
|
||||
|
||||
Reference in New Issue
Block a user