完善半自动真实履约链路及虎牙账号资料保存

This commit is contained in:
yml
2026-11-14 09:23:37 +08:00
parent 3ea5dd5ce4
commit 81a7721ee1
13 changed files with 662 additions and 31 deletions
+22
View File
@@ -4,6 +4,7 @@ import {
buildOpen91QueryMock,
createAffiliateDashMockClaim,
createKuaishouIndustryVoucherMockData,
createSemiAutoIntegrationMock,
createSemiAutoMock,
getDevMockStatus,
isDevMockEnabled,
@@ -96,6 +97,27 @@ router.post(
}),
)
router.post(
'/dev-mock/semi-auto-integration',
createJsonHandler((req) => createSemiAutoIntegrationMock(req.body || {}), {
successMessage: '半自动真实履约链路测试单已生成',
errorMessage: '生成半自动真实履约链路测试单失败',
scope: '[admin/dev-mock/semi-auto-integration]',
audit: (_req, data) => ({
action: 'dev_mock_create_semi_auto_integration',
targetType: 'dev_mock',
targetId: String((data as { orderNo?: string })?.orderNo || ''),
data: {
platform: 'semi_auto',
pipeline: 'order_service',
orderNo: (data as { orderNo?: string })?.orderNo,
skuKey: (data as { skuKey?: string })?.skuKey,
connectorMode: (data as { connectorMode?: string })?.connectorMode,
},
}),
}),
)
router.post(
'/dev-mock/open91/query',
createJsonHandler((req) => buildOpen91QueryMock(req.body || {}), {
@@ -24,6 +24,7 @@ import {
parseManualChannelQuantity,
} from './manual-credit-quantity.js'
import { productSourceFilterSql } from './cost-center-mappers.js'
import { ensureSemiAutoTasksForOrderItems } from '../semi-auto/semi-auto-task-service.js'
/**
* 人工履约 SKU 配置服务。
@@ -944,6 +945,7 @@ export async function rematchManualFulfillments(input: Record<string, unknown> =
const updatedIds = attached.map((row) => Number(row.id))
if (updatedIds.length > 0) {
await resolveExceptionsForItemsByReason(updatedIds, 'manual_config_missing')
await ensureSemiAutoTasksForOrderItems(updatedIds)
}
// 配置生效后,把 SKU 对应的成本版本挂上(若已配置商品)。
@@ -951,6 +953,7 @@ export async function rematchManualFulfillments(input: Record<string, unknown> =
return {
updatedCount: attached.length,
orderItemIds: updatedIds,
skuKey,
dateFrom,
dateTo,
@@ -18,6 +18,7 @@ import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.
import { saveManualFulfillmentConfig } from '../cost-center/manual-fulfillment-config-service.js'
import {
assignFulfillmentChannelsWithExecutor,
attachFulfillmentConfigurations,
attachFulfillmentConfigurationsWithExecutor,
} from '../fulfillment/fulfillment-state-service.js'
import { containsCjk } from '../fulfillment/internal-sku-resolver.js'
@@ -31,12 +32,16 @@ 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'
import { ensureSemiAutoTasksForOrderItems } from '../semi-auto/semi-auto-task-service.js'
import {
defaultSemiAutoConnectorMode,
isExternalSemiAutoConnectorConfigured,
type SemiAutoConnectorMode,
} from '../semi-auto/huya-connector-client.js'
import { assertDevMockEnabled, isDevMockEnabled } from './dev-mock-enabled.js'
import { upsertInternalSkuMapping } from '../fulfillment/internal-sku-mapping-service.js'
import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js'
import { upsertOrderFromSource } from '../order/order-service.js'
export { assertDevMockEnabled, isDevMockEnabled }
@@ -83,6 +88,11 @@ export type DevMockSemiAutoResult = {
tips: string[]
}
export type DevSemiAutoIntegrationResult = DevMockSemiAutoResult & {
pipeline: 'order_service'
mappingId: number
}
export function getDevMockStatus() {
const enabled = isDevMockEnabled()
return {
@@ -521,6 +531,269 @@ export async function createSemiAutoMock(
}
}
/**
* 真实履约链路集成测试单。
*
* 订单必须通过正式 upsertOrderFromSource 入库,再走 SKU 映射、渠道判定和
* Carrier 补偿;不直接写 order/order_item/fulfillment。
*/
export async function createSemiAutoIntegrationMock(
input: {
platform?: unknown
orderNo?: unknown
skuName?: unknown
quantity?: unknown
pageTitle?: unknown
browserTargetUrl?: unknown
connectorMode?: unknown
} = {},
): Promise<DevSemiAutoIntegrationResult> {
assertDevMockEnabled()
const platform = normalizeMockSemiAutoPlatform(input.platform)
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const orderNo = String(
input.orderNo || `${platform === 'weibo' ? 'INTWB' : 'INTSA'}${suffix}`,
).trim()
const skuKey = normalizeMockSemiAutoSku(
input.skuName || `${platform === 'weibo' ? '微博' : '虎牙'}真实链路测试-${suffix}`,
platform,
)
const quantity = normalizeMockSemiAutoQuantity(input.quantity)
const connectorMode =
platform === 'weibo' ? 'embedded' : normalizeMockSemiAutoConnectorMode(input.connectorMode)
const provider = 'dev_integration'
const sourcePlatform = 'internal_test'
const shopId = 'dev-integration-shop'
const externalSkuCode = `INTEGRATION-${suffix}`
const pageTitle = String(
input.pageTitle || (platform === 'weibo' ? '微博账号登录授权' : '虎牙账号登录授权'),
)
.trim()
.slice(0, 60)
const browserTargetUrl = String(
input.browserTargetUrl ||
(platform === 'weibo' ? 'https://weibo.com/' : 'https://www.huya.com/'),
)
.trim()
.slice(0, 1000)
const existing = await findOrderByPlatformOrderId({
provider,
platform: sourcePlatform,
shopId,
platformOrderId: orderNo,
})
if (existing) {
throw createHttpError(`订单号已存在:${orderNo},请换一个或留空自动生成`, {
statusCode: 409,
errorCode: 'dev_integration_order_exists',
})
}
const config = await saveManualFulfillmentConfig({
skuKey,
enabled: true,
note: '开发集成测试:真实订单履约链路',
channels: [{ platformKey: 'fund_cash', creditQuantity: 100, enabled: true }],
semiAutoEnabled: true,
semiAutoPlatform: platform,
semiAutoConfig: {
pageTitle,
pageDescription: '真实入库链路测试订单:登录与 CK 获取使用当前 Connector。',
loginInstructions: '请使用本人账号完成登录授权,客服后续人工处理。',
loginMethods: ['sms', 'qr'],
browserTargets: [
{
key: platform,
label: platform === 'weibo' ? '微博' : '虎牙',
url: browserTargetUrl,
},
],
},
})
const configId = Number(config.id || 0)
if (!configId) {
throw createHttpError('创建真实链路人工配置失败', {
statusCode: 500,
errorCode: 'dev_integration_manual_config_failed',
})
}
const mappingId = await upsertInternalSkuMapping({
provider,
shopId,
matchType: 'sku_code',
matchValue: externalSkuCode,
internalSku: skuKey,
priority: 1000,
source: 'dev_integration',
note: `订单 ${orderNo}`,
})
const now = nowIso()
const upserted = await upsertOrderFromSource(
{
provider,
platform: sourcePlatform,
shopId,
shopName: '半自动真实链路测试店',
platformOrderId: orderNo,
orderStatus: 'paid',
payStatus: 'paid',
buyerId: `integration-buyer-${suffix}`,
buyerName: '集成测试客户',
receiverContact: '',
totalAmount: 100 * quantity,
currency: 'CNY',
rawPayload: {
source: 'dev_integration',
orderNo,
skuKey,
platform,
createdAt: now,
},
paidAt: now,
items: [
{
quantity,
skuCode: externalSkuCode,
skuName: skuKey,
spec: {},
snapshot: {
source: 'dev_integration',
semiAuto: { platform },
},
},
],
},
{ sourceLabel: 'dev_semi_auto_integration' },
)
if (upserted.ignored || !upserted.order) {
throw createHttpError(upserted.ignoreReason || '真实链路订单入库失败', {
statusCode: 500,
errorCode: 'dev_integration_order_failed',
})
}
const orderItemId = Number(upserted.orderItems[0]?.id || 0)
if (!orderItemId) {
throw createHttpError('真实链路订单项创建失败', {
statusCode: 500,
errorCode: 'dev_integration_order_item_failed',
})
}
await assignPendingOrderItemChannels({
orderItemIds: [orderItemId],
afterId: 0,
batchSize: 10,
maxBatches: 1,
})
const manualConfig = await query<{
config_id: number
canonical_product_id: number | null
}>(
`
SELECT cfg.id AS config_id, cfg.canonical_product_id
FROM v_order_item_current oi
JOIN manual_fulfillment_configs cfg
ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
AND cfg.enabled = TRUE
WHERE oi.id = $1
AND oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'manual'
LIMIT 1
`,
[orderItemId],
)
const manualConfigRow = manualConfig.rows[0]
if (!manualConfigRow) {
throw createHttpError('真实链路未匹配到已启用的 manual 配置', {
statusCode: 500,
errorCode: 'dev_integration_manual_config_missing',
})
}
await attachFulfillmentConfigurations({
mappings: [
{
orderItemId,
configId: Number(manualConfigRow.config_id),
...(manualConfigRow.canonical_product_id
? { canonicalProductId: Number(manualConfigRow.canonical_product_id) }
: {}),
},
],
expected: { status: 'assigned', channel: 'manual' },
onlyWhenConfigNull: true,
})
await ensureSemiAutoTasksForOrderItems([orderItemId])
const resolved = await query<{
fulfillment_id: number
fulfillment_channel: string
fulfillment_status: string
task_id: number
}>(
`
SELECT
f.id AS fulfillment_id,
f.channel AS fulfillment_channel,
f.status AS fulfillment_status,
task.id AS task_id
FROM fulfillments f
LEFT JOIN semi_auto_tasks task ON task.order_item_id = f.order_item_id
WHERE f.order_item_id = $1
LIMIT 1
`,
[orderItemId],
)
const row = resolved.rows[0]
if (
!row ||
row.fulfillment_channel !== 'manual' ||
row.fulfillment_status !== 'assigned' ||
!Number(row.task_id)
) {
throw createHttpError(
`真实链路未生成半自动任务:channel=${row?.fulfillment_channel || ''}, status=${row?.fulfillment_status || ''}, task=${row?.task_id || 0}`,
{ statusCode: 500, errorCode: 'dev_integration_semi_auto_task_failed' },
)
}
await query('UPDATE semi_auto_tasks SET connector_mode = $2, updated_at = NOW() WHERE id = $1', [
Number(row.task_id),
connectorMode,
])
const connectorConfigured =
connectorMode === 'external' ? isExternalSemiAutoConnectorConfigured() : true
return {
platform: 'semi_auto',
semiAutoPlatform: platform,
pipeline: 'order_service',
orderId: Number(upserted.order.id),
orderNo,
orderItemId,
fulfillmentId: Number(row.fulfillment_id),
configId,
taskId: Number(row.task_id),
skuKey,
connectorMode,
connectorConfigured,
mappingId,
customerPath: `/c/${encodeURIComponent(orderNo)}`,
adminPath: `/admin/semi-auto-tasks?keyword=${encodeURIComponent(orderNo)}`,
tips: [
'订单通过正式 upsertOrderFromSource 入库。',
'SKU 通过 internal_sku_mappings 解析,并按 sku_channel_routes 判定到 manual。',
'Carrier 补偿后生成正式 fulfillment 和 semi_auto_task。',
connectorConfigured
? '登录与 CK 获取使用当前 Connector。'
: '远程 Connector 未配置,请在后台任务中选择可用连接模式。',
],
}
}
function normalizeMockSemiAutoPlatform(value: unknown): 'huya' | 'weibo' {
return String(value || '')
.trim()
@@ -38,6 +38,7 @@ import {
scheduleVoucherConsumeFallbackForOrderItems,
scheduleVoucherConsumeForOrderItems,
} from './voucher-consume-queue-service.js'
import { ensureSemiAutoTasksForOrderItems } from '../semi-auto/semi-auto-task-service.js'
import {
assignFulfillmentChannelsWithExecutor,
attachFulfillmentConfigurations,
@@ -445,6 +446,12 @@ export async function assignPendingOrderItemChannels(
0,
2000,
)
await ensureSemiAutoTasksForOrderItems(assignedItemIds).catch((error) => {
logWarn('[channel-assign]', '半自动任务主动物化失败,等待后台回填', {
orderItemIds: assignedItemIds,
error: error instanceof Error ? error.message : String(error),
})
})
await scheduleVoucherConsumeForOrderItems(assignedItemIds, 'order_received').catch(
(error) => {
logWarn('[channel-assign]', '订单收到后安排自动核销失败', {
@@ -623,6 +630,12 @@ export async function assignOrderItemsAfterCarrierReady(input: {
}
if (assignedIds.length > 0) {
await ensureSemiAutoTasksForOrderItems(assignedIds).catch((error) => {
logWarn('[channel-assign]', '指定人工渠道后物化半自动任务失败', {
orderItemIds: assignedIds,
error: error instanceof Error ? error.message : String(error),
})
})
await scheduleVoucherConsumeForOrderItems(assignedIds, 'order_received').catch((error) => {
logWarn('[channel-assign]', '指定渠道后安排自动核销失败', {
orderItemIds: assignedIds,
@@ -33,6 +33,7 @@ import {
touchWatermark,
type FinancePipelineName,
} from './finance-pipeline-status.js'
import { ensureSemiAutoTasksForOrderItems } from '../semi-auto/semi-auto-task-service.js'
/**
* 财务与履约管道作业。
@@ -160,6 +161,9 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
onlyWhenConfigNull: true,
})
attached = configured.length
if (attached > 0) {
await ensureSemiAutoTasksForOrderItems(manual.rows.map((row) => Number(row.id)))
}
}
// 人工渠道但没有配置:进「人工 SKU 未配置」队列。
@@ -157,6 +157,10 @@ function extractInt(value: unknown): number {
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function readString(value: unknown): string {
return String(value ?? '').trim()
}
@@ -680,13 +684,13 @@ export class HuyaNativeConnector {
errorCode: 'huya_livelink_param_failed',
})
}
const profileResponse = await rpc.getUserProfileBatch({
const profile = await rpc.getUserProfile({
uid: session.uid,
cookie: session.cookie,
targetUids: [session.uid],
targetUid: session.uid,
})
const profile = profileResponse?.profiles[0]
const nickname = profile?.nick || profile?.passport || session.nickname
session.nickname = nickname
const avatar = profile?.avatar || ''
const redirectUrl =
'https://zt.huya.com/b02faae1/pc/index.html' +
@@ -972,7 +976,7 @@ export class HuyaNativeConnector {
data.username ??
cookieValue(responseCookie, 'udb_passport'),
) || ''
const nickname = readString(data.nickname ?? data.nickName ?? data.nick ?? username) || username
let nickname = readString(data.nickname ?? data.nickName ?? data.nick ?? username) || username
session.deviceInfo.sdid = session.deviceInfo.sdid || session.sdid
session.deviceInfo.hdid = session.deviceInfo.hdid || session.hdid
try {
@@ -1005,6 +1009,23 @@ export class HuyaNativeConnector {
qimeiFeature: qimeiFeatureFromDeviceProfile(session.deviceInfo),
})
session.uid = uid
const profile = await new HuyaRpcClient({
fetchImpl: this.fetchImpl,
timeoutMs: this.rpcTimeoutMs,
})
.getUserProfile({
uid,
cookie: session.cookie,
targetUid: uid,
})
.catch((error) => {
logWarn('huya/profile', '登录后查询虎牙用户资料失败,回退登录返回昵称', {
uid,
error: errorMessage(error),
})
return null
})
nickname = readString(profile?.nick || profile?.passport || nickname) || nickname
session.nickname = nickname
session.status = 'authenticated'
session.smsSessionData = ''
@@ -8,6 +8,7 @@ import { buildHuyaLoginContext, ensureHuyaWebCookieIdentity, x64hash128 } from '
import { HuyaNativeConnector } from './huya-native-connector.js'
import { detectHuyaSliderDistance } from './huya-sms-role.js'
import { hasExistingHuyaBinding, parseBindStruct } from './huya-rpc.js'
import { TAF_TYPE, TafWriter } from './taf.js'
const BIND_RESP_HEX =
'0a0100c816002a0001100120013a060016002602717136004612e6b3aae6958fe6849fe' +
@@ -15,6 +16,40 @@ const BIND_RESP_HEX =
'802604e6898b51360be88bb9e69e9c28694f53294600560066000b526a2595806c0b36' +
'0ce5928ce5b9b3e7b2bee88bb1460050070b'
function buildHuyaProfileResponse(input: {
uid: number
nickname: string
avatar?: string
passport?: string
}): Buffer {
const profileWriter = new TafWriter()
profileWriter.writeStruct(0, (writer) => {
writer.writeInt64(0, input.uid)
writer.writeString(1, input.nickname)
writer.writeString(2, input.avatar || '')
writer.writeString(17, input.passport || String(input.uid))
})
const dataWriter = new TafWriter()
dataWriter.writeHead(0, TAF_TYPE.MAP)
dataWriter.writeInt32(0, 1)
dataWriter.writeString(0, 'tRsp')
dataWriter.writeBytes(1, profileWriter.toBuffer())
const responseWriter = new TafWriter()
responseWriter.writeInt16(1, 3)
responseWriter.writeInt8(2, 0)
responseWriter.writeInt32(3, 0)
responseWriter.writeInt32(4, 1)
responseWriter.writeString(5, 'huyauserui')
responseWriter.writeString(6, 'getUserProfile')
responseWriter.writeBytes(7, dataWriter.toBuffer())
responseWriter.writeInt32(8, 3000)
responseWriter.writeMap(9, {})
responseWriter.writeMap(10, {})
return responseWriter.toBuffer()
}
test('QIMEI x64hash128 与 SDK 向量一致', () => {
assert.equal(x64hash128('', 31), '24700f9f1986800ab4fcc880530dd0ed')
assert.equal(x64hash128('a', 31), 'f8b09930c1df727ef75677a1d6ae5bd6')
@@ -182,6 +217,7 @@ test('已绑定账号和角色时直接使用当前绑定,不再发起换绑',
test('内置 Connector 完成扫码、CK 兑换和一次性票据且不泄露 CK', async () => {
const requests: Array<{ url: string; body: string; headers: Record<string, string> }> = []
let stage = 0
let profileCalls = 0
let now = Date.now()
const fetchImpl = (async (urlInput: string | URL | Request, init?: RequestInit) => {
const url = String(urlInput)
@@ -238,6 +274,18 @@ test('内置 Connector 完成扫码、CK 兑换和一次性票据且不泄露 CK
headers: { 'content-type': 'application/json' },
})
}
if (url.startsWith('https://cdnws.api.huya.com/')) {
profileCalls += 1
return new Response(
buildHuyaProfileResponse({
uid: 123456,
nickname: '扫码资料昵称',
avatar: 'https://example.com/avatar.jpg',
passport: 'hy_123456',
}),
{ status: 200, headers: { 'content-type': 'application/octet-stream' } },
)
}
throw new Error(`unexpected request: ${url}`)
}) as typeof fetch
@@ -285,13 +333,14 @@ test('内置 Connector 完成扫码、CK 兑换和一次性票据且不泄露 CK
const authenticated = await connector.getLoginSession(created.sessionId)
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.huyaUid, '123456')
assert.equal(authenticated.nickname, '测试用户')
assert.equal(authenticated.nickname, '扫码资料昵称')
assert.equal(profileCalls, 1)
assert.equal(JSON.stringify(authenticated).includes('secret-biztoken'), false)
assert.equal(JSON.stringify(authenticated).includes('server-issued-cred'), false)
const credential = connector.exportSessionCredential(created.sessionId)
assert.equal(credential.platformUid, '123456')
assert.equal(credential.nickname, '测试用户')
assert.equal(credential.nickname, '扫码资料昵称')
assert.equal(
credential.cookies.find((item) => item.name === 'udb_cred')?.value,
'server-issued-cred',
@@ -321,6 +370,7 @@ test('内置 Connector 完成扫码、CK 兑换和一次性票据且不泄露 CK
test('内置 Connector 手动发码并完成短信登录后保存账号 CK', async () => {
const requests: Array<{ url: string; body: string }> = []
let profileCalls = 0
const fetchImpl = (async (urlInput: string | URL | Request, init?: RequestInit) => {
const url = String(urlInput)
const body = typeof init?.body === 'string' ? init.body : ''
@@ -382,6 +432,17 @@ test('内置 Connector 手动发码并完成短信登录后保存账号 CK', asy
headers: { 'content-type': 'application/json' },
})
}
if (url.startsWith('https://cdnws.api.huya.com/')) {
profileCalls += 1
return new Response(
buildHuyaProfileResponse({
uid: 987654,
nickname: '短信资料昵称',
passport: 'hy_987654',
}),
{ status: 200, headers: { 'content-type': 'application/octet-stream' } },
)
}
throw new Error(`unexpected request: ${url}`)
}) as typeof fetch
@@ -428,11 +489,13 @@ test('内置 Connector 手动发码并完成短信登录后保存账号 CK', asy
})
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.huyaUid, '987654')
assert.equal(authenticated.nickname, '短信登录用户')
assert.equal(authenticated.nickname, '短信资料昵称')
assert.equal(profileCalls, 1)
assert.equal(JSON.stringify(authenticated).includes('sms-session-secret'), false)
const credential = connector.exportSessionCredential(sent.session.sessionId)
assert.equal(credential.platformUid, '987654')
assert.equal(credential.nickname, '短信资料昵称')
assert.equal(
credential.cookies.find((item) => item.name === 'udb_cred')?.value,
'server-issued-sms-cred',
@@ -421,6 +421,10 @@ function parseProfile(reader: TafReader): HuyaUserProfile {
return { uid, nick, avatar, passport }
}
function parseProfileResponse(reader: TafReader): HuyaUserProfile | null {
return parseProfile(reader)
}
function parseProfileBatch(reader: TafReader): HuyaUserProfileResponse {
const profiles =
reader.readList<HuyaUserProfile>(0, (itemReader, dataType) => {
@@ -543,6 +547,26 @@ export class HuyaRpcClient {
return response ? readWupStruct(response, parseProfileBatch) : null
}
async getUserProfile(input: {
uid: number
cookie: string
targetUid: number
}): Promise<HuyaUserProfile | null> {
const targetUid = Math.trunc(input.targetUid)
if (!(targetUid > 0)) return null
const response = await this.callRpc(
'huyauserui',
'getUserProfile',
input.uid,
input.cookie,
(writer) => {
this.writeActivityUser(writer, 0, input.cookie)
writer.writeInt64(1, targetUid)
},
)
return response ? readWupStruct(response, parseProfileResponse) : null
}
private async callRpc(
servant: string,
func: string,
@@ -523,13 +523,25 @@ type SemiAutoCandidate = {
quantity: number
}
async function insertMissingSemiAutoTasksWithClient(client: PoolClient, orderNo?: string) {
async function insertMissingSemiAutoTasksWithClient(
client: PoolClient,
orderNo?: string,
orderItemIds?: number[],
) {
const params: unknown[] = []
let orderFilter = ''
const filters: string[] = []
if (orderNo) {
params.push(orderNo)
orderFilter = 'AND o.platform_order_id = $1'
filters.push(`o.platform_order_id = $${params.length}`)
}
const normalizedItemIds = [
...new Set((orderItemIds || []).map(Number).filter((id) => Number.isInteger(id) && id > 0)),
]
if (normalizedItemIds.length > 0) {
params.push(normalizedItemIds)
filters.push(`oi.id = ANY($${params.length}::bigint[])`)
}
const itemFilter = filters.length > 0 ? `AND ${filters.join(' AND ')}` : ''
const candidates = await client.query<SemiAutoCandidate>(
`
SELECT
@@ -549,7 +561,7 @@ async function insertMissingSemiAutoTasksWithClient(client: PoolClient, orderNo?
AND cfg.enabled = TRUE
AND cfg.semi_auto_enabled = TRUE
AND cfg.semi_auto_platform IN ('huya', 'weibo')
${orderFilter}
${itemFilter}
AND NOT EXISTS (
SELECT 1 FROM semi_auto_tasks existing WHERE existing.order_item_id = oi.id
)
@@ -605,6 +617,24 @@ export async function ensureSemiAutoTasksForOrder(orderNoInput: unknown) {
return { orderNo, createdIds: result.createdIds }
}
export async function ensureSemiAutoTasksForOrderItems(input: unknown[]) {
const orderItemIds = [
...new Set(
(Array.isArray(input) ? input : [])
.map(Number)
.filter((id) => Number.isInteger(id) && id > 0),
),
]
if (orderItemIds.length === 0) return { createdIds: [], orderIds: [] }
const result = await withTransaction((client) =>
insertMissingSemiAutoTasksWithClient(client, undefined, orderItemIds),
)
for (const orderId of result.orderIds) {
publishAdminOrderRealtimeChange(orderId)
}
return { createdIds: result.createdIds, orderIds: result.orderIds }
}
/**
* 后台列表读取前的幂等回填。
*
@@ -14,7 +14,7 @@ test(
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const { closeDb, query, withTransaction } = await import('../../db/client.js')
const {
ensureSemiAutoTasksForOrder,
exportAdminSemiAutoCredential,
@@ -164,7 +164,10 @@ test(
reauthorized.events.some((event) => event.eventType === 'customer_reauth_requested'),
)
} finally {
if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId])
if (orderId > 0) {
await query('DELETE FROM order_source_snapshots WHERE order_id = $1', [orderId])
await query('DELETE FROM orders WHERE id = $1', [orderId])
}
if (credentialId > 0) {
await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId])
}
@@ -644,14 +647,19 @@ test(
assert.equal(tasks[0]?.loginStatus, 'authenticated')
assert.equal(tasks[0]?.executionStatus, 'waiting_operator')
assert.equal(tasks[0]?.credentialSource, 'sms')
assert.ok(tasks[0]?.huyaUid)
assert.equal(tasks[0]?.platformNickname, 'Mock虎牙用户')
const stored = await query<{
id: number
credential_source: string
credential_payload_json: string
platform_nickname: string
huya_uid: string
}>(
`SELECT credential.id, credential.credential_source,
credential.credential_payload_json
credential.credential_payload_json,
task.platform_nickname, task.huya_uid
FROM semi_auto_tasks task
JOIN semi_auto_credentials credential ON credential.id = task.credential_id
WHERE task.id = $1`,
@@ -661,6 +669,8 @@ test(
assert.ok(credentialId > 0)
assert.equal(stored.rows[0]?.credential_source, 'sms')
assert.match(stored.rows[0]?.credential_payload_json || '', /mock_huya_session/)
assert.equal(stored.rows[0]?.platform_nickname, 'Mock虎牙用户')
assert.equal(stored.rows[0]?.huya_uid, tasks[0]?.huyaUid)
} finally {
if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId])
if (configId > 0) {
@@ -675,3 +685,84 @@ test(
}
},
)
test(
'真实订单入口可生成 manual 履约和半自动任务',
{ 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, withTransaction } = await import('../../db/client.js')
const { createSemiAutoIntegrationMock } = await import('../dev-mock/dev-mock-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const orderNo = `IT-INTEGRATION-SA-${suffix}`
let orderId = 0
let configId = 0
let mappingId = 0
try {
const result = await createSemiAutoIntegrationMock({
platform: 'huya',
orderNo,
skuName: `真实链路虎牙测试-${suffix}`,
quantity: 1,
connectorMode: 'embedded',
})
orderId = result.orderId
configId = result.configId
mappingId = result.mappingId
assert.equal(result.pipeline, 'order_service')
assert.equal(result.orderNo, orderNo)
assert.ok(result.orderItemId > 0)
assert.ok(result.fulfillmentId > 0)
assert.ok(result.taskId > 0)
const stored = await query<{
channel: string
fulfillment_status: string
login_status: string
execution_status: string
config_id: number
}>(
`
SELECT
f.channel,
f.status AS fulfillment_status,
task.login_status,
task.execution_status,
task.manual_fulfillment_config_id AS config_id
FROM semi_auto_tasks task
JOIN fulfillments f ON f.id = task.fulfillment_id
WHERE task.id = $1
`,
[result.taskId],
)
assert.equal(stored.rows[0]?.channel, 'manual')
assert.equal(stored.rows[0]?.fulfillment_status, 'assigned')
assert.equal(stored.rows[0]?.login_status, 'waiting_login')
assert.equal(stored.rows[0]?.execution_status, 'waiting_customer')
assert.equal(Number(stored.rows[0]?.config_id), result.configId)
const mapping = await query<{ internal_sku: string }>(
'SELECT internal_sku FROM internal_sku_mappings WHERE id = $1',
[result.mappingId],
)
assert.equal(mapping.rows[0]?.internal_sku, result.skuKey)
} finally {
await withTransaction(async (client) => {
await client.query("SET LOCAL session_replication_role = 'replica'")
if (configId > 0) {
await client.query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
}
if (orderId > 0) {
await client.query('DELETE FROM order_source_snapshots WHERE order_id = $1', [orderId])
await client.query('DELETE FROM orders WHERE id = $1', [orderId])
}
if (mappingId > 0) {
await client.query('DELETE FROM internal_sku_mappings WHERE id = $1', [mappingId])
}
})
delete process.env.ENABLE_DEV_MOCK
await closeDb()
}
},
)
@@ -25,11 +25,13 @@ import {
createAffiliateDashDevMock,
createKuaishouIndustryVoucherDevMock,
createSemiAutoDevMock,
createSemiAutoIntegrationDevMock,
fetchDevMockStatus,
type DevMockCreateResult,
type DevMockKuaishouIndustryVoucherResult,
type DevMockOpen91QueryResult,
type DevMockSemiAutoResult,
type DevSemiAutoIntegrationResult,
} from '@/services/admin'
export default function AdminDevMockPage() {
@@ -38,7 +40,9 @@ 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 [lastSemiAuto, setLastSemiAuto] = useState<
DevMockSemiAutoResult | DevSemiAutoIntegrationResult | null
>(null)
const statusQuery = useQuery({
queryKey: ['admin-dev-mock-status'],
@@ -93,6 +97,17 @@ export default function AdminDevMockPage() {
},
})
const semiAutoIntegrationMutation = useMutation({
mutationFn: createSemiAutoIntegrationDevMock,
onSuccess: (response) => {
setLastSemiAuto(response.data)
message.success('半自动真实履约链路测试单已生成')
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '生成失败')
},
})
const defaultOrderNo = useMemo(() => lastCreate?.orderNo || '', [lastCreate?.orderNo])
if (statusQuery.isLoading) {
@@ -149,7 +164,27 @@ export default function AdminDevMockPage() {
</Col>
<Col xs={24} xl={12}>
<Card title="2. 半自动履约测试单(可选 Mock 范围)" bordered={false}>
<Card title="2. 半自动真实履约链路测试单" bordered={false}>
<Alert
type="success"
showIcon
style={{ marginBottom: 12 }}
message="正式订单入口"
description="订单走 upsertOrderFromSource,随后执行 SKU 映射、渠道判定、Carrier 补偿和半自动任务生成。"
/>
<SemiAutoMockForm
loading={semiAutoIntegrationMutation.isPending}
submitText="生成真实链路测试单"
defaultConnectorMode={status?.semiAuto?.defaultConnectorMode}
externalConnectorConfigured={status?.semiAuto?.externalConnectorConfigured}
mockConnectorAvailable={status?.semiAuto?.mockConnectorAvailable}
onSubmit={(values) => semiAutoIntegrationMutation.mutate(values)}
/>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card title="3. 半自动直连 Mock(页面联调)" bordered={false}>
<SemiAutoMockForm
loading={semiAutoMutation.isPending}
defaultConnectorMode={status?.semiAuto?.defaultConnectorMode}
@@ -161,7 +196,7 @@ export default function AdminDevMockPage() {
</Col>
<Col xs={24} xl={12}>
<Card title="3. 快手电子凭证测试数据" bordered={false}>
<Card title="4. 快手电子凭证测试数据" bordered={false}>
<KuaishouIndustryVoucherMockForm
loading={voucherMutation.isPending}
onSubmit={(values) => voucherMutation.mutate(values)}
@@ -170,7 +205,7 @@ export default function AdminDevMockPage() {
</Col>
<Col xs={24}>
<Card title="4. 模拟 91 查单(open-91/query)" bordered={false}>
<Card title="5. 模拟 91 查单(open-91/query)" bordered={false}>
<Form
layout="inline"
initialValues={{ orderNo: defaultOrderNo, execute: '1' }}
@@ -289,7 +324,7 @@ export default function AdminDevMockPage() {
{lastSemiAuto ? (
<Card
title="最近生成的半自动履约 Mock"
title="最近生成的半自动测试单"
extra={<Link to={lastSemiAuto.adminPath}>打开后台任务</Link>}
>
<Row gutter={[16, 12]}>
@@ -306,6 +341,7 @@ export default function AdminDevMockPage() {
<div>
{lastSemiAuto.semiAutoPlatform === 'weibo' ? '微博' : '虎牙'} ·{' '}
{lastSemiAuto.skuKey} · 任务 #{lastSemiAuto.taskId}
{'pipeline' in lastSemiAuto ? ' · 正式订单链路' : ' · 直连 Mock'}
</div>
</Col>
<Col span={24}>
@@ -516,12 +552,16 @@ function KuaishouIndustryVoucherMockForm({
function SemiAutoMockForm({
loading,
submitText,
integration = false,
defaultConnectorMode,
externalConnectorConfigured,
mockConnectorAvailable,
onSubmit,
}: {
loading: boolean
submitText?: string
integration?: boolean
defaultConnectorMode?: 'embedded' | 'external' | 'mock'
externalConnectorConfigured?: boolean
mockConnectorAvailable?: boolean
@@ -564,7 +604,7 @@ function SemiAutoMockForm({
<Form.Item
label="履约平台"
name="platform"
extra="微博 Mock 单登录成功即视为授权完成,不再有角色绑定步骤。"
extra="当前只负责客户登录和账号 CK 获取;客服在后台人工处理后续履约。"
>
<Select
options={[
@@ -582,23 +622,31 @@ function SemiAutoMockForm({
</Form.Item>
{selectedPlatform === 'huya' ? (
<Form.Item
label="Mock 范围"
label={integration ? 'Connector 模式' : 'Mock 范围'}
name="connectorMode"
extra="只 Mock 订单时,登录、角色绑定和浏览器票据注入可走内置真实 Connector,也可显式使用远程 Connector。"
extra={
integration
? '订单始终走正式订单与履约链路,这里只选择登录和 CK 获取使用的 Connector。'
: '只 Mock 订单时,登录和 CK 获取可走内置真实 Connector,也可显式使用远程 Connector。'
}
>
<Select
options={[
{
value: 'embedded',
label: '仅 Mock 订单(内置真实登录 / 绑定 / 注入)',
label: integration
? '内置真实 Connector'
: '仅 Mock 订单(内置真实登录 / CK 获取)',
},
{
value: 'external',
label: '仅 Mock 订单(远程真实 Connector)',
label: integration ? '远程真实 Connector' : '仅 Mock 订单(远程真实 Connector)',
},
{
value: 'mock',
label: '订单 + 登录绑定都 Mock(内置 Connector)',
label: integration
? '内置 Mock Connector(仅联调登录)'
: '订单 + 登录 CK 都 Mock(内置 Connector)',
disabled: mockConnectorAvailable === false,
},
]}
@@ -627,7 +675,15 @@ function SemiAutoMockForm({
<Form.Item label="订单号 orderNo(可空)" name="orderNo">
<Input
allowClear
placeholder={selectedPlatform === 'weibo' ? '留空自动 MOCKWB…' : '留空自动 MOCKSA…'}
placeholder={
integration
? selectedPlatform === 'weibo'
? '留空自动 INTWB…'
: '留空自动 INTSA…'
: selectedPlatform === 'weibo'
? '留空自动 MOCKWB…'
: '留空自动 MOCKSA…'
}
/>
</Form.Item>
<Space size={12} style={{ display: 'flex' }}>
@@ -665,16 +721,22 @@ function SemiAutoMockForm({
selectedPlatform === 'weibo'
? '微博使用内置真实 Connector:短信验证码会真实发送到客户手机号,扫码需在微博 App 确认;登录成功即视为授权完成。'
: selectedMode === 'mock'
? '登录和绑定会使用内置 Mock Connector,仅用于联调页面。'
? integration
? '订单和履约链路是正式的,只有登录与 CK 获取使用内置 Mock Connector。'
: '登录和 CK 获取会使用内置 Mock Connector,仅用于联调页面。'
: selectedMode === 'external'
? externalConnectorConfigured
? '远程真实 Connector 已配置;只有订单由 Mock 生成。'
? integration
? '远程真实 Connector 已配置,订单走正式履约链路。'
: '远程真实 Connector 已配置;只有订单由 Mock 生成。'
: '远程真实 Connector 未配置:请设置 SEMI_AUTO_CONNECTOR_BASE_URL 和 API_KEY。'
: '使用 order_site 内置真实 Connector;无需启动 live-hub-py,只有订单由 Mock 生成。'
: integration
? '使用 order_site 内置真实 Connector,订单走正式履约链路。'
: '使用 order_site 内置真实 Connector;无需启动 live-hub-py,只有订单由 Mock 生成。'
}
/>
<Button type="primary" htmlType="submit" loading={loading} block>
生成半自动履约 Mock
{submitText || '生成半自动履约 Mock'}
</Button>
</Form>
)
@@ -68,6 +68,11 @@ export type DevMockSemiAutoResult = {
tips: string[]
}
export type DevSemiAutoIntegrationResult = DevMockSemiAutoResult & {
pipeline: 'order_service'
mappingId: number
}
export function fetchDevMockStatus() {
return apiGet<DevMockStatus>('/api/v1/admin/dev-mock/status')
}
@@ -102,6 +107,21 @@ export function createSemiAutoDevMock(payload: {
return apiPost<DevMockSemiAutoResult>('/api/v1/admin/dev-mock/semi-auto', payload)
}
export function createSemiAutoIntegrationDevMock(payload: {
platform?: 'huya' | 'weibo'
orderNo?: string
skuName?: string
quantity?: number
pageTitle?: string
browserTargetUrl?: string
connectorMode?: 'embedded' | 'external' | 'mock'
}) {
return apiPost<DevSemiAutoIntegrationResult>(
'/api/v1/admin/dev-mock/semi-auto-integration',
payload,
)
}
export function buildOpen91QueryDevMock(payload: {
orderNo: string
execute?: boolean
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
status: authoritative
version: 2.15
version: 2.16
updated_at: 2026-11-14
---
@@ -286,6 +286,8 @@ manual fulfillment
- `fulfillments.channel` 仍为 `manual`;成本、销售与总账口径不变。
- 是否启用半自动履约由 `manual_fulfillment_configs.semi_auto_*` 显式配置,不能按商品名或平台前缀猜测。
- 半自动任务必须绑定 `order_item_id`、`fulfillments.id` 和人工履约配置,不得伪造接单平台工单。
- 半自动任务必须在 manual 渠道判定、人工配置保存/重匹配或 Carrier 补配成功后按订单项主动物化,
不能依赖客户先打开 `/c/<订单号>` 或客服先打开半自动任务列表。后台和客户页的幂等回填仅作为兜底。
- 当前阶段半自动平台接入的唯一职责是让客户完成平台登录并取得账号 CK。角色绑定、换绑、
角色校验和绑定失败处置属于后续业务流程,不进入当前客户页、主状态机或完成门禁。
- 登录状态和执行状态保存于 `semi_auto_tasks`,不得写入 `material_json` 作为正式状态。
@@ -330,6 +332,9 @@ manual fulfillment
成本快照、钱包扣减、履约状态和 `semi_auto_tasks` 完成状态必须全部成功或全部回滚;
事务提交后才执行报表刷新、Connector 会话撤销等旁路动作。
- 后台“任务 → 半自动履约”列表读取前必须执行幂等任务回填,不能依赖客户先打开 `/c/<订单号>`。
- 开发环境的半自动集成测试单必须通过正式 `upsertOrderFromSource` 入库,并继续经过 SKU 映射、
`sku_channel_routes`、渠道判定和 Carrier 补偿;不得直接写 `orders`、`order_items` 或
`fulfillments` 伪造成功。允许显式选择内置 Mock Connector,但不得用 Connector Mock 代替订单链路测试。
- 完成截图可选;操作人、操作时间、外部单号和成本确认必须留痕。
- 客户统一入口为 `/c/<平台订单号>`,它是入口而非安全授权凭据;高价值或全自动场景必须增加二次验证,不能仅凭订单号自动履约。