优化:补充半自动履约开发 Mock 与内置 Connector

This commit is contained in:
yml2213
2026-11-09 16:02:52 +08:00
parent cb9538a775
commit b0b8e983ba
12 changed files with 1188 additions and 8 deletions
+4
View File
@@ -8,6 +8,7 @@ import orderServiceRouter from './routes/order-service.js'
import filesRouter from './routes/files.js'
import claimsRouter from './routes/claims.js'
import customerFulfillmentRouter from './routes/customer-fulfillment.js'
import devMockConnectorRouter from './routes/dev-mock-connector.js'
import dcAuthRouter from './routes/dcauth.js'
import douyinMashidaRouter from './routes/douyin-mashida.js'
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
@@ -54,6 +55,9 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
)
app.use(express.urlencoded({ extended: true }))
// 本地开发用内置 Connector;生产环境由路由内部直接返回 404。
app.use('/api/v1/dev-mock/connector', devMockConnectorRouter)
app.get('/health', (_req, res) => {
res.json(
buildSuccessPayload(
+20
View File
@@ -4,6 +4,7 @@ import {
buildOpen91QueryMock,
createAffiliateDashMockClaim,
createKuaishouIndustryVoucherMockData,
createSemiAutoMock,
getDevMockStatus,
isDevMockEnabled,
} from '../../services/dev-mock/dev-mock-service.js'
@@ -75,6 +76,25 @@ router.post(
}),
)
router.post(
'/dev-mock/semi-auto',
createJsonHandler((req) => createSemiAutoMock(req.body || {}), {
successMessage: '半自动履约 Mock 已生成',
errorMessage: '生成半自动履约 Mock 失败',
scope: '[admin/dev-mock/semi-auto]',
audit: (_req, data) => ({
action: 'dev_mock_create_semi_auto',
targetType: 'dev_mock',
targetId: String((data as { orderNo?: string })?.orderNo || ''),
data: {
platform: 'semi_auto',
orderNo: (data as { orderNo?: string })?.orderNo,
skuKey: (data as { skuKey?: string })?.skuKey,
},
}),
}),
)
router.post(
'/dev-mock/open91/query',
createJsonHandler((req) => buildOpen91QueryMock(req.body || {}), {
@@ -0,0 +1,144 @@
import type { Request, Response } from 'express'
import { Router } from 'express'
import { runtimeConfig } from '../config/runtime.js'
import { isDevMockEnabled } from '../services/dev-mock/dev-mock-service.js'
import {
confirmMockBinding,
consumeMockBrowserTicket,
createMockBrowserTicket,
createMockLoginSession,
getMockBinding,
getMockLoginSession,
listMockSessions,
resetMockSessions,
revokeMockSession,
setMockBindingStatus,
setMockLoginStatus,
startMockBinding,
} from '../services/dev-mock/mock-semi-auto-connector.js'
const router = Router()
function connectorApiKey() {
return (
String(runtimeConfig.semiAuto?.connectorApiKey || '').trim() || 'local-semi-auto-connector-key'
)
}
function bodyOf(req: Request): Record<string, unknown> {
return req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? (req.body as Record<string, unknown>)
: {}
}
function sendDetail(res: Response, statusCode: number, detail: string) {
res.status(statusCode).json({ detail })
}
function handle(action: (req: Request) => unknown) {
return (req: Request, res: Response) => {
try {
res.json(action(req))
} catch (error) {
const statusCode =
error && typeof error === 'object' && 'statusCode' in error
? Number((error as { statusCode?: unknown }).statusCode) || 500
: 500
const detail =
error instanceof Error ? error.message : String(error || 'Mock Connector 请求失败')
sendDetail(res, statusCode, detail)
}
}
}
router.use((req, res, next) => {
if (!isDevMockEnabled()) {
sendDetail(res, 404, '开发 Mock Connector 未启用')
return
}
const key = String(req.header('x-semi-auto-key') || '').trim()
if (!key || key !== connectorApiKey()) {
sendDetail(res, 401, '半自动履约 Connector 鉴权失败')
return
}
next()
})
router.post(
'/api/internal/huya-semi-auto/sessions',
handle((req) => {
const body = bodyOf(req)
return createMockLoginSession({ taskId: body.taskId, orderNo: body.orderNo })
}),
)
router.get(
'/api/internal/huya-semi-auto/sessions/:sessionId',
handle((req) => getMockLoginSession(req.params.sessionId)),
)
router.delete(
'/api/internal/huya-semi-auto/sessions/:sessionId',
handle((req) => revokeMockSession(req.params.sessionId)),
)
router.post(
'/api/internal/huya-semi-auto/sessions/:sessionId/binding',
handle((req) => startMockBinding(req.params.sessionId)),
)
router.get(
'/api/internal/huya-semi-auto/sessions/:sessionId/binding',
handle((req) => getMockBinding(req.params.sessionId)),
)
router.post(
'/api/internal/huya-semi-auto/sessions/:sessionId/binding/confirm',
handle((req) => confirmMockBinding(req.params.sessionId)),
)
router.post(
'/api/internal/huya-semi-auto/sessions/:sessionId/browser-ticket',
handle((req) => {
const body = bodyOf(req)
return createMockBrowserTicket({
sessionId: req.params.sessionId,
targetUrl: body.targetUrl,
})
}),
)
router.get(
'/api/internal/huya-semi-auto/browser-tickets/:ticket',
handle((req) => consumeMockBrowserTicket(req.params.ticket)),
)
// 测试辅助:不经过轮询直接推进状态,方便验证失败、过期和异常分支。
router.get(
'/__control/sessions',
handle(() => listMockSessions()),
)
router.post(
'/__control/sessions/:sessionId/login',
handle((req) => {
const body = bodyOf(req)
return setMockLoginStatus(req.params.sessionId, body.status)
}),
)
router.post(
'/__control/sessions/:sessionId/binding',
handle((req) => {
const body = bodyOf(req)
return setMockBindingStatus(req.params.sessionId, body.status)
}),
)
router.post(
'/__control/reset',
handle(() => resetMockSessions()),
)
export default router
@@ -8,22 +8,31 @@ import { createOrder, findOrderByPlatformOrderId } from '../../repositories/orde
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
import { createTask, updateTask } from '../../repositories/task-repo.js'
import { upsertKuaishouIndustryVoucher } from '../../repositories/kuaishou-industry-voucher-repo.js'
import { query, withTransaction } from '../../db/client.js'
import {
getFulfillmentProfileByKey,
upsertFulfillmentProfile,
} from '../../repositories/fulfillment-profile-repo.js'
import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.js'
import { saveManualFulfillmentConfig } from '../cost-center/manual-fulfillment-config-service.js'
import {
assignFulfillmentChannelsWithExecutor,
attachFulfillmentConfigurationsWithExecutor,
} from '../fulfillment/fulfillment-state-service.js'
import { containsCjk } from '../fulfillment/internal-sku-resolver.js'
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, assertOpen91Config } from '../open-91/config.js'
import {
getKuaishouIndustrySourceConfig,
listKuaishouIndustryShopConfigs,
} from '../platforms/kuaishou-industry/source-config-service.js'
import { isProductionLike } from '../../config/runtime-validation.js'
import { runtimeConfig } from '../../config/runtime.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import { TASK_STATUS } from '../../domain/task-status.js'
import { ensureSemiAutoTasksForOrder } from '../semi-auto/semi-auto-task-service.js'
export type DevMockCreateResult = {
platform: 'affiliate_dash'
@@ -51,6 +60,21 @@ export type DevMockKuaishouIndustryVoucherResult = {
}>
}
export type DevMockSemiAutoResult = {
platform: 'semi_auto'
orderId: number
orderNo: string
orderItemId: number
fulfillmentId: number
configId: number
taskId: number
skuKey: string
connectorMode: 'mock' | 'external'
customerPath: string
adminPath: string
tips: string[]
}
export function isDevMockEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
if (String(env.ENABLE_DEV_MOCK || '').trim() === '1') {
return true
@@ -87,12 +111,32 @@ export function getDevMockStatus() {
description: '本站 claim + mock 绑定二维码/提交发货(不调真实 affiliate-dash 平台)',
steps: ['uid', 'bind', 'submitted', 'completed', 'failed'],
},
{
key: 'semi_auto',
name: '半自动履约',
description:
'创建人工渠道半自动测试单;登录、绑定和浏览器票据由内置 Mock Connector 模拟',
steps: ['create_order', 'login', 'binding', 'browser_ticket', 'complete'],
},
]
: [],
defaultUid: '10001',
semiAuto: {
connectorMode: semiAutoConnectorMode(),
connectorConfigured: Boolean(
String(runtimeConfig.semiAuto?.connectorBaseUrl || '').trim() &&
String(runtimeConfig.semiAuto?.connectorApiKey || '').trim(),
),
platform: 'huya',
},
}
}
function semiAutoConnectorMode(): 'mock' | 'external' {
const baseUrl = String(runtimeConfig.semiAuto?.connectorBaseUrl || '').trim()
return baseUrl.includes('/dev-mock/connector') ? 'mock' : 'external'
}
type AffiliateDashMockStep = 'uid' | 'bind' | 'submitted' | 'completed' | 'failed'
/**
@@ -284,9 +328,188 @@ export async function createAffiliateDashMockClaim(
}
/**
* 生成电子凭证列表测试数据,不调用快手接口。
* 覆盖未使用、已核销、已销毁和发码失败等运营页面常见状态。
* 生成半自动履约测试单:人工 SKU 配置、订单、履约和半自动任务都走真实领域服务。
* 平台登录、绑定和浏览器票据由内置 Mock Connector 模拟。
*/
export async function createSemiAutoMock(
input: {
orderNo?: unknown
skuName?: unknown
quantity?: unknown
pageTitle?: unknown
pageDescription?: unknown
browserTargetUrl?: unknown
} = {},
): Promise<DevMockSemiAutoResult> {
assertDevMockEnabled()
const orderNo = String(input.orderNo || `MOCKSA${Date.now()}`).trim()
const skuKey = normalizeMockSemiAutoSku(input.skuName)
const quantity = normalizeMockSemiAutoQuantity(input.quantity)
const pageTitle = String(input.pageTitle || '虎牙账号授权测试')
.trim()
.slice(0, 60)
const pageDescription = String(
input.pageDescription || '开发 Mock:完成登录和角色绑定后,客服在后台完成履约。',
)
.trim()
.slice(0, 500)
const browserTargetUrl = String(input.browserTargetUrl || 'https://www.huya.com/')
.trim()
.slice(0, 1000)
await assertOrderNoAvailable(orderNo)
const config = await saveManualFulfillmentConfig({
skuKey,
enabled: true,
note: '开发 Mock 半自动履约',
channels: [{ platformKey: 'fund_cash', creditQuantity: 100, enabled: true }],
semiAutoEnabled: true,
semiAutoPlatform: 'huya',
semiAutoConfig: {
pageTitle,
pageDescription,
loginInstructions: 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。',
bindingInstructions: 'Mock 绑定:页面轮询两次后会自动识别角色,点击确认即可。',
browserTargets: [{ key: 'huya', label: '虎牙', url: browserTargetUrl }],
},
})
const configId = Number(config.id || 0)
const canonicalProductId = config.canonicalProductId ? Number(config.canonicalProductId) : null
if (!configId) {
throw createHttpError('半自动履约 Mock 配置创建失败', {
statusCode: 500,
errorCode: 'dev_mock_semi_auto_config_failed',
})
}
const now = nowIso()
const order = await createBaseOrder({
orderNo,
productNo: skuKey,
productName: skuKey,
now,
platformTag: 'semi-auto',
totalAmount: 100 * quantity,
})
const [orderItem] = await replaceOrderItems(order.id, [
{
skuCode: skuKey,
skuName: skuKey,
quantity,
specJson: JSON.stringify({
source: 'dev_mock',
skuNick: skuKey,
semiAuto: { platform: 'huya', mock: true },
}),
itemSnapshotJson: JSON.stringify({
source: 'dev_mock',
skuKey,
platform: 'huya',
mock: true,
}),
createdAt: now,
updatedAt: now,
},
])
const orderItemId = Number(orderItem?.id || 0)
if (!orderItemId) {
throw createHttpError('半自动履约 Mock 订单项创建失败', {
statusCode: 500,
errorCode: 'dev_mock_semi_auto_item_failed',
})
}
const fulfillmentResult = await query<{ id: number }>(
'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1',
[orderItemId],
)
const fulfillmentId = Number(fulfillmentResult.rows[0]?.id || 0)
if (!fulfillmentId) {
throw createHttpError('半自动履约 Mock 履约单创建失败', {
statusCode: 500,
errorCode: 'dev_mock_semi_auto_fulfillment_failed',
})
}
await withTransaction(async (client) => {
await assignFulfillmentChannelsWithExecutor(client, {
assignments: [{ orderItemId, channel: 'manual', source: 'manual_confirm' }],
eventType: 'dev_mock_semi_auto_channel_assigned',
reason: 'dev_mock',
actor: { type: 'system' },
})
await attachFulfillmentConfigurationsWithExecutor(client, {
mappings: [
{
orderItemId,
configId,
...(canonicalProductId ? { canonicalProductId } : {}),
},
],
expected: { status: 'assigned', channel: 'manual' },
onlyWhenConfigNull: true,
})
})
const ensured = await ensureSemiAutoTasksForOrder(orderNo)
let taskId = Number(ensured.createdIds[0] || 0)
if (!taskId) {
const existing = await query<{ id: number }>(
'SELECT id FROM semi_auto_tasks WHERE order_item_id = $1 LIMIT 1',
[orderItemId],
)
taskId = Number(existing.rows[0]?.id || 0)
}
if (!taskId) {
throw createHttpError('半自动履约 Mock 任务创建失败', {
statusCode: 500,
errorCode: 'dev_mock_semi_auto_task_failed',
})
}
const connectorMode = semiAutoConnectorMode()
return {
platform: 'semi_auto',
orderId: Number(order.id),
orderNo,
orderItemId,
fulfillmentId,
configId,
taskId,
skuKey,
connectorMode,
customerPath: `/c/${encodeURIComponent(orderNo)}`,
adminPath: `/admin/semi-auto-tasks?keyword=${encodeURIComponent(orderNo)}`,
tips: [
'打开客户页,点击“获取登录二维码”;Mock Connector 会在两次轮询后自动模拟登录成功。',
'登录后页面会自动创建绑定二维码,轮询两次后识别角色,点击“确认绑定角色”。',
'后台“任务 → 半自动履约”领取任务后生成浏览器票据;扩展消费票据后写入 Mock Cookie。',
'完成履约仍走真实确认流程,会写入成本快照和财务确认。',
connectorMode === 'mock'
? '当前使用内置 Mock Connector,不需要启动 live-hub-py。'
: '当前配置的是外部 Connector;请确认 Connector 已启动,否则登录和绑定会失败。',
],
}
}
function normalizeMockSemiAutoSku(value: unknown) {
const raw = String(value || '').trim()
const withCjk = raw
? containsCjk(raw)
? raw
: `虎牙半自动-${raw}`
: `虎牙半自动测试-${Date.now()}`
return withCjk.slice(0, 48)
}
function normalizeMockSemiAutoQuantity(value: unknown) {
const parsed = Math.trunc(Number(value || 1))
if (!Number.isFinite(parsed) || parsed < 1) return 1
return Math.min(10, parsed)
}
export async function createKuaishouIndustryVoucherMockData(
input: {
sellerId?: unknown
@@ -499,7 +722,9 @@ async function createBaseOrder(input: {
productName: string
now: string
platformTag: string
totalAmount?: number
}) {
const totalAmount = input.totalAmount ?? 100
const order = await createOrder({
provider: OPEN_91_PROVIDER,
platform: OPEN_91_PLATFORM,
@@ -511,7 +736,7 @@ async function createBaseOrder(input: {
buyerId: 'mock-buyer',
buyerName: 'mock 领取客户',
receiverContact: '',
totalAmount: 100,
totalAmount,
currency: 'CNY',
rawPayloadJson: JSON.stringify({
source: OPEN_91_PROVIDER,
@@ -523,7 +748,7 @@ async function createBaseOrder(input: {
productNo: input.productNo,
productName: input.productName,
buyNum: 1,
maxAmount: '1.00',
maxAmount: (totalAmount / 100).toFixed(2),
},
}),
paidAt: input.now,
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
confirmMockBinding,
consumeMockBrowserTicket,
createMockBrowserTicket,
createMockLoginSession,
getMockBinding,
getMockLoginSession,
resetMockSessions,
revokeMockSession,
setMockBindingStatus,
setMockLoginStatus,
startMockBinding,
} from './mock-semi-auto-connector.js'
test.beforeEach(() => resetMockSessions())
test('Mock Connector 可按轮询自动完成登录、绑定和一次性票据消费', () => {
const created = createMockLoginSession({ taskId: 101, orderNo: 'MOCK-SA-1' })
assert.equal(created.status, 'waiting_scan')
assert.equal(created.hasCredential, false)
assert.match(created.qrContent, /^mock-huya-login:\/\//)
const scanned = getMockLoginSession(created.sessionId)
assert.equal(scanned.status, 'scanned')
assert.equal(scanned.hasCredential, false)
const authenticated = getMockLoginSession(created.sessionId)
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.hasCredential, true)
assert.ok(authenticated.huyaUid)
const binding = startMockBinding(created.sessionId)
assert.equal(binding.status, 'qr_pending')
assert.ok(binding.qrImage.startsWith('data:image/svg+xml;base64,'))
assert.equal(getMockBinding(created.sessionId).status, 'scanned')
const roleReady = getMockBinding(created.sessionId)
assert.equal(roleReady.status, 'role_ready')
assert.ok(roleReady.bindGameRole)
const confirmed = confirmMockBinding(created.sessionId)
assert.equal(confirmed.status, 'confirmed')
const ticket = createMockBrowserTicket({
sessionId: created.sessionId,
targetUrl: 'https://www.huya.com/mock',
})
assert.equal(ticket.targetUrl, 'https://www.huya.com/mock')
const payload = consumeMockBrowserTicket(ticket.ticket)
assert.equal(payload.platform, 'huya')
assert.ok(payload.cookies.length >= 1)
assert.equal(payload.cookies[0]?.domain, '.huya.com')
assert.throws(() => consumeMockBrowserTicket(ticket.ticket), /已被消费/)
})
test('Mock Connector 支持控制状态、撤销会话和重置', () => {
const created = createMockLoginSession({ taskId: 202, orderNo: 'MOCK-SA-2' })
const authenticated = setMockLoginStatus(created.sessionId, 'authenticated')
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.hasCredential, true)
const confirmed = setMockBindingStatus(created.sessionId, 'confirmed')
assert.equal(confirmed.status, 'confirmed')
assert.ok(confirmed.bindGameRole)
revokeMockSession(created.sessionId)
assert.throws(() => getMockLoginSession(created.sessionId), /不存在或已失效/)
const second = createMockLoginSession({ taskId: 203, orderNo: 'MOCK-SA-3' })
const reset = resetMockSessions()
assert.equal(reset.cleared, 1)
assert.throws(() => getMockLoginSession(second.sessionId), /不存在或已失效/)
})
@@ -0,0 +1,405 @@
/**
* 开发环境内置的半自动履约 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 }
}
@@ -339,3 +339,77 @@ test(
}
},
)
test(
'开发 Mock 可生成半自动履约测试单并回填任务',
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
process.env.ENABLE_DEV_MOCK = '1'
const { closeDb, query } = await import('../../db/client.js')
const { createSemiAutoMock } = await import('../dev-mock/dev-mock-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const orderNo = `IT-MOCK-SA-${suffix}`
const skuKey = `半自动Mock-${suffix}`
let orderId = 0
let configId = 0
try {
const result = await createSemiAutoMock({
orderNo,
skuName: skuKey,
quantity: 1,
pageTitle: 'Mock 集成测试',
})
orderId = result.orderId
configId = result.configId
assert.ok(result.orderItemId > 0)
assert.ok(result.fulfillmentId > 0)
assert.ok(result.taskId > 0)
assert.ok(['mock', 'external'].includes(result.connectorMode))
assert.equal(result.customerPath, `/c/${encodeURIComponent(orderNo)}`)
const row = await query<{
channel: string
fulfillment_status: string
fulfillment_config_id: number
semi_auto_enabled: boolean
semi_auto_platform: string
login_status: string
binding_status: string
execution_status: string
}>(
`
SELECT
f.channel,
f.status AS fulfillment_status,
f.fulfillment_config_id,
cfg.semi_auto_enabled,
cfg.semi_auto_platform,
task.login_status,
task.binding_status,
task.execution_status
FROM semi_auto_tasks task
JOIN fulfillments f ON f.id = task.fulfillment_id
JOIN manual_fulfillment_configs cfg ON cfg.id = task.manual_fulfillment_config_id
WHERE task.order_item_id = $1
`,
[result.orderItemId],
)
assert.equal(row.rows[0]?.channel, 'manual')
assert.equal(row.rows[0]?.fulfillment_status, 'assigned')
assert.equal(Number(row.rows[0]?.fulfillment_config_id), result.configId)
assert.equal(row.rows[0]?.semi_auto_enabled, true)
assert.equal(row.rows[0]?.semi_auto_platform, 'huya')
assert.equal(row.rows[0]?.login_status, 'waiting_login')
assert.equal(row.rows[0]?.binding_status, 'not_started')
assert.equal(row.rows[0]?.execution_status, 'waiting_customer')
} finally {
if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId])
if (configId > 0) {
await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
}
delete process.env.ENABLE_DEV_MOCK
await closeDb()
}
},
)
@@ -8,6 +8,7 @@ import {
Divider,
Form,
Input,
InputNumber,
Result,
Row,
Select,
@@ -23,10 +24,12 @@ import {
buildOpen91QueryDevMock,
createAffiliateDashDevMock,
createKuaishouIndustryVoucherDevMock,
createSemiAutoDevMock,
fetchDevMockStatus,
type DevMockCreateResult,
type DevMockKuaishouIndustryVoucherResult,
type DevMockOpen91QueryResult,
type DevMockSemiAutoResult,
} from '@/services/admin'
export default function AdminDevMockPage() {
@@ -35,6 +38,7 @@ export default function AdminDevMockPage() {
const [lastVoucherCreate, setLastVoucherCreate] =
useState<DevMockKuaishouIndustryVoucherResult | null>(null)
const [lastQuery, setLastQuery] = useState<DevMockOpen91QueryResult | null>(null)
const [lastSemiAuto, setLastSemiAuto] = useState<DevMockSemiAutoResult | null>(null)
const statusQuery = useQuery({
queryKey: ['admin-dev-mock-status'],
@@ -78,6 +82,17 @@ export default function AdminDevMockPage() {
},
})
const semiAutoMutation = useMutation({
mutationFn: createSemiAutoDevMock,
onSuccess: (response) => {
setLastSemiAuto(response.data)
message.success('半自动履约 Mock 已生成')
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '生成失败')
},
})
const defaultOrderNo = useMemo(() => lastCreate?.orderNo || '', [lastCreate?.orderNo])
if (statusQuery.isLoading) {
@@ -134,7 +149,17 @@ export default function AdminDevMockPage() {
</Col>
<Col xs={24} xl={12}>
<Card title="2. 快手电子凭证测试数据" bordered={false}>
<Card title="2. 半自动履约测试单(Mock Connector)" bordered={false}>
<SemiAutoMockForm
loading={semiAutoMutation.isPending}
connectorMode={status?.semiAuto?.connectorMode}
onSubmit={(values) => semiAutoMutation.mutate(values)}
/>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card title="3. 快手电子凭证测试数据" bordered={false}>
<KuaishouIndustryVoucherMockForm
loading={voucherMutation.isPending}
onSubmit={(values) => voucherMutation.mutate(values)}
@@ -143,7 +168,7 @@ export default function AdminDevMockPage() {
</Col>
<Col xs={24}>
<Card title="3. 模拟 91 查单(open-91/query)" bordered={false}>
<Card title="4. 模拟 91 查单(open-91/query)" bordered={false}>
<Form
layout="inline"
initialValues={{ orderNo: defaultOrderNo, execute: '1' }}
@@ -260,6 +285,81 @@ export default function AdminDevMockPage() {
</Card>
) : null}
{lastSemiAuto ? (
<Card
title="最近生成的半自动履约 Mock"
extra={<Link to={lastSemiAuto.adminPath}>打开后台任务</Link>}
>
<Row gutter={[16, 12]}>
<Col xs={24} md={12}>
<Typography.Text type="secondary">订单号</Typography.Text>
<div>
<Typography.Text copyable strong>
{lastSemiAuto.orderNo}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={12}>
<Typography.Text type="secondary">内部 SKU / 任务</Typography.Text>
<div>
{lastSemiAuto.skuKey} · 任务 #{lastSemiAuto.taskId}
</div>
</Col>
<Col span={24}>
<Typography.Text type="secondary">客户履约入口</Typography.Text>
<div>
<Typography.Link
href={`${window.location.origin}${lastSemiAuto.customerPath}`}
target="_blank"
rel="noreferrer"
>
<LinkOutlined /> {window.location.origin}
{lastSemiAuto.customerPath}
</Typography.Link>
</div>
<Space style={{ marginTop: 8 }} wrap>
<Button
icon={<CopyOutlined />}
onClick={async () => {
await navigator.clipboard.writeText(
`${window.location.origin}${lastSemiAuto.customerPath}`,
)
message.success('已复制客户履约链接')
}}
>
复制客户链接
</Button>
<Button
type="primary"
href={`${window.location.origin}${lastSemiAuto.customerPath}`}
target="_blank"
>
打开客户页
</Button>
</Space>
</Col>
<Col span={24}>
<Alert
type={lastSemiAuto.connectorMode === 'mock' ? 'success' : 'warning'}
showIcon
message={
lastSemiAuto.connectorMode === 'mock'
? '当前使用内置 Mock Connector'
: '当前使用外部 Connector'
}
description={
<ul style={{ margin: 0, paddingLeft: 18 }}>
{lastSemiAuto.tips.map((tip) => (
<li key={tip}>{tip}</li>
))}
</ul>
}
/>
</Col>
</Row>
</Card>
) : null}
{lastVoucherCreate ? (
<Card
title="最近生成的电子凭证 Mock"
@@ -400,3 +500,72 @@ function KuaishouIndustryVoucherMockForm({
</Form>
)
}
function SemiAutoMockForm({
loading,
connectorMode,
onSubmit,
}: {
loading: boolean
connectorMode?: 'mock' | 'external'
onSubmit: (values: {
orderNo?: string
skuName?: string
quantity?: number
pageTitle?: string
browserTargetUrl?: string
}) => void
}) {
return (
<Form
layout="vertical"
initialValues={{
quantity: 1,
pageTitle: '虎牙账号授权测试',
browserTargetUrl: 'https://www.huya.com/',
}}
onFinish={onSubmit}
>
<Form.Item
label="内部 SKU 名称(可空)"
name="skuName"
extra="留空自动生成“虎牙半自动测试-时间戳”;必须包含中文。"
>
<Input allowClear placeholder="虎牙半自动测试" />
</Form.Item>
<Form.Item label="订单号 orderNo(可空)" name="orderNo">
<Input allowClear placeholder="留空自动 MOCKSA…" />
</Form.Item>
<Space size={12} style={{ display: 'flex' }}>
<Form.Item label="数量" name="quantity" style={{ flex: 1 }}>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="客户页标题" name="pageTitle" style={{ flex: 2 }}>
<Input />
</Form.Item>
</Space>
<Form.Item
label="客服浏览器目标页"
name="browserTargetUrl"
extra="客服点击“打开浏览器登录态”时,扩展会注入到此页面。"
>
<Input placeholder="https://www.huya.com/" />
</Form.Item>
<Alert
type={connectorMode === 'mock' ? 'success' : 'warning'}
showIcon
style={{ marginBottom: 12 }}
message={
connectorMode === 'mock'
? '内置 Mock Connector 已启用,登录和绑定会自动推进。'
: connectorMode === 'external'
? '当前配置为外部 Connector,请先确认外部服务已启动。'
: 'Connector 状态未知;生成后可在客户页验证。'
}
/>
<Button type="primary" htmlType="submit" loading={loading} block>
生成半自动履约 Mock
</Button>
</Form>
)
}
@@ -10,6 +10,11 @@ export type DevMockStatus = {
description: string
steps: string[]
}>
semiAuto?: {
connectorMode: 'mock' | 'external'
connectorConfigured: boolean
platform: string
}
}
export type DevMockCreateResult = {
@@ -45,6 +50,21 @@ export type DevMockKuaishouIndustryVoucherResult = {
}>
}
export type DevMockSemiAutoResult = {
platform: 'semi_auto'
orderId: number
orderNo: string
orderItemId: number
fulfillmentId: number
configId: number
taskId: number
skuKey: string
connectorMode: 'mock' | 'external'
customerPath: string
adminPath: string
tips: string[]
}
export function fetchDevMockStatus() {
return apiGet<DevMockStatus>('/api/v1/admin/dev-mock/status')
}
@@ -66,6 +86,17 @@ export function createKuaishouIndustryVoucherDevMock(payload: { sellerId?: strin
)
}
export function createSemiAutoDevMock(payload: {
orderNo?: string
skuName?: string
quantity?: number
pageTitle?: string
pageDescription?: string
browserTargetUrl?: string
}) {
return apiPost<DevMockSemiAutoResult>('/api/v1/admin/dev-mock/semi-auto', payload)
}
export function buildOpen91QueryDevMock(payload: {
orderNo: string
execute?: boolean