优化:补充半自动履约开发 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
@@ -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,