406 lines
12 KiB
TypeScript
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> = {
|
|
'<': '<',
|
|
'>': '>',
|
|
'&': '&',
|
|
"'": ''',
|
|
'"': '"',
|
|
}
|
|
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 }
|
|
}
|