Files
order_site/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts
T

406 lines
12 KiB
TypeScript

/**
* 开发环境内置的半自动履约 Connector。
*
* 只用于本地和测试环境:模拟虎牙登录、角色绑定、一次性浏览器注入票据,
* 不接触真实平台 Cookie。生产环境由 `isDevMockEnabled()` 拦截。
*/
import crypto from 'node:crypto'
import type {
ConnectorBindingSession,
ConnectorBrowserTicket,
ConnectorInjectionPayload,
ConnectorLoginSession,
} from '../semi-auto/huya-connector-client.js'
import { createHttpError } from '../../utils/http.js'
const LOGIN_QR_TTL_MS = 5 * 60 * 1000
const CREDENTIAL_TTL_MS = 24 * 60 * 60 * 1000
const BINDING_QR_TTL_MS = 5 * 60 * 1000
const BROWSER_TICKET_TTL_MS = 2 * 60 * 1000
type MockLoginStatus = ConnectorLoginSession['status']
type MockBindingStatus = ConnectorBindingSession['status']
type MockTicket = {
ticket: string
targetUrl: string
expiresAtMs: number
consumed: boolean
}
type MockSession = {
sessionId: string
taskId: number
orderNo: string
createdAtMs: number
loginStatus: MockLoginStatus
loginPolls: number
loginExpiresAtMs: number
credentialExpiresAtMs: number
qrContent: string
huyaUid: string
nickname: string
hasCredential: boolean
bindingStatus: MockBindingStatus
bindingPolls: number
bindingQrExpiresAtMs: number
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
message: string
tickets: Map<string, MockTicket>
}
const sessions = new Map<string, MockSession>()
function buildMockUid(sessionId: string) {
return `mock-huya-${sessionId.slice(-8)}`
}
function buildMockBindingQr(sessionId: string) {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
<rect width="320" height="320" fill="#ffffff"/>
<rect x="24" y="24" width="272" height="272" fill="none" stroke="#111827" stroke-width="6"/>
<text x="160" y="142" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="#111827">MOCK BIND</text>
<text x="160" y="182" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#4b5563">${escapeXml(sessionId.slice(-12))}</text>
<text x="160" y="218" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#6b7280">虎牙角色绑定测试二维码</text>
</svg>
`.trim()
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
}
function escapeXml(value: string) {
return value.replace(/[<>&'"]/g, (char) => {
const entities: Record<string, string> = {
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
"'": '&apos;',
'"': '&quot;',
}
return entities[char] || char
})
}
function requireSession(sessionIdInput: unknown): MockSession {
const sessionId = String(sessionIdInput || '').trim()
const session = sessions.get(sessionId)
if (!session) {
throw createHttpError('Mock Connector 会话不存在或已失效', {
statusCode: 404,
errorCode: 'mock_semi_auto_session_not_found',
})
}
expireSessionIfNeeded(session)
return session
}
function expireSessionIfNeeded(session: MockSession) {
const now = Date.now()
if (session.loginStatus === 'authenticated') {
if (session.credentialExpiresAtMs <= now) {
session.loginStatus = 'expired'
session.hasCredential = false
session.message = 'Mock 登录凭证已过期'
}
return
}
if (session.loginExpiresAtMs <= now && session.loginStatus !== 'expired') {
session.loginStatus = 'expired'
session.hasCredential = false
session.message = 'Mock 登录二维码已过期'
}
}
function loginView(session: MockSession): ConnectorLoginSession {
return {
sessionId: session.sessionId,
status: session.loginStatus,
qrContent: session.qrContent,
expiresAt: new Date(session.loginExpiresAtMs).toISOString(),
huyaUid: session.huyaUid,
nickname: session.nickname,
hasCredential: session.hasCredential,
message: session.message,
}
}
function bindingView(session: MockSession): ConnectorBindingSession {
return {
sessionId: session.sessionId,
status: session.bindingStatus,
qrImage: session.bindingStatus === 'not_started' ? '' : buildMockBindingQr(session.sessionId),
qrExpiresAt: new Date(session.bindingQrExpiresAtMs).toISOString(),
bindGameAccount: session.bindGameAccount,
bindGameRole: session.bindGameRole,
bindGameChannel: session.bindGameChannel,
message: session.message,
}
}
function markAuthenticated(session: MockSession) {
session.loginStatus = 'authenticated'
session.hasCredential = true
session.huyaUid = buildMockUid(session.sessionId)
session.nickname = 'Mock虎牙用户'
session.credentialExpiresAtMs = Date.now() + CREDENTIAL_TTL_MS
session.message = 'Mock 登录成功'
}
function markRoleReady(session: MockSession) {
session.bindingStatus = 'role_ready'
session.bindGameAccount = `mock-account-${session.sessionId.slice(-6)}`
session.bindGameRole = 'Mock测试角色'
session.bindGameChannel = '虎牙'
session.message = 'Mock 已识别游戏角色,请确认绑定'
}
export function createMockLoginSession(input: {
taskId?: unknown
orderNo?: unknown
}): ConnectorLoginSession {
const sessionId = crypto.randomUUID()
const now = Date.now()
const session: MockSession = {
sessionId,
taskId: Number(input.taskId || 0),
orderNo: String(input.orderNo || '').trim(),
createdAtMs: now,
loginStatus: 'waiting_scan',
loginPolls: 0,
loginExpiresAtMs: now + LOGIN_QR_TTL_MS,
credentialExpiresAtMs: 0,
qrContent: `mock-huya-login://${sessionId}`,
huyaUid: '',
nickname: '',
hasCredential: false,
bindingStatus: 'not_started',
bindingPolls: 0,
bindingQrExpiresAtMs: 0,
bindGameAccount: '',
bindGameRole: '',
bindGameChannel: '',
message: 'Mock 登录二维码已生成,轮询后会自动模拟扫码成功',
tickets: new Map(),
}
sessions.set(sessionId, session)
return loginView(session)
}
export function getMockLoginSession(sessionIdInput: unknown): ConnectorLoginSession {
const session = requireSession(sessionIdInput)
if (session.loginStatus === 'waiting_scan' || session.loginStatus === 'scanned') {
session.loginPolls += 1
if (session.loginPolls >= 2) {
markAuthenticated(session)
} else if (session.loginPolls >= 1) {
session.loginStatus = 'scanned'
session.message = 'Mock 已扫码,等待确认登录'
}
}
return loginView(session)
}
export function startMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
const session = requireSession(sessionIdInput)
if (session.loginStatus !== 'authenticated') {
throw createHttpError('Mock 登录尚未完成,不能开始绑定', {
statusCode: 409,
errorCode: 'mock_semi_auto_login_required',
})
}
const now = Date.now()
session.bindingStatus = 'qr_pending'
session.bindingPolls = 0
session.bindingQrExpiresAtMs = now + BINDING_QR_TTL_MS
session.bindGameAccount = ''
session.bindGameRole = ''
session.bindGameChannel = ''
session.message = 'Mock 绑定二维码已生成,轮询后会自动识别角色'
return bindingView(session)
}
export function getMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
const session = requireSession(sessionIdInput)
if (session.bindingStatus === 'qr_pending' || session.bindingStatus === 'scanned') {
if (session.bindingQrExpiresAtMs <= Date.now()) {
session.bindingStatus = 'expired'
session.message = 'Mock 绑定二维码已过期'
return bindingView(session)
}
session.bindingPolls += 1
if (session.bindingPolls >= 2) {
markRoleReady(session)
} else if (session.bindingPolls >= 1) {
session.bindingStatus = 'scanned'
session.message = 'Mock 已扫码,正在识别角色'
}
}
return bindingView(session)
}
export function confirmMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
const session = requireSession(sessionIdInput)
if (session.bindingStatus !== 'role_ready') {
throw createHttpError('Mock 角色尚未就绪,不能确认绑定', {
statusCode: 409,
errorCode: 'mock_semi_auto_binding_not_ready',
})
}
session.bindingStatus = 'confirmed'
session.message = 'Mock 角色绑定已确认'
return bindingView(session)
}
export function createMockBrowserTicket(input: {
sessionId?: unknown
targetUrl?: unknown
}): ConnectorBrowserTicket {
const session = requireSession(input.sessionId)
if (session.bindingStatus !== 'confirmed') {
throw createHttpError('Mock 角色尚未绑定,不能生成浏览器票据', {
statusCode: 409,
errorCode: 'mock_semi_auto_binding_required',
})
}
const ticket = `mock-ticket-${crypto.randomUUID()}`
const targetUrl = String(input.targetUrl || 'https://www.huya.com/').trim()
const expiresAtMs = Date.now() + BROWSER_TICKET_TTL_MS
session.tickets.set(ticket, { ticket, targetUrl, expiresAtMs, consumed: false })
return {
ticket,
expiresAt: new Date(expiresAtMs).toISOString(),
targetUrl,
}
}
export function consumeMockBrowserTicket(ticketInput: unknown): ConnectorInjectionPayload {
const ticket = String(ticketInput || '').trim()
for (const session of sessions.values()) {
const item = session.tickets.get(ticket)
if (!item) continue
if (item.consumed) {
throw createHttpError('Mock 注入票据已被消费', {
statusCode: 410,
errorCode: 'mock_semi_auto_ticket_consumed',
})
}
if (item.expiresAtMs <= Date.now()) {
throw createHttpError('Mock 注入票据已过期', {
statusCode: 410,
errorCode: 'mock_semi_auto_ticket_expired',
})
}
item.consumed = true
return {
platform: 'huya',
targetUrl: item.targetUrl,
expiresAt: new Date(item.expiresAtMs).toISOString(),
cookies: [
{
name: 'mock_huya_uid',
value: session.huyaUid || buildMockUid(session.sessionId),
domain: '.huya.com',
path: '/',
secure: true,
},
{
name: 'mock_huya_session',
value: session.sessionId,
domain: '.huya.com',
path: '/',
secure: true,
},
],
}
}
throw createHttpError('Mock 注入票据不存在', {
statusCode: 404,
errorCode: 'mock_semi_auto_ticket_not_found',
})
}
export function revokeMockSession(sessionIdInput: unknown) {
const sessionId = String(sessionIdInput || '').trim()
sessions.delete(sessionId)
return { ok: true }
}
export function setMockLoginStatus(
sessionIdInput: unknown,
statusInput: unknown,
): ConnectorLoginSession {
const session = requireSession(sessionIdInput)
const status = String(statusInput || '').trim() as MockLoginStatus
if (!['waiting_scan', 'scanned', 'authenticated', 'expired', 'failed'].includes(status)) {
throw createHttpError('Mock 登录状态无效', {
statusCode: 400,
errorCode: 'mock_semi_auto_login_status_invalid',
})
}
session.loginStatus = status
if (status === 'authenticated') {
markAuthenticated(session)
} else {
session.hasCredential = false
session.message = `Mock 登录状态已切换为 ${status}`
}
return loginView(session)
}
export function setMockBindingStatus(
sessionIdInput: unknown,
statusInput: unknown,
): ConnectorBindingSession {
const session = requireSession(sessionIdInput)
const status = String(statusInput || '').trim() as MockBindingStatus
if (
![
'not_started',
'qr_pending',
'scanned',
'role_ready',
'confirmed',
'expired',
'failed',
].includes(status)
) {
throw createHttpError('Mock 绑定状态无效', {
statusCode: 400,
errorCode: 'mock_semi_auto_binding_status_invalid',
})
}
session.bindingStatus = status
if (status === 'role_ready' || status === 'confirmed') {
if (!session.bindGameRole) markRoleReady(session)
session.bindingStatus = status
} else {
session.bindGameAccount = ''
session.bindGameRole = ''
session.bindGameChannel = ''
}
session.message = `Mock 绑定状态已切换为 ${status}`
return bindingView(session)
}
export function listMockSessions() {
return [...sessions.values()].map((session) => ({
sessionId: session.sessionId,
taskId: session.taskId,
orderNo: session.orderNo,
loginStatus: session.loginStatus,
bindingStatus: session.bindingStatus,
hasCredential: session.hasCredential,
ticketCount: session.tickets.size,
createdAt: new Date(session.createdAtMs).toISOString(),
}))
}
export function resetMockSessions() {
const count = sessions.size
sessions.clear()
return { cleared: count }
}