945 lines
29 KiB
TypeScript
945 lines
29 KiB
TypeScript
/**
|
||
* 开发环境专用 Mock 数据工厂。
|
||
* 生产环境禁止调用;由路由层 isDevMockEnabled 拦截。
|
||
*/
|
||
import crypto from 'node:crypto'
|
||
|
||
import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
|
||
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 { 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'
|
||
import {
|
||
defaultSemiAutoConnectorMode,
|
||
isExternalSemiAutoConnectorConfigured,
|
||
type SemiAutoConnectorMode,
|
||
} from '../semi-auto/huya-connector-client.js'
|
||
import { assertDevMockEnabled, isDevMockEnabled } from './dev-mock-enabled.js'
|
||
|
||
export { assertDevMockEnabled, isDevMockEnabled }
|
||
|
||
export type DevMockCreateResult = {
|
||
platform: 'affiliate_dash'
|
||
orderId: number
|
||
orderNo: string
|
||
taskId: number
|
||
taskNo: string
|
||
claimToken: string
|
||
claimUrl: string
|
||
frontendClaimUrl: string
|
||
expectedUid: string
|
||
step: string
|
||
productName: string
|
||
tips: string[]
|
||
open91QueryHint: string
|
||
}
|
||
|
||
export type DevMockKuaishouIndustryVoucherResult = {
|
||
createdCount: number
|
||
sellerId: string
|
||
vouchers: Array<{
|
||
voucherCode: string
|
||
oid: string
|
||
status: string
|
||
}>
|
||
}
|
||
|
||
export type DevMockSemiAutoResult = {
|
||
platform: 'semi_auto'
|
||
semiAutoPlatform: 'huya' | 'weibo'
|
||
orderId: number
|
||
orderNo: string
|
||
orderItemId: number
|
||
fulfillmentId: number
|
||
configId: number
|
||
taskId: number
|
||
skuKey: string
|
||
connectorMode: SemiAutoConnectorMode
|
||
connectorConfigured: boolean
|
||
customerPath: string
|
||
adminPath: string
|
||
tips: string[]
|
||
}
|
||
|
||
export function getDevMockStatus() {
|
||
const enabled = isDevMockEnabled()
|
||
return {
|
||
enabled,
|
||
nodeEnv: String(process.env.NODE_ENV || 'development'),
|
||
platforms: enabled
|
||
? [
|
||
{
|
||
key: 'affiliate_dash',
|
||
name: 'affiliate-dash',
|
||
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: {
|
||
defaultConnectorMode: defaultSemiAutoConnectorMode(),
|
||
externalConnectorConfigured: isExternalSemiAutoConnectorConfigured(),
|
||
mockConnectorAvailable: enabled,
|
||
platform: 'huya',
|
||
},
|
||
}
|
||
}
|
||
|
||
type AffiliateDashMockStep = 'uid' | 'bind' | 'submitted' | 'completed' | 'failed'
|
||
|
||
/**
|
||
* 生成 affiliate-dash 领取 mock:不调用真实 affiliate-dash 平台。
|
||
* 上下文带 mock 标记,sync/refresh/submit 全部短路,领取页可走完四步。
|
||
*/
|
||
export async function createAffiliateDashMockClaim(
|
||
input: {
|
||
step?: unknown
|
||
orderNo?: unknown
|
||
productName?: unknown
|
||
productSku?: unknown
|
||
uid?: unknown
|
||
frontendBaseUrl?: unknown
|
||
} = {},
|
||
): Promise<DevMockCreateResult> {
|
||
assertDevMockEnabled()
|
||
|
||
const step = normalizeAffiliateDashStep(input.step)
|
||
const orderNo = String(input.orderNo || `MOCKAD${Date.now()}`).trim()
|
||
const productName = String(input.productName || '幸运币90个').trim()
|
||
const productSku = String(input.productSku || 'lucky_coin_x90').trim()
|
||
const expectedUid = resolveMockUid(input.uid, step === 'uid' ? '' : '166909256')
|
||
const fallbackUid = expectedUid || '166909256'
|
||
const bindUrl =
|
||
step === 'uid'
|
||
? ''
|
||
: `https://skin.khhao.com/mock/bind?order=${encodeURIComponent(orderNo)}&uid=${encodeURIComponent(fallbackUid)}`
|
||
const bound = step === 'submitted' || step === 'completed' || step === 'failed'
|
||
const orderStatus =
|
||
step === 'completed'
|
||
? 'delivered'
|
||
: step === 'failed'
|
||
? 'ship_failed'
|
||
: step === 'submitted'
|
||
? 'delivering'
|
||
: 'paid'
|
||
const now = nowIso()
|
||
|
||
await assertOrderNoAvailable(orderNo)
|
||
|
||
const profile = await ensureProfile('affiliate_dash', 'affiliate-dash 履约', true)
|
||
const order = await createBaseOrder({
|
||
orderNo,
|
||
productNo: productSku,
|
||
productName,
|
||
now,
|
||
platformTag: 'affiliate-dash',
|
||
})
|
||
const orderItem = await createBaseOrderItem({
|
||
orderId: order.id,
|
||
orderNo,
|
||
productNo: productSku,
|
||
productName,
|
||
now,
|
||
productCode: productSku,
|
||
})
|
||
|
||
const task = await createTask({
|
||
orderId: order.id,
|
||
orderItemId: orderItem.id,
|
||
unitIndex: 1,
|
||
provider: OPEN_91_PROVIDER,
|
||
platform: OPEN_91_PLATFORM,
|
||
shopId: OPEN_91_PROVIDER,
|
||
shopName: '91卡券',
|
||
platformOrderId: orderNo,
|
||
taskNo: randomId('DT'),
|
||
profileId: profile.id,
|
||
executorKey: 'affiliate_dash',
|
||
taskStatus:
|
||
step === 'completed'
|
||
? TASK_STATUS.REDEEMED
|
||
: step === 'failed'
|
||
? TASK_STATUS.RETRY_PENDING
|
||
: step === 'submitted'
|
||
? TASK_STATUS.REDEEMING
|
||
: TASK_STATUS.LINK_GENERATED,
|
||
deliveryStatus:
|
||
step === 'completed' ? 'delivered' : step === 'submitted' ? 'delivering' : 'pending',
|
||
resultCode:
|
||
step === 'completed'
|
||
? 'affiliate_dash_delivered'
|
||
: step === 'failed'
|
||
? 'affiliate_dash_ship_failed'
|
||
: '',
|
||
resultMessage:
|
||
step === 'completed'
|
||
? 'affiliate-dash 履约成功'
|
||
: step === 'failed'
|
||
? '开发 mock 模拟发货失败'
|
||
: '等待用户领取',
|
||
claimToken: '',
|
||
claimExpiresAt: null,
|
||
automationMode: 'manual',
|
||
requiresClaim: true,
|
||
userActionStatus: step === 'completed' ? 'not_required' : 'pending_claim',
|
||
attemptCount: 0,
|
||
lastError: step === 'failed' ? '开发 mock 模拟发货失败' : '',
|
||
contextJson: JSON.stringify({
|
||
profileKey: 'affiliate_dash',
|
||
profileName: 'affiliate-dash 履约',
|
||
skuCode: productName,
|
||
skuName: productName,
|
||
claimIdentity: expectedUid
|
||
? {
|
||
expectedUid,
|
||
submittedAt: now,
|
||
source: 'dev_mock',
|
||
}
|
||
: {
|
||
expectedUid: '',
|
||
submittedAt: null,
|
||
source: '',
|
||
},
|
||
affiliateDash: {
|
||
flowType: 'affiliate_dash',
|
||
sku: productSku,
|
||
productName,
|
||
orderNo: `AD-${orderNo}`,
|
||
clientOrderNo: '',
|
||
orderStatus,
|
||
canShip: true,
|
||
cannotShipReason: '',
|
||
providerOrderNo: '',
|
||
failureReason: step === 'failed' ? '开发 mock 模拟发货失败' : '',
|
||
amount: 20,
|
||
currency: 'POINT',
|
||
bindUuid: bound ? `mock-bind-${orderNo}` : '',
|
||
bindUrl,
|
||
qrUrl: '',
|
||
gameAccount: bound ? fallbackUid : '',
|
||
expectedGameAccount: expectedUid || '',
|
||
bindMismatch: false,
|
||
bound,
|
||
boundAccount: bound ? fallbackUid : '',
|
||
gameChannel: 'Android',
|
||
submitStatus:
|
||
step === 'completed'
|
||
? 'delivered'
|
||
: step === 'failed'
|
||
? 'ship_failed'
|
||
: step === 'submitted'
|
||
? 'submitting'
|
||
: '',
|
||
consumeStatus: step === 'completed' ? 'not_required' : 'pending',
|
||
lastSyncedAt: now,
|
||
mock: {
|
||
enabled: true,
|
||
orderNo,
|
||
createdAt: now,
|
||
},
|
||
},
|
||
}),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
})
|
||
|
||
if (!task) {
|
||
throw createHttpError('履约任务创建失败', {
|
||
statusCode: 500,
|
||
errorCode: 'dev_mock_task_failed',
|
||
})
|
||
}
|
||
|
||
const claimToken = await createTaskClaimToken(task.id, orderNo)
|
||
await updateTask(task.id, {
|
||
claim_token: claimToken.token,
|
||
claim_expires_at: claimToken.expired_at,
|
||
claimed_at: expectedUid ? now : null,
|
||
redeemed_at: step === 'completed' ? now : null,
|
||
updated_at: now,
|
||
})
|
||
|
||
return buildCreateResult({
|
||
platform: 'affiliate_dash',
|
||
orderId: order.id,
|
||
orderNo,
|
||
taskId: task.id,
|
||
taskNo: task.task_no,
|
||
claimToken: claimToken.token,
|
||
claimUrl: buildOrderClaimUrl(orderNo),
|
||
frontendBaseUrl: input.frontendBaseUrl,
|
||
expectedUid: fallbackUid,
|
||
step,
|
||
productName,
|
||
tips: buildAffiliateDashTips(step, fallbackUid),
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 生成半自动履约测试单:人工 SKU 配置、订单、履约和半自动任务都走真实领域服务。
|
||
* 平台登录、绑定和浏览器票据按 connectorMode 选择真实 Connector 或内置 Mock。
|
||
*/
|
||
export async function createSemiAutoMock(
|
||
input: {
|
||
platform?: unknown
|
||
orderNo?: unknown
|
||
skuName?: unknown
|
||
quantity?: unknown
|
||
pageTitle?: unknown
|
||
pageDescription?: unknown
|
||
browserTargetUrl?: unknown
|
||
connectorMode?: unknown
|
||
} = {},
|
||
): Promise<DevMockSemiAutoResult> {
|
||
assertDevMockEnabled()
|
||
|
||
const platform = normalizeMockSemiAutoPlatform(input.platform)
|
||
const orderNo = String(
|
||
input.orderNo || `${platform === 'weibo' ? 'MOCKWB' : 'MOCKSA'}${Date.now()}`,
|
||
).trim()
|
||
const skuKey = normalizeMockSemiAutoSku(input.skuName, platform)
|
||
const quantity = normalizeMockSemiAutoQuantity(input.quantity)
|
||
// 微博目前只支持内置 Connector,忽略其他模式,避免生成必然失败的测试单。
|
||
const connectorMode =
|
||
platform === 'weibo' ? 'embedded' : normalizeMockSemiAutoConnectorMode(input.connectorMode)
|
||
const pageTitle = String(
|
||
input.pageTitle || (platform === 'weibo' ? '微博发帖授权测试' : '虎牙账号授权测试'),
|
||
)
|
||
.trim()
|
||
.slice(0, 60)
|
||
const pageDescription = String(
|
||
input.pageDescription ||
|
||
(platform === 'weibo'
|
||
? '开发 Mock:完成微博登录即视为授权,客服在后台完成发帖履约。'
|
||
: '开发 Mock:完成登录和角色绑定后,客服在后台完成履约。'),
|
||
)
|
||
.trim()
|
||
.slice(0, 500)
|
||
const browserTargetUrl = String(
|
||
input.browserTargetUrl ||
|
||
(platform === 'weibo' ? 'https://weibo.com/' : '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: platform,
|
||
semiAutoConfig: {
|
||
pageTitle,
|
||
pageDescription,
|
||
loginInstructions:
|
||
platform === 'weibo'
|
||
? '真实登录:使用微博绑定手机号接收验证码登录,或使用微博 App 扫码登录。登录仅用于本次订单履约,不会在页面展示账号 Cookie。'
|
||
: connectorMode === 'mock'
|
||
? 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。'
|
||
: '真实登录:点击获取二维码后,请使用虎牙 App 扫码并在手机上确认登录。',
|
||
...(platform === 'weibo' ? { loginMethods: ['sms', 'qr'] } : {}),
|
||
browserTargets: [
|
||
{
|
||
key: platform,
|
||
label: platform === 'weibo' ? '微博' : '虎牙',
|
||
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, mock: true },
|
||
}),
|
||
itemSnapshotJson: JSON.stringify({
|
||
source: 'dev_mock',
|
||
skuKey,
|
||
platform,
|
||
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',
|
||
})
|
||
}
|
||
|
||
await query('UPDATE semi_auto_tasks SET connector_mode = $2, updated_at = NOW() WHERE id = $1', [
|
||
taskId,
|
||
connectorMode,
|
||
])
|
||
|
||
const connectorConfigured =
|
||
connectorMode === 'external' ? isExternalSemiAutoConnectorConfigured() : true
|
||
return {
|
||
platform: 'semi_auto',
|
||
semiAutoPlatform: platform,
|
||
orderId: Number(order.id),
|
||
orderNo,
|
||
orderItemId,
|
||
fulfillmentId,
|
||
configId,
|
||
taskId,
|
||
skuKey,
|
||
connectorMode,
|
||
connectorConfigured,
|
||
customerPath: `/c/${encodeURIComponent(orderNo)}`,
|
||
adminPath: `/admin/semi-auto-tasks?keyword=${encodeURIComponent(orderNo)}`,
|
||
tips:
|
||
platform === 'weibo'
|
||
? [
|
||
'完成履约仍走真实确认流程,会写入成本快照和财务确认。',
|
||
'微博使用内置 Connector:短信验证码会真实发送到客户手机号;扫码登录需在微博 App 中确认。',
|
||
'后台领取任务后生成 weibo-ticket-* 浏览器票据,扩展消费票据后写入微博 Cookie。',
|
||
'微博登录成功即视为授权完成,任务会直接进入等待客服处理。',
|
||
]
|
||
: [
|
||
'完成履约仍走真实确认流程,会写入成本快照和财务确认。',
|
||
connectorMode === 'mock'
|
||
? '当前使用内置 Mock Connector:打开客户页点击登录,轮询两次后自动登录;绑定同样自动识别角色。'
|
||
: connectorMode === 'external'
|
||
? connectorConfigured
|
||
? '当前使用远程真实 Connector:订单是 Mock 的,登录、绑定和浏览器票据都走外部服务。'
|
||
: '当前选择远程真实 Connector,但 SEMI_AUTO_CONNECTOR_BASE_URL / API_KEY 未配置,登录会失败。'
|
||
: '当前使用 order_site 内置真实 Connector:无需启动 live-hub-py,扫码登录、绑定和票据都在本进程完成。',
|
||
connectorMode === 'mock'
|
||
? '后台领取任务后生成浏览器票据;扩展消费票据后写入 Mock Cookie。'
|
||
: connectorMode === 'external'
|
||
? '请确保远程 Connector 已启动,并保证两边 SEMI_AUTO_CONNECTOR_API_KEY 一致。'
|
||
: '登录凭据只保存在当前进程内存中,重启后端会要求客户重新扫码。',
|
||
],
|
||
}
|
||
}
|
||
|
||
function normalizeMockSemiAutoPlatform(value: unknown): 'huya' | 'weibo' {
|
||
return String(value || '')
|
||
.trim()
|
||
.toLowerCase() === 'weibo'
|
||
? 'weibo'
|
||
: 'huya'
|
||
}
|
||
|
||
function normalizeMockSemiAutoConnectorMode(value: unknown): SemiAutoConnectorMode {
|
||
const mode = String(value || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
if (mode === 'external' || mode === 'mock') return mode
|
||
return 'embedded'
|
||
}
|
||
|
||
function normalizeMockSemiAutoSku(value: unknown, platform: 'huya' | 'weibo' = 'huya') {
|
||
const prefix = platform === 'weibo' ? '微博半自动' : '虎牙半自动'
|
||
const raw = String(value || '').trim()
|
||
const withCjk = raw
|
||
? containsCjk(raw)
|
||
? raw
|
||
: `${prefix}-${raw}`
|
||
: `${prefix}测试-${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
|
||
} = {},
|
||
): Promise<DevMockKuaishouIndustryVoucherResult> {
|
||
assertDevMockEnabled()
|
||
|
||
const now = nowIso()
|
||
const timestamp = Date.now()
|
||
const sellerId = resolveMockIndustrySellerId(input.sellerId)
|
||
const specimens = [
|
||
{ status: 'UNUSED', callbackStatus: 'success', productName: '周卡 VIP 尊享礼包' },
|
||
{
|
||
status: 'UNUSED',
|
||
callbackStatus: 'failed',
|
||
callbackError: '开发 Mock 模拟发码回调失败',
|
||
productName: '幸运盲盒 限定款',
|
||
},
|
||
{
|
||
status: 'CONSUMED',
|
||
callbackStatus: 'success',
|
||
consumeSerialNum: `CONSUME-MOCK-${timestamp}-3`,
|
||
consumedAt: now,
|
||
productName: '星轨通行证月卡',
|
||
},
|
||
{ status: 'UNUSED', callbackStatus: 'success', productName: '精英荣耀专属礼盒' },
|
||
{
|
||
status: 'DESTROYED',
|
||
callbackStatus: 'success',
|
||
destroyedAt: now,
|
||
productName: '退款销毁测试券',
|
||
},
|
||
{
|
||
status: 'CONSUMED',
|
||
callbackStatus: 'success',
|
||
consumeSerialNum: `CONSUME-MOCK-${timestamp}-6`,
|
||
consumedAt: now,
|
||
productName: '浪漫天命皮肤礼包',
|
||
},
|
||
]
|
||
|
||
const vouchers: DevMockKuaishouIndustryVoucherResult['vouchers'] = []
|
||
for (const [index, specimen] of specimens.entries()) {
|
||
const oid = `MOCK-KS-${timestamp}-${index + 1}`
|
||
const voucher = await upsertKuaishouIndustryVoucher({
|
||
oid,
|
||
unitIndex: 1,
|
||
sellerId,
|
||
token: `mock-token-${timestamp}-${index + 1}`,
|
||
eticketType: 'GAME_OPEN_TICKET_CONSUME',
|
||
status: specimen.status,
|
||
validStartTime: timestamp - 60 * 60 * 1000,
|
||
validEndTime: timestamp + 7 * 24 * 60 * 60 * 1000,
|
||
consumeSerialNum: specimen.consumeSerialNum || '',
|
||
consumedAt: specimen.consumedAt || null,
|
||
destroyedAt: specimen.destroyedAt || null,
|
||
sendCallbackStatus: specimen.callbackStatus,
|
||
sendCallbackAttemptCount: specimen.callbackStatus === 'failed' ? 1 : 0,
|
||
sendCallbackLastError: specimen.callbackError || '',
|
||
sendCallbackResponseJson: {
|
||
mock: true,
|
||
success: specimen.callbackStatus === 'success',
|
||
},
|
||
sendCallbackSentAt: now,
|
||
rawPayloadJson: {
|
||
source: 'dev_mock',
|
||
mock: true,
|
||
productName: specimen.productName,
|
||
},
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
})
|
||
|
||
if (!voucher) {
|
||
throw createHttpError('电子凭证 Mock 创建失败', {
|
||
statusCode: 500,
|
||
errorCode: 'dev_mock_kuaishou_industry_voucher_failed',
|
||
})
|
||
}
|
||
|
||
vouchers.push({
|
||
voucherCode: voucher.voucher_code,
|
||
oid: voucher.oid,
|
||
status: voucher.status,
|
||
})
|
||
}
|
||
|
||
return {
|
||
createdCount: vouchers.length,
|
||
sellerId,
|
||
vouchers,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成 91 查询请求体(带签名),可选对本机发起查询。
|
||
*/
|
||
export async function buildOpen91QueryMock(
|
||
input: {
|
||
orderNo?: unknown
|
||
execute?: unknown
|
||
baseUrl?: unknown
|
||
} = {},
|
||
) {
|
||
assertDevMockEnabled()
|
||
|
||
const orderNo = String(input.orderNo || '').trim()
|
||
if (!orderNo) {
|
||
throw createHttpError('请填写 orderNo', {
|
||
statusCode: 400,
|
||
errorCode: 'dev_mock_order_no_required',
|
||
})
|
||
}
|
||
|
||
const config = assertOpen91Config()
|
||
const timestamp = Math.floor(Date.now() / 1000)
|
||
const payload = {
|
||
orderNo,
|
||
timestamp,
|
||
version: config.version || '1.0',
|
||
}
|
||
const sign = signOpen91Payload(payload, config.secret)
|
||
const body = { ...payload, sign }
|
||
const port = String(process.env.PORT || process.env.BACKEND_PORT || '3000').trim() || '3000'
|
||
const defaultBase = `http://127.0.0.1:${port}`
|
||
const baseUrl = String(input.baseUrl || defaultBase)
|
||
.replace(/\/#\/claim\/?$/, '')
|
||
.replace(/\/$/, '')
|
||
const endpoint = `${baseUrl || defaultBase}/api/v1/open/91/orders/query`
|
||
|
||
let response: unknown = null
|
||
if (input.execute === true || input.execute === 'true' || input.execute === 1) {
|
||
try {
|
||
const res = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
const text = await res.text()
|
||
try {
|
||
response = { status: res.status, body: JSON.parse(text) }
|
||
} catch {
|
||
response = { status: res.status, body: text }
|
||
}
|
||
} catch (error) {
|
||
response = {
|
||
status: 0,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
tip: '本机请求失败时,可复制 requestBody 用 curl 手动打到 backend',
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
endpoint,
|
||
requestBody: body,
|
||
curl: `curl -sS -X POST '${endpoint}' -H 'content-type: application/json' -d '${JSON.stringify(body)}'`,
|
||
response,
|
||
}
|
||
}
|
||
|
||
// --- helpers ---
|
||
|
||
async function assertOrderNoAvailable(orderNo: string) {
|
||
const existing = await findOrderByPlatformOrderId({
|
||
provider: OPEN_91_PROVIDER,
|
||
platform: OPEN_91_PLATFORM,
|
||
shopId: OPEN_91_PROVIDER,
|
||
platformOrderId: orderNo,
|
||
})
|
||
if (existing) {
|
||
throw createHttpError(`订单号已存在:${orderNo},请换一个或留空自动生成`, {
|
||
statusCode: 409,
|
||
errorCode: 'dev_mock_order_exists',
|
||
})
|
||
}
|
||
}
|
||
|
||
async function ensureProfile(profileKey: string, name: string, requiresClaim: boolean) {
|
||
const existing = await getFulfillmentProfileByKey(profileKey)
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
|
||
const now = nowIso()
|
||
const profile = await upsertFulfillmentProfile({
|
||
profileKey,
|
||
name,
|
||
executorKey: profileKey,
|
||
requiresClaim,
|
||
autoDispatch: false,
|
||
inventoryStrategy: 'external_platform',
|
||
configJson: '{}',
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
})
|
||
|
||
if (!profile) {
|
||
throw createHttpError('履约配置创建失败', {
|
||
statusCode: 500,
|
||
errorCode: 'dev_mock_profile_failed',
|
||
})
|
||
}
|
||
return profile
|
||
}
|
||
|
||
async function createBaseOrder(input: {
|
||
orderNo: string
|
||
productNo: string
|
||
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,
|
||
shopId: OPEN_91_PROVIDER,
|
||
shopName: '91卡券',
|
||
platformOrderId: input.orderNo,
|
||
orderStatus: 'paid',
|
||
payStatus: 'paid',
|
||
buyerId: 'mock-buyer',
|
||
buyerName: 'mock 领取客户',
|
||
receiverContact: '',
|
||
totalAmount,
|
||
currency: 'CNY',
|
||
rawPayloadJson: JSON.stringify({
|
||
source: OPEN_91_PROVIDER,
|
||
mock: true,
|
||
platformTag: input.platformTag,
|
||
receivedAt: input.now,
|
||
body: {
|
||
orderNo: input.orderNo,
|
||
productNo: input.productNo,
|
||
productName: input.productName,
|
||
buyNum: 1,
|
||
maxAmount: (totalAmount / 100).toFixed(2),
|
||
},
|
||
}),
|
||
paidAt: input.now,
|
||
createdAt: input.now,
|
||
updatedAt: input.now,
|
||
})
|
||
|
||
if (!order) {
|
||
throw createHttpError('订单创建失败', { statusCode: 500, errorCode: 'dev_mock_order_failed' })
|
||
}
|
||
return order
|
||
}
|
||
|
||
async function createBaseOrderItem(input: {
|
||
orderId: number
|
||
orderNo: string
|
||
productNo: string
|
||
productName: string
|
||
now: string
|
||
productCode?: string
|
||
}) {
|
||
const snapshot = {
|
||
source: OPEN_91_PROVIDER,
|
||
orderNo: input.orderNo,
|
||
productNo: input.productNo,
|
||
productName: input.productName,
|
||
affiliateDash: {
|
||
sku: input.productCode || input.productNo,
|
||
productName: input.productName,
|
||
matchMode: 'mock',
|
||
},
|
||
}
|
||
|
||
const [orderItem] = await replaceOrderItems(input.orderId, [
|
||
{
|
||
skuCode: input.productName,
|
||
skuName: input.productName,
|
||
quantity: 1,
|
||
specJson: JSON.stringify({
|
||
source: OPEN_91_PROVIDER,
|
||
orderNo: input.orderNo,
|
||
productNo: input.productNo,
|
||
productName: input.productName,
|
||
}),
|
||
itemSnapshotJson: JSON.stringify(snapshot),
|
||
createdAt: input.now,
|
||
updatedAt: input.now,
|
||
},
|
||
])
|
||
|
||
if (!orderItem) {
|
||
throw createHttpError('订单商品创建失败', {
|
||
statusCode: 500,
|
||
errorCode: 'dev_mock_item_failed',
|
||
})
|
||
}
|
||
return orderItem
|
||
}
|
||
|
||
function normalizeAffiliateDashStep(value: unknown): AffiliateDashMockStep {
|
||
const step = String(value || 'uid').trim()
|
||
if (['uid', 'bind', 'submitted', 'completed', 'failed'].includes(step)) {
|
||
return step as AffiliateDashMockStep
|
||
}
|
||
return 'uid'
|
||
}
|
||
|
||
function resolveMockUid(value: unknown, fallback: string) {
|
||
const raw = String(value ?? '').trim()
|
||
if (!raw) {
|
||
return normalizeClaimUid(fallback)
|
||
}
|
||
return assertValidClaimUid(raw)
|
||
}
|
||
|
||
function resolveMockIndustrySellerId(value: unknown): string {
|
||
const sellerId = String(value || '').trim()
|
||
if (sellerId) {
|
||
return sellerId
|
||
}
|
||
|
||
const shop = listKuaishouIndustryShopConfigs(getKuaishouIndustrySourceConfig()).find(
|
||
(item) => item.enabled !== false && (item.sellerId || item.shopId),
|
||
)
|
||
return String(shop?.sellerId || shop?.shopId || '141242642').trim()
|
||
}
|
||
|
||
function buildCreateResult(input: {
|
||
platform: 'affiliate_dash'
|
||
orderId: number
|
||
orderNo: string
|
||
taskId: number
|
||
taskNo: string
|
||
claimToken: string
|
||
claimUrl: string
|
||
frontendBaseUrl?: unknown
|
||
expectedUid: string
|
||
step: string
|
||
productName: string
|
||
tips: string[]
|
||
}): DevMockCreateResult {
|
||
return {
|
||
platform: input.platform,
|
||
orderId: input.orderId,
|
||
orderNo: input.orderNo,
|
||
taskId: input.taskId,
|
||
taskNo: input.taskNo,
|
||
claimToken: input.claimToken,
|
||
claimUrl: input.claimUrl,
|
||
frontendClaimUrl: buildOrderClaimUrl(input.orderNo, input.frontendBaseUrl || undefined),
|
||
expectedUid: input.expectedUid,
|
||
step: input.step,
|
||
productName: input.productName,
|
||
tips: input.tips,
|
||
open91QueryHint: `可在 Mock 页「91 查单」使用 orderNo=${input.orderNo},或 CLI:npm run mock:open91 -- --mode=query --orderNo=${input.orderNo}`,
|
||
}
|
||
}
|
||
|
||
function buildAffiliateDashTips(step: AffiliateDashMockStep, uid: string) {
|
||
if (step === 'uid') {
|
||
return [`打开领取链接,Step1 填 UID:${uid}(或自定义)`, '提交后进入绑定步,mock 会给出二维码']
|
||
}
|
||
if (step === 'bind') {
|
||
return [
|
||
`已预填 UID=${uid},绑定二维码为 mock 生成(扫描无效)`,
|
||
'正常流程:扫码完成真实绑定后自动进入下一步',
|
||
]
|
||
}
|
||
if (step === 'submitted') {
|
||
return ['已模拟绑定成功,可点「提交发货」(mock 直接模拟发货成功)']
|
||
}
|
||
if (step === 'failed') {
|
||
return ['已模拟发货失败态,用于看领取页结果展示']
|
||
}
|
||
return ['已模拟 affiliate-dash 发货完成态']
|
||
}
|
||
|
||
function signOpen91Payload(payload: Record<string, unknown>, secret: string) {
|
||
const queryString = Object.entries(payload)
|
||
.filter(([key]) => key !== 'sign')
|
||
.sort(([left], [right]) => left.localeCompare(right))
|
||
.map(([key, value]) => `${key}=${value == null ? '' : String(value)}`)
|
||
.join('&')
|
||
return crypto
|
||
.createHash('md5')
|
||
.update(`${secret}${queryString}${secret}`, 'utf8')
|
||
.digest('hex')
|
||
.toUpperCase()
|
||
}
|