diff --git a/.env.mac-docker.example b/.env.mac-docker.example index d3f1d3d3..1cd356e2 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -70,7 +70,7 @@ KAQUAN91_USER_ID= KAQUAN91_SECRET= # 半自动履约 Connector(虎牙登录/绑定/CK 注入票据) -SEMI_AUTO_CONNECTOR_BASE_URL=http://host.docker.internal:8800 +SEMI_AUTO_CONNECTOR_BASE_URL=http://127.0.0.1:3000/api/v1/dev-mock/connector SEMI_AUTO_CONNECTOR_API_KEY=local-semi-auto-connector-key SEMI_AUTO_CONNECTOR_TIMEOUT_MS=15000 diff --git a/README.md b/README.md index b989f360..fa99824b 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,34 @@ cp .env.server.example .env | `SEMI_AUTO_CONNECTOR_API_KEY` | order_site 与 Connector 的内部鉴权密钥 | | `SEMI_AUTO_CONNECTOR_TIMEOUT_MS` | Connector 请求超时,默认 15000 毫秒 | +### 本地半自动履约 Mock + +`docker-compose.dev.yml` 默认把 Connector 指向 order_site 自带的开发 Mock: + +```text +http://127.0.0.1:3000/api/v1/dev-mock/connector +``` + +本地验证流程: + +1. 打开后台 `/admin/dev-mock`,在“半自动履约测试单”中生成订单。 +2. 打开客户链接,点击获取登录二维码;Mock 会在两次轮询后自动模拟登录成功。 +3. 页面自动创建绑定二维码,轮询后识别测试角色,点击确认绑定。 +4. 打开后台“任务 → 半自动履约”,领取任务并生成浏览器注入票据。 +5. 完成履约,核对任务、履约、成本快照和财务确认。 + +需要接真实 Connector 时,显式覆盖 `SEMI_AUTO_CONNECTOR_BASE_URL`,例如 +`http://host.docker.internal:8800`。开发 Mock 路由在非开发环境返回 404。 + +半自动履约真实 PostgreSQL 集成测试需要独立库,避免写入生产 dump: + +```bash +cd apps/backend +DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/order_site_it npm run db:migrate +SEMI_AUTO_TASK_TEST_DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/order_site_it \ + npx tsx --test src/services/semi-auto/semi-auto-task.integration.test.ts +``` + ## 运行数据 | 环境 | 路径 / Volume | diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 5b54702b..0445e72e 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -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( diff --git a/apps/backend/src/routes/admin/dev-mock.ts b/apps/backend/src/routes/admin/dev-mock.ts index 9fb3f979..0638d28f 100644 --- a/apps/backend/src/routes/admin/dev-mock.ts +++ b/apps/backend/src/routes/admin/dev-mock.ts @@ -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 || {}), { diff --git a/apps/backend/src/routes/dev-mock-connector.ts b/apps/backend/src/routes/dev-mock-connector.ts new file mode 100644 index 00000000..63a80526 --- /dev/null +++ b/apps/backend/src/routes/dev-mock-connector.ts @@ -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 { + return req.body && typeof req.body === 'object' && !Array.isArray(req.body) + ? (req.body as Record) + : {} +} + +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 diff --git a/apps/backend/src/services/dev-mock/dev-mock-service.ts b/apps/backend/src/services/dev-mock/dev-mock-service.ts index d3f08e21..c1ede1e8 100644 --- a/apps/backend/src/services/dev-mock/dev-mock-service.ts +++ b/apps/backend/src/services/dev-mock/dev-mock-service.ts @@ -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 { + 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, diff --git a/apps/backend/src/services/dev-mock/mock-semi-auto-connector.test.ts b/apps/backend/src/services/dev-mock/mock-semi-auto-connector.test.ts new file mode 100644 index 00000000..1d656d81 --- /dev/null +++ b/apps/backend/src/services/dev-mock/mock-semi-auto-connector.test.ts @@ -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), /不存在或已失效/) +}) diff --git a/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts b/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts new file mode 100644 index 00000000..dd4d598b --- /dev/null +++ b/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts @@ -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 +} + +const sessions = new Map() + +function buildMockUid(sessionId: string) { + return `mock-huya-${sessionId.slice(-8)}` +} + +function buildMockBindingQr(sessionId: string) { + const svg = ` + + + + MOCK BIND + ${escapeXml(sessionId.slice(-12))} + 虎牙角色绑定测试二维码 + + `.trim() + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` +} + +function escapeXml(value: string) { + return value.replace(/[<>&'"]/g, (char) => { + const entities: Record = { + '<': '<', + '>': '>', + '&': '&', + "'": ''', + '"': '"', + } + 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 } +} diff --git a/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts b/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts index cd8adc38..a5c73678 100644 --- a/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts +++ b/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts @@ -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() + } + }, +) diff --git a/apps/frontend/src/pages/admin/AdminDevMockPage.tsx b/apps/frontend/src/pages/admin/AdminDevMockPage.tsx index c4f62e45..20250797 100644 --- a/apps/frontend/src/pages/admin/AdminDevMockPage.tsx +++ b/apps/frontend/src/pages/admin/AdminDevMockPage.tsx @@ -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(null) const [lastQuery, setLastQuery] = useState(null) + const [lastSemiAuto, setLastSemiAuto] = useState(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() { - + + semiAutoMutation.mutate(values)} + /> + + + + + voucherMutation.mutate(values)} @@ -143,7 +168,7 @@ export default function AdminDevMockPage() { - +
) : null} + {lastSemiAuto ? ( + 打开后台任务} + > + + + 订单号 +
+ + {lastSemiAuto.orderNo} + +
+ + + 内部 SKU / 任务 +
+ {lastSemiAuto.skuKey} · 任务 #{lastSemiAuto.taskId} +
+ + + 客户履约入口 +
+ + {window.location.origin} + {lastSemiAuto.customerPath} + +
+ + + + + + + + {lastSemiAuto.tips.map((tip) => ( +
  • {tip}
  • + ))} + + } + /> + +
    +
    + ) : null} + {lastVoucherCreate ? ( ) } + +function SemiAutoMockForm({ + loading, + connectorMode, + onSubmit, +}: { + loading: boolean + connectorMode?: 'mock' | 'external' + onSubmit: (values: { + orderNo?: string + skuName?: string + quantity?: number + pageTitle?: string + browserTargetUrl?: string + }) => void +}) { + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/frontend/src/services/admin/dev-mock.ts b/apps/frontend/src/services/admin/dev-mock.ts index 981e43f1..fcf0d4d4 100644 --- a/apps/frontend/src/services/admin/dev-mock.ts +++ b/apps/frontend/src/services/admin/dev-mock.ts @@ -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('/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('/api/v1/admin/dev-mock/semi-auto', payload) +} + export function buildOpen91QueryDevMock(payload: { orderNo: string execute?: boolean diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ac286475..b1f4a2ce 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -118,7 +118,8 @@ services: WORKSPACE_ROOT: /workspace # ADMIN_* 仅走 env_file,避免 JSON 经 ${} 注入时被 YAML 破坏 KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120} - SEMI_AUTO_CONNECTOR_BASE_URL: ${SEMI_AUTO_CONNECTOR_BASE_URL:-http://host.docker.internal:8800} + # 默认使用 order_site 内置 Mock Connector;需要接外部 Connector 时显式覆盖。 + SEMI_AUTO_CONNECTOR_BASE_URL: ${SEMI_AUTO_CONNECTOR_BASE_URL:-http://127.0.0.1:3000/api/v1/dev-mock/connector} SEMI_AUTO_CONNECTOR_API_KEY: ${SEMI_AUTO_CONNECTOR_API_KEY:-local-semi-auto-connector-key} SEMI_AUTO_CONNECTOR_TIMEOUT_MS: ${SEMI_AUTO_CONNECTOR_TIMEOUT_MS:-15000} logging: *json-log-rotation