优化:补充半自动履约开发 Mock 与内置 Connector
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> = {
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user