From 81a7721ee111acea1a4c010fae9fc15b87316526 Mon Sep 17 00:00:00 2001 From: yml Date: Sat, 14 Nov 2026 09:23:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=8D=8A=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E5=B1=A5=E7=BA=A6=E9=93=BE=E8=B7=AF=E5=8F=8A?= =?UTF-8?q?=E8=99=8E=E7=89=99=E8=B4=A6=E5=8F=B7=E8=B5=84=E6=96=99=E4=BF=9D?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/routes/admin/dev-mock.ts | 22 ++ .../manual-fulfillment-config-service.ts | 3 + .../src/services/dev-mock/dev-mock-service.ts | 273 ++++++++++++++++++ .../fulfillment/channel-assign-service.ts | 13 + .../scheduler/finance-pipeline-jobs.ts | 4 + .../huya-native/huya-native-connector.ts | 29 +- .../semi-auto/huya-native/huya-native.test.ts | 69 ++++- .../semi-auto/huya-native/huya-rpc.ts | 24 ++ .../semi-auto/semi-auto-task-service.ts | 38 ++- .../semi-auto-task.integration.test.ts | 97 ++++++- .../src/pages/admin/AdminDevMockPage.tsx | 94 +++++- apps/frontend/src/services/admin/dev-mock.ts | 20 ++ docs/统一订单履约与财务模型-权威版.md | 7 +- 13 files changed, 662 insertions(+), 31 deletions(-) diff --git a/apps/backend/src/routes/admin/dev-mock.ts b/apps/backend/src/routes/admin/dev-mock.ts index ea4e9352..444e9c83 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, + 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 || {}), { diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index a60cfb50..db6e78ea 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -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 = 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 = return { updatedCount: attached.length, + orderItemIds: updatedIds, skuKey, dateFrom, dateTo, 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 506fd818..d6f1ebcd 100644 --- a/apps/backend/src/services/dev-mock/dev-mock-service.ts +++ b/apps/backend/src/services/dev-mock/dev-mock-service.ts @@ -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 { + 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() diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 0555a711..e913a296 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -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, diff --git a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts index f171afc3..c49eaa03 100644 --- a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts +++ b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts @@ -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 未配置」队列。 diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts index 5808ef60..8bc31c4f 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts @@ -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 = '' diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts index e9d791ae..8d361eba 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts @@ -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 }> = [] 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', diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-rpc.ts b/apps/backend/src/services/semi-auto/huya-native/huya-rpc.ts index 440c55c9..dae1c858 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-rpc.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-rpc.ts @@ -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(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 { + 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, diff --git a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts index 546f2622..5efe0b64 100644 --- a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts +++ b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts @@ -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( ` 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 } +} + /** * 后台列表读取前的幂等回填。 * 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 7c21944b..b3fd1cc8 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 @@ -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() + } + }, +) diff --git a/apps/frontend/src/pages/admin/AdminDevMockPage.tsx b/apps/frontend/src/pages/admin/AdminDevMockPage.tsx index d0b6bdf5..0be2209d 100644 --- a/apps/frontend/src/pages/admin/AdminDevMockPage.tsx +++ b/apps/frontend/src/pages/admin/AdminDevMockPage.tsx @@ -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(null) const [lastQuery, setLastQuery] = useState(null) - const [lastSemiAuto, setLastSemiAuto] = useState(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() { - + + + semiAutoIntegrationMutation.mutate(values)} + /> + + + + + - + voucherMutation.mutate(values)} @@ -170,7 +205,7 @@ export default function AdminDevMockPage() { - +
打开后台任务} > @@ -306,6 +341,7 @@ export default function AdminDevMockPage() {
{lastSemiAuto.semiAutoPlatform === 'weibo' ? '微博' : '虎牙'} ·{' '} {lastSemiAuto.skuKey} · 任务 #{lastSemiAuto.taskId} + {'pipeline' in lastSemiAuto ? ' · 正式订单链路' : ' · 直连 Mock'}
@@ -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({ @@ -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 生成。' } /> ) diff --git a/apps/frontend/src/services/admin/dev-mock.ts b/apps/frontend/src/services/admin/dev-mock.ts index 7f54e343..8f44645d 100644 --- a/apps/frontend/src/services/admin/dev-mock.ts +++ b/apps/frontend/src/services/admin/dev-mock.ts @@ -68,6 +68,11 @@ export type DevMockSemiAutoResult = { tips: string[] } +export type DevSemiAutoIntegrationResult = DevMockSemiAutoResult & { + pipeline: 'order_service' + mappingId: number +} + export function fetchDevMockStatus() { return apiGet('/api/v1/admin/dev-mock/status') } @@ -102,6 +107,21 @@ export function createSemiAutoDevMock(payload: { return apiPost('/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( + '/api/v1/admin/dev-mock/semi-auto-integration', + payload, + ) +} + export function buildOpen91QueryDevMock(payload: { orderNo: string execute?: boolean diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index a6dd2c44..00abc269 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -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/<平台订单号>`,它是入口而非安全授权凭据;高价值或全自动场景必须增加二次验证,不能仅凭订单号自动履约。