收敛半自动平台为登录获取 CK 流程

移除角色绑定对客户页、状态展示和完成门禁的阻塞;虎牙与微博统一保存并直接导出 CK;同步精简客服工作台、配置项和权威文档。
This commit is contained in:
yml
2026-11-14 01:13:00 +08:00
parent 8a54c41476
commit d178556f03
22 changed files with 526 additions and 732 deletions
@@ -1290,9 +1290,6 @@ function normalizeSemiAutoConfig(payload: JsonObject): {
loginInstructions: String(config.loginInstructions || '')
.trim()
.slice(0, 1000),
bindingInstructions: String(config.bindingInstructions || '')
.trim()
.slice(0, 1000),
browserTargets: Array.isArray(config.browserTargets)
? config.browserTargets
.filter((item): item is Record<string, unknown> =>
@@ -370,12 +370,6 @@ export async function createSemiAutoMock(
: connectorMode === 'mock'
? 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。'
: '真实登录:点击获取二维码后,请使用虎牙 App 扫码并在手机上确认登录。',
bindingInstructions:
platform === 'weibo'
? '微博登录成功即完成授权,无需再绑定游戏角色。'
: connectorMode === 'mock'
? 'Mock 绑定:页面轮询两次后会自动识别角色,点击确认即可。'
: '真实绑定:扫码后等待 Connector 识别角色,核对账号和区服后点击确认绑定。',
...(platform === 'weibo' ? { loginMethods: ['sms', 'qr'] } : {}),
browserTargets: [
{
@@ -6,6 +6,7 @@ import {
consumeMockBrowserTicket,
createMockBrowserTicket,
createMockLoginSession,
exportMockSessionCredential,
getMockBinding,
getMockLoginSession,
resetMockSessions,
@@ -31,6 +32,9 @@ test('Mock Connector 可按轮询自动完成登录、绑定和一次性票据
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.hasCredential, true)
assert.ok(authenticated.huyaUid)
const credential = exportMockSessionCredential(created.sessionId)
assert.equal(credential.platformUid, authenticated.huyaUid)
assert.ok(credential.cookies.length >= 1)
const binding = startMockBinding(created.sessionId)
assert.equal(binding.status, 'qr_pending')
@@ -65,6 +69,12 @@ test('Mock Connector 支持控制状态、撤销会话和重置', () => {
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.hasCredential, true)
const ticket = createMockBrowserTicket({
sessionId: created.sessionId,
targetUrl: 'https://www.huya.com/no-binding',
})
assert.equal(consumeMockBrowserTicket(ticket.ticket).targetUrl, 'https://www.huya.com/no-binding')
const confirmed = setMockBindingStatus(created.sessionId, 'confirmed')
assert.equal(confirmed.status, 'confirmed')
assert.ok(confirmed.bindGameRole)
@@ -12,6 +12,7 @@ import type {
ConnectorInjectionPayload,
ConnectorLoginSession,
} from '../semi-auto/huya-connector-client.js'
import type { HuyaStoredCredential } from '../semi-auto/huya-native/huya-native-connector.js'
import { createHttpError } from '../../utils/http.js'
const LOGIN_QR_TTL_MS = 5 * 60 * 1000
@@ -203,6 +204,42 @@ export function getMockLoginSession(sessionIdInput: unknown): ConnectorLoginSess
return loginView(session)
}
export function exportMockSessionCredential(sessionIdInput: unknown): HuyaStoredCredential {
const session = requireSession(sessionIdInput)
if (session.loginStatus !== 'authenticated' || !session.hasCredential) {
throw createHttpError('Mock 登录尚未完成,不能导出 CK', {
statusCode: 409,
errorCode: 'mock_semi_auto_credential_not_ready',
})
}
return {
platformUid: session.huyaUid,
nickname: session.nickname,
cookies: [
{
name: 'mock_huya_uid',
value: session.huyaUid,
domain: '.huya.com',
path: '/',
secure: true,
httpOnly: false,
hostOnly: false,
expiresAt: null,
},
{
name: 'mock_huya_session',
value: session.sessionId,
domain: '.huya.com',
path: '/',
secure: true,
httpOnly: true,
hostOnly: false,
expiresAt: null,
},
],
}
}
export function startMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
const session = requireSession(sessionIdInput)
if (session.loginStatus !== 'authenticated') {
@@ -259,10 +296,10 @@ export function createMockBrowserTicket(input: {
targetUrl?: unknown
}): ConnectorBrowserTicket {
const session = requireSession(input.sessionId)
if (session.bindingStatus !== 'confirmed') {
throw createHttpError('Mock 角色尚未绑定,不能生成浏览器票据', {
if (session.loginStatus !== 'authenticated' || !session.hasCredential) {
throw createHttpError('Mock 登录尚未完成,不能生成浏览器票据', {
statusCode: 409,
errorCode: 'mock_semi_auto_binding_required',
errorCode: 'mock_semi_auto_login_required',
})
}
const ticket = `mock-ticket-${crypto.randomUUID()}`
@@ -45,6 +45,44 @@ test('创建登录会话按真实 Connector 协议发送蛇形字段', async ()
}
})
test('外部虎牙 Connector 支持导出账号级 CK', async () => {
const originalFetch = globalThis.fetch
let requestUrl = ''
globalThis.fetch = (async (url: string | URL | Request) => {
requestUrl = String(url)
return new Response(
JSON.stringify({
platformUid: '1686664168',
nickname: '外部虎牙账号',
cookies: [
{
name: 'udb_cred',
value: 'external-cred',
domain: '.huya.com',
path: '/',
secure: true,
httpOnly: true,
hostOnly: false,
expiresAt: null,
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
const credential = await connector.exportHuyaSessionCredential('session-1', 'external')
assert.equal(
requestUrl,
'http://connector.test/api/internal/huya-semi-auto/sessions/session-1/credential',
)
assert.equal(credential.platformUid, '1686664168')
assert.equal(credential.cookies[0]?.value, 'external-cred')
} finally {
globalThis.fetch = originalFetch
}
})
test('重新扫码换绑会向 Connector 传递 force_rebind', async () => {
const originalFetch = globalThis.fetch
let requestBody: Record<string, unknown> = {}
@@ -6,6 +6,7 @@ import {
devMockConnectorBaseUrl,
} from '../dev-mock/connector-endpoint.js'
import { isDevMockEnabled } from '../dev-mock/dev-mock-enabled.js'
import { exportMockSessionCredential } from '../dev-mock/mock-semi-auto-connector.js'
import {
HUYA_NATIVE_TICKET_PREFIX,
huyaNativeConnector,
@@ -276,17 +277,17 @@ export async function createHuyaBrowserTicket(
)
}
export function exportHuyaSessionCredential(
export async function exportHuyaSessionCredential(
sessionId: string,
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): HuyaStoredCredential {
if (mode !== 'embedded') {
throw createHttpError('当前虎牙 Connector 模式不支持直接导出 CK', {
statusCode: 409,
errorCode: 'semi_auto_credential_export_unsupported',
})
}
return huyaNativeConnector.exportSessionCredential(sessionId)
): Promise<HuyaStoredCredential> {
if (mode === 'embedded') return huyaNativeConnector.exportSessionCredential(sessionId)
if (mode === 'mock') return exportMockSessionCredential(sessionId)
return callConnector<HuyaStoredCredential>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/credential`,
{},
mode,
)
}
export function inferSemiAutoConnectorModeFromTicket(ticket: string): SemiAutoConnectorMode {
@@ -168,25 +168,15 @@ function displayStatus(task: TaskRow) {
if (task.credential_status === 'invalid' || task.credential_status === 'error') {
return { key: 'credential_expired', label: '登录已失效,需重新授权' }
}
if (
task.execution_status === 'failed' ||
task.login_status === 'failed' ||
task.binding_status === 'failed'
) {
if (task.execution_status === 'failed' || task.login_status === 'failed') {
return { key: 'failed', label: '需客服处理' }
}
if (task.binding_status === 'confirmed') {
return { key: 'waiting_operator', label: '等待客服处理' }
}
if (task.binding_status === 'role_ready') {
return { key: 'binding_confirm', label: '待确认绑定' }
}
if (task.login_status === 'authenticated') {
return { key: 'waiting_binding', label: '等待扫码绑定' }
return { key: 'waiting_operator', label: 'CK 已获取,等待客服处理' }
}
if (task.login_status === 'scanned') return { key: 'login_scanned', label: '已扫码,确认中' }
if (task.login_status === 'qr_pending') return { key: 'waiting_login', label: '等待客户登录' }
return { key: 'waiting_customer', label: '等待客户打开链接' }
return { key: 'waiting_customer', label: '等待客户授权' }
}
function mapTask(task: TaskRow) {
@@ -418,7 +408,6 @@ async function resetTaskForCustomerLogin(
UPDATE semi_auto_tasks
SET connector_session_id = '',
login_status = 'waiting_login',
binding_status = 'not_started',
execution_status = 'waiting_customer',
customer_link_opened_at = NULL,
auth_started_at = NULL,
@@ -437,8 +426,6 @@ async function resetTaskForCustomerLogin(
...(input.actorId ? { actorId: input.actorId } : {}),
fromLoginStatus: task.login_status,
toLoginStatus: 'waiting_login',
fromBindingStatus: task.binding_status,
toBindingStatus: 'not_started',
fromExecutionStatus: task.execution_status,
toExecutionStatus: 'waiting_customer',
detail: { reason: input.reason },
@@ -673,25 +660,20 @@ async function updateLoginTask(
actorType: TaskActorType,
) {
const mapped = mapLoginUpdate(session)
// 微博没有独立的游戏角色绑定步骤:登录成功即视为授权完成。
const autoConfirmWeiboBinding =
task.platform === 'weibo' &&
mapped.status === 'authenticated' &&
task.binding_status !== 'confirmed'
const nextExecutionStatus: ExecutionStatus =
mapped.status === 'authenticated'
? task.platform === 'weibo' || task.binding_status === 'confirmed'
? 'waiting_operator'
: 'waiting_customer'
: task.execution_status
mapped.status === 'authenticated' ? 'waiting_operator' : task.execution_status
const storedCredential =
mapped.status === 'authenticated' && mapped.hasCredential
? task.platform === 'weibo'
? exportWeiboSessionCredential(session.sessionId, task.connector_mode)
: task.connector_mode === 'embedded'
? exportHuyaSessionCredential(session.sessionId, task.connector_mode)
: null
: await exportHuyaSessionCredential(session.sessionId, task.connector_mode)
: null
if (mapped.status === 'authenticated' && !storedCredential) {
throw createHttpError('平台登录成功但未能获取账号 CK,请联系客服处理', {
statusCode: 502,
errorCode: 'semi_auto_credential_export_failed',
})
}
await withTransaction(async (client) => {
const credentialId = storedCredential
? await upsertSemiAutoCredentialWithClient(client, {
@@ -704,17 +686,12 @@ async function updateLoginTask(
`
UPDATE semi_auto_tasks
SET login_status = $2,
credential_id = COALESCE($8, credential_id),
credential_id = COALESCE($7, credential_id),
huya_uid = CASE WHEN $3 <> '' THEN $3 ELSE huya_uid END,
platform_nickname = CASE WHEN $4 <> '' THEN $4 ELSE platform_nickname END,
authenticated_at = CASE WHEN $2 = 'authenticated' THEN COALESCE(authenticated_at, NOW()) ELSE authenticated_at END,
binding_status = CASE WHEN $6::boolean THEN 'confirmed' ELSE binding_status END,
bind_game_account = CASE WHEN $6::boolean AND $4 <> '' THEN $4 ELSE bind_game_account END,
bind_game_role = CASE WHEN $6::boolean AND $3 <> '' THEN $3 ELSE bind_game_role END,
bind_game_channel = CASE WHEN $6::boolean THEN '微博' ELSE bind_game_channel END,
bound_at = CASE WHEN $6::boolean THEN COALESCE(bound_at, NOW()) ELSE bound_at END,
execution_status = $5,
last_error = CASE WHEN $2 IN ('failed', 'expired') THEN $7 ELSE '' END,
last_error = CASE WHEN $2 IN ('failed', 'expired') THEN $6 ELSE '' END,
updated_at = NOW()
WHERE id = $1
`,
@@ -724,7 +701,6 @@ async function updateLoginTask(
mapped.uid,
mapped.nickname,
nextExecutionStatus,
autoConfirmWeiboBinding,
mapped.message,
credentialId,
],
@@ -741,22 +717,6 @@ async function updateLoginTask(
message: mapped.message,
},
})
if (autoConfirmWeiboBinding) {
await appendTaskEvent(client, task.id, {
eventType: 'binding_status_changed',
actorType,
fromBindingStatus: task.binding_status,
toBindingStatus: 'confirmed',
fromExecutionStatus: task.execution_status,
toExecutionStatus: nextExecutionStatus,
detail: {
gameAccount: mapped.nickname,
gameRole: mapped.uid,
gameChannel: '微博',
message: '微博登录成功,授权已完成',
},
})
}
})
}
@@ -893,7 +853,6 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno
UPDATE semi_auto_tasks
SET connector_session_id = $2,
login_status = 'qr_pending',
binding_status = 'not_started',
execution_status = 'waiting_customer',
auth_started_at = COALESCE(auth_started_at, NOW()),
last_error = '',
@@ -1130,16 +1089,11 @@ export async function loginPublicWeiboSms(
SET connector_session_id = $2,
credential_id = COALESCE($5, credential_id),
login_status = 'authenticated',
binding_status = 'confirmed',
execution_status = 'waiting_operator',
huya_uid = CASE WHEN $3 <> '' THEN $3 ELSE huya_uid END,
platform_nickname = CASE WHEN $4 <> '' THEN $4 ELSE platform_nickname END,
bind_game_account = CASE WHEN $4 <> '' THEN $4 ELSE bind_game_account END,
bind_game_role = CASE WHEN $3 <> '' THEN $3 ELSE bind_game_role END,
bind_game_channel = '微博',
auth_started_at = COALESCE(auth_started_at, NOW()),
authenticated_at = COALESCE(authenticated_at, NOW()),
bound_at = COALESCE(bound_at, NOW()),
last_error = '',
updated_at = NOW()
WHERE id = $1
@@ -1155,23 +1109,6 @@ export async function loginPublicWeiboSms(
toExecutionStatus: nextExecutionStatus,
detail: { method: 'sms', hasCredential: mapped.hasCredential, message: mapped.message },
})
if (task.binding_status !== 'confirmed') {
await appendTaskEvent(client, task.id, {
eventType: 'binding_status_changed',
actorType: 'customer',
fromBindingStatus: task.binding_status,
toBindingStatus: 'confirmed',
fromExecutionStatus: task.execution_status,
toExecutionStatus: nextExecutionStatus,
detail: {
gameAccount: mapped.nickname,
gameRole: mapped.uid,
gameChannel: '微博',
method: 'sms',
message: '微博登录成功,授权已完成',
},
})
}
})
if (previousSessionId && previousSessionId !== sessionId) {
@@ -1303,15 +1240,8 @@ export async function getAdminSemiAutoTask(taskIdInput: unknown) {
}
}
export async function exportAdminSemiAutoCredential(taskIdInput: unknown, session: AdminSession) {
export async function exportAdminSemiAutoCredential(taskIdInput: unknown) {
const task = await loadTask(normalizeId(taskIdInput))
const adminUserId = Number(session.userId || 0)
if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) {
throw createHttpError('该任务已被其他客服领取', {
statusCode: 409,
errorCode: 'semi_auto_task_claimed',
})
}
if (!task.credential_id) {
throw createHttpError('当前任务尚未保存账号 CK', {
statusCode: 404,
@@ -1392,10 +1322,6 @@ export async function syncAdminSemiAutoTask(taskIdInput: unknown) {
await updateLoginTask(task, login, 'connector')
task = await loadTask(taskId)
}
if (task.login_status === 'authenticated' && task.binding_status !== 'confirmed') {
const binding = await getBindingForTask(task, task.connector_session_id)
await updateBindingTask(task, binding, 'connector')
}
return getAdminSemiAutoTask(taskId)
}
@@ -1450,10 +1376,10 @@ export async function createAdminBrowserSession(
errorCode: 'semi_auto_task_claimed',
})
}
if (!task.connector_session_id || task.binding_status !== 'confirmed') {
throw createHttpError('客户尚未完成登录和绑定', {
if (!task.connector_session_id || task.login_status !== 'authenticated') {
throw createHttpError('客户尚未完成登录,或登录会话已失效', {
statusCode: 409,
errorCode: 'semi_auto_binding_required',
errorCode: 'semi_auto_login_required',
})
}
const config = safeParseJson(task.semi_auto_config_json)
@@ -1591,10 +1517,15 @@ export async function completeAdminSemiAutoTask(
errorCode: 'semi_auto_task_cancelled',
})
}
if (!task.connector_session_id || task.binding_status !== 'confirmed') {
throw createHttpError('客户尚未完成登录和绑定,不能完成履约', {
if (
task.login_status !== 'authenticated' ||
!task.credential_id ||
task.credential_status === 'invalid' ||
task.credential_status === 'error'
) {
throw createHttpError('客户账号 CK 尚未就绪或已失效,不能完成履约', {
statusCode: 409,
errorCode: 'semi_auto_binding_required',
errorCode: 'semi_auto_credential_required',
})
}
if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) {
@@ -17,6 +17,7 @@ test(
const { closeDb, query } = await import('../../db/client.js')
const {
ensureSemiAutoTasksForOrder,
exportAdminSemiAutoCredential,
listAdminSemiAutoTasks,
listPublicSemiAutoTasks,
requestAdminSemiAutoCustomerReauth,
@@ -117,7 +118,7 @@ test(
last_checked_at, last_check_error, created_at, updated_at
) VALUES (
'huya', $1, '集成测试虎牙', '{"cookies":[]}',
'qr', 'invalid', NOW(), NOW(), 'CK 已失效', NOW(), NOW()
'qr', 'valid', NOW(), NOW(), '', NOW(), NOW()
) RETURNING id
`,
[`it-huya-${suffix}`],
@@ -129,16 +130,21 @@ test(
SET credential_id = $2,
connector_session_id = $3,
login_status = 'authenticated',
binding_status = 'confirmed',
binding_status = 'not_started',
execution_status = 'waiting_operator',
customer_link_opened_at = NOW(),
authenticated_at = NOW(),
bound_at = NOW(),
updated_at = NOW()
WHERE id = $1
`,
[taskId, credentialId, `it-huya-session-${suffix}`],
)
const authenticatedList = await listAdminSemiAutoTasks({ keyword: orderNo })
assert.equal(authenticatedList.items[0]?.loginStatus, 'authenticated')
assert.equal(authenticatedList.items[0]?.displayStatus.label, 'CK 已获取,等待客服处理')
const exported = await exportAdminSemiAutoCredential(taskId)
assert.equal(exported.credentialId, credentialId)
assert.equal(exported.platform, 'huya')
const reauthorized = await requestAdminSemiAutoCustomerReauth(taskId, {
userId: 1,
@@ -186,6 +192,7 @@ test(
let orderId = 0
let configId = 0
let adminId = 0
let credentialId = 0
let triggerCreated = false
try {
const admin = await query<{ id: number }>(
@@ -260,19 +267,33 @@ test(
const ensured = await ensureSemiAutoTasksForOrder(orderNo)
const taskId = Number(ensured.createdIds[0] || 0)
assert.ok(taskId > 0)
const credential = await query<{ id: number }>(
`
INSERT INTO semi_auto_credentials (
platform, platform_uid, platform_nickname, credential_payload_json,
credential_source, credential_status, credential_updated_at,
last_checked_at, last_check_error, created_at, updated_at
) VALUES (
'huya', $1, '半自动原子性账号', '{"cookies":[]}',
'qr', 'valid', NOW(), NOW(), '', NOW(), NOW()
) RETURNING id
`,
[`it-atomic-huya-${suffix}`],
)
credentialId = Number(credential.rows[0]?.id || 0)
assert.ok(credentialId > 0)
await query(
`
UPDATE semi_auto_tasks
SET connector_session_id = $2,
SET credential_id = $2,
login_status = 'authenticated',
binding_status = 'confirmed',
binding_status = 'not_started',
execution_status = 'waiting_operator',
authenticated_at = NOW(),
bound_at = NOW(),
updated_at = NOW()
WHERE id = $1
`,
[taskId, `it-session-${suffix}`],
[taskId, credentialId],
)
await query(
@@ -390,6 +411,9 @@ test(
if (configId > 0) {
await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
}
if (credentialId > 0) {
await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId])
}
if (adminId > 0) {
await query('DELETE FROM admin_users WHERE id = $1', [adminId])
}