Files
order_site/apps/backend/src/services/dev-mock/dev-mock-service.ts
T

1245 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 开发环境专用 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 {
getFulfillmentProfileByKey,
upsertFulfillmentProfile,
} from '../../repositories/fulfillment-profile-repo.js'
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.js'
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, assertOpen91Config } from '../open-91/config.js'
import { parseOpen91ProductNo } from '../platforms/ninetyone/order-service.js'
import {
getKuaishouIndustrySourceConfig,
listKuaishouIndustryShopConfigs,
} from '../platforms/kuaishou-industry/source-config-service.js'
import { isProductionLike } from '../../config/runtime-validation.js'
import { createHttpError } from '../../utils/http.js'
import { addHours, nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import { TASK_STATUS } from '../../domain/task-status.js'
export type LewanMockStep = 'uid' | 'binding' | 'confirm' | 'result'
export type FeifeiMockStep = 'uid' | 'ready' | 'completed' | 'failed'
export type DevMockCreateResult = {
platform: 'lewan' | 'feifei' | '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
}>
}
type DeliveryItem = {
cloudSkuId: number
cloudSkuName: string
quantity: number
}
export function isDevMockEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
if (String(env.ENABLE_DEV_MOCK || '').trim() === '1') {
return true
}
if (
String(env.ENABLE_DEV_MOCK || '')
.trim()
.toLowerCase() === 'true'
) {
return true
}
return !isProductionLike(env)
}
export function assertDevMockEnabled() {
if (!isDevMockEnabled()) {
throw createHttpError('当前环境未开启开发 Mock(仅非 production 或 ENABLE_DEV_MOCK=1', {
statusCode: 403,
errorCode: 'dev_mock_disabled',
})
}
}
export function getDevMockStatus() {
const enabled = isDevMockEnabled()
return {
enabled,
nodeEnv: String(process.env.NODE_ENV || 'development'),
platforms: enabled
? [
{
key: 'lewan',
name: 'kuaishou-lewan',
description: '本站 claim + 强制 UID 匹配 + mock 兑换(不调 CloudTentacles',
steps: ['uid', 'binding', 'confirm', 'result'],
},
{
key: 'feifei',
name: 'kuaishou-feifei',
description: '本站 claim + 拼 uid 的 H5 链接(不调真实 feifei 下单)',
steps: ['uid', 'ready', 'completed', 'failed'],
},
{
key: 'affiliate_dash',
name: 'affiliate-dash',
description: '本站 claim + mock 绑定二维码/提交发货(不调真实 affiliate-dash 平台)',
steps: ['uid', 'bind', 'submitted', 'completed', 'failed'],
},
]
: [],
defaultUid: '10001',
}
}
export async function createLewanMockClaim(
input: {
step?: unknown
orderNo?: unknown
productNo?: unknown
uid?: unknown
items?: unknown
frontendBaseUrl?: unknown
} = {},
): Promise<DevMockCreateResult> {
assertDevMockEnabled()
const step = normalizeLewanStep(input.step)
const orderNo = String(input.orderNo || `MOCK91${Date.now()}`).trim()
const productNo = String(input.productNo || '套餐_1----3676797936').trim()
const productInfo = parseOpen91ProductNo(productNo)
const productName = productInfo.productName || productInfo.rawProductNo || '套餐_1'
const consumeShopId = productInfo.shopId || '3676797936'
const expectedUid = resolveMockUid(input.uid, step === 'uid' ? '' : '10001')
const deliveryItems = parseDeliveryItems(input.items)
const now = nowIso()
await assertOrderNoAvailable(orderNo)
const profile = await ensureProfile('kuaishou_ct_assisted', 'kuaishou-lewan 履约', false)
const order = await createBaseOrder({
orderNo,
productNo,
productName,
now,
platformTag: 'lewan',
})
const orderItem = await createBaseOrderItem({
orderId: order.id,
orderNo,
productNo,
productName,
consumeShopId,
deliveryItems,
now,
executor: 'lewan',
})
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: 'kuaishou_ct_assisted',
taskStatus: resolveLewanTaskStatus(step),
deliveryStatus: step === 'result' ? 'success' : 'pending',
resultCode: step === 'result' ? 'mock_success' : '',
resultMessage: step === 'result' ? '开发 mock 已模拟兑换成功' : '',
claimToken: '',
claimExpiresAt: null,
automationMode: 'manual',
requiresClaim: true,
userActionStatus: step === 'result' ? 'not_required' : 'pending_claim',
attemptCount: 0,
lastError: '',
contextJson: JSON.stringify(
buildLewanMockContext({
step,
orderNo,
productName,
productNo,
consumeShopId,
deliveryItems,
expectedUid,
timestamp: now,
}),
),
createdAt: now,
updatedAt: now,
})
if (!task) {
throw createHttpError('履约任务创建失败', {
statusCode: 500,
errorCode: 'dev_mock_task_failed',
})
}
const claimToken = await createTaskClaimToken(task.id)
await updateTask(task.id, {
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
claimed_at: step === 'uid' ? null : now,
role_confirmed_at: step === 'confirm' || step === 'result' ? now : null,
redeemed_at: step === 'result' ? now : null,
role_id: ['binding', 'confirm', 'result'].includes(step) ? expectedUid || '10001' : '',
role_name: ['binding', 'confirm', 'result'].includes(step) ? '测试角色' : '',
updated_at: now,
})
return buildCreateResult({
platform: 'lewan',
orderId: order.id,
orderNo,
taskId: task.id,
taskNo: task.task_no,
claimToken: claimToken.token,
claimUrl: buildClaimUrl(claimToken.token),
frontendBaseUrl: input.frontendBaseUrl,
expectedUid: expectedUid || '10001',
step,
productName,
tips: buildLewanTips(step, expectedUid || '10001'),
})
}
export async function createFeifeiMockClaim(
input: {
step?: unknown
orderNo?: unknown
productName?: unknown
productCode?: unknown
uid?: unknown
h5Url?: unknown
frontendBaseUrl?: unknown
} = {},
): Promise<DevMockCreateResult> {
assertDevMockEnabled()
const step = normalizeFeifeiStep(input.step)
const orderNo = String(input.orderNo || `MOCKFF${Date.now()}`).trim()
const productName = String(input.productName || '套装-浪漫天命').trim()
const productCode = String(input.productCode || `MOCK-FF-${Date.now()}`).trim()
const expectedUid = resolveMockUid(input.uid, step === 'uid' ? '' : '166909256')
const rawH5 =
String(input.h5Url || '').trim() ||
`http://skin-exchange.yiquyou.icu/h5/bind?code=mock_${encodeURIComponent(orderNo)}&product_name=${encodeURIComponent(productName)}`
const now = nowIso()
await assertOrderNoAvailable(orderNo)
const profile = await ensureProfile('kuaishou_feifei', 'kuaishou-feifei 履约', true)
const order = await createBaseOrder({
orderNo,
productNo: productName,
productName,
now,
platformTag: 'feifei',
})
const orderItem = await createBaseOrderItem({
orderId: order.id,
orderNo,
productNo: productName,
productName,
consumeShopId: '',
deliveryItems: [],
now,
executor: 'feifei',
productCode,
})
const rechargeStatus =
step === 'completed' ? 30 : step === 'failed' ? 40 : step === 'ready' ? 15 : 15
const rechargeStatusLabel =
step === 'completed'
? '充值成功'
: step === 'failed'
? '充值失败'
: step === 'ready'
? '待绑定'
: '待领取'
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: 'kuaishou_feifei',
taskStatus:
step === 'completed'
? TASK_STATUS.COMPLETED
: step === 'failed'
? TASK_STATUS.MANUAL_REVIEW
: TASK_STATUS.LINK_GENERATED,
deliveryStatus: step === 'completed' ? 'delivered' : 'pending',
resultCode:
step === 'completed'
? 'kuaishou_feifei_completed'
: step === 'failed'
? 'kuaishou_feifei_status_40'
: '',
resultMessage: rechargeStatusLabel,
claimToken: '',
claimExpiresAt: null,
automationMode: 'manual',
requiresClaim: true,
userActionStatus: step === 'completed' ? 'not_required' : 'pending_claim',
attemptCount: 0,
lastError: step === 'failed' ? '开发 mock 模拟 feifei 失败' : '',
contextJson: JSON.stringify({
profileKey: 'kuaishou_feifei',
profileName: 'kuaishou-feifei 履约',
skuCode: productName,
skuName: productName,
claimIdentity: expectedUid
? {
expectedUid,
submittedAt: now,
source: 'dev_mock',
}
: {
expectedUid: '',
submittedAt: null,
source: '',
},
kuaishouFeifei: {
flowType: 'kuaishou_feifei',
productCode,
productName,
platformOrderNo: orderNo,
orderNo: `FF-${orderNo}`,
rechargeStatus,
rechargeStatusLabel,
rechargeResultMessage: step === 'failed' ? '开发 mock 失败' : '',
claimUrl: '',
consumeStatus: step === 'completed' ? 'not_required' : 'pending',
h5: {
entryUrl: rawH5,
rechargeUrl: rawH5,
},
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)
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: 'feifei',
orderId: order.id,
orderNo,
taskId: task.id,
taskNo: task.task_no,
claimToken: claimToken.token,
claimUrl: buildClaimUrl(claimToken.token),
frontendBaseUrl: input.frontendBaseUrl,
expectedUid: expectedUid || '166909256',
step,
productName,
tips: buildFeifeiTips(step, expectedUid || '166909256'),
})
}
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,
consumeShopId: '',
deliveryItems: [],
now,
executor: 'affiliate_dash',
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)
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: buildClaimUrl(claimToken.token),
frontendBaseUrl: input.frontendBaseUrl,
expectedUid: fallbackUid,
step,
productName,
tips: buildAffiliateDashTips(step, fallbackUid),
})
}
/**
* 生成电子凭证列表测试数据,不调用快手接口。
* 覆盖未使用、已核销、已销毁和发码失败等运营页面常见状态。
*/
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
}) {
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: 100,
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: '1.00',
},
}),
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
consumeShopId: string
deliveryItems: DeliveryItem[]
now: string
executor: 'lewan' | 'feifei' | 'affiliate_dash'
productCode?: string
}) {
const snapshot =
input.executor === 'feifei'
? {
source: OPEN_91_PROVIDER,
orderNo: input.orderNo,
productNo: input.productNo,
productName: input.productName,
kuaishouFeifei: {
productCode: input.productCode || '',
productName: input.productName,
matchMode: 'mock',
},
}
: input.executor === 'affiliate_dash'
? {
source: OPEN_91_PROVIDER,
orderNo: input.orderNo,
productNo: input.productNo,
productName: input.productName,
affiliateDash: {
sku: input.productCode || input.productNo,
productName: input.productName,
matchMode: 'mock',
},
}
: {
source: OPEN_91_PROVIDER,
orderNo: input.orderNo,
productNo: input.productNo,
productName: input.productName,
shopId: input.consumeShopId,
cloudtentacles: {
matchMode: 'mock',
normalizedProductName: input.productName,
cloudSourceKeys: ['mock-cloudtentacles'],
resolvedSourceKey: 'mock-cloudtentacles',
deliveryItems: input.deliveryItems,
},
}
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 buildLewanMockContext(input: {
step: LewanMockStep
orderNo: string
productName: string
productNo: string
consumeShopId: string
deliveryItems: DeliveryItem[]
expectedUid: string
timestamp: string
}) {
const roleReady = ['binding', 'confirm', 'result'].includes(input.step)
const roleConfirmed = ['confirm', 'result'].includes(input.step)
const completed = input.step === 'result'
const boundUid = roleReady ? input.expectedUid || '10001' : ''
const primaryItem = input.deliveryItems[0] || {
cloudSkuId: 910001,
cloudSkuName: 'Mock 商品',
quantity: 1,
}
return {
profileKey: 'kuaishou_ct_assisted',
profileName: 'kuaishou-lewan 履约',
skuCode: input.productName,
skuName: input.productName,
claimIdentity: input.expectedUid
? {
expectedUid: input.expectedUid,
submittedAt: input.timestamp,
source: 'dev_mock',
}
: {
expectedUid: '',
submittedAt: null,
source: '',
},
kuaishouCloudFulfillment: {
flowType: 'kuaishou_cloud_fulfillment',
configId: `mock:${input.productName}`,
internalSkuCode: input.productName,
internalSkuName: input.productName,
deliveryItems: input.deliveryItems.length ? input.deliveryItems : [primaryItem],
mock: {
enabled: true,
orderNo: input.orderNo,
productNo: input.productNo,
createdAt: input.timestamp,
},
ticket: {
code: roleReady ? `MOCK-${input.orderNo}` : '',
status: roleReady ? 'verified' : 'pending',
capturedAt: roleReady ? input.timestamp : null,
capturedBy: roleReady ? { source: 'dev_mock' } : null,
verifiedAt: roleReady ? input.timestamp : null,
oid: roleReady ? `MOCK-OID-${input.orderNo}` : '',
formToken: roleReady ? `MOCK-FORM-${input.orderNo}` : '',
leftCount: roleReady ? 1 : 0,
goodsTitle: roleReady ? input.productName : '',
},
binding: {
prepareStatus: roleReady ? 'ready' : 'pending',
cloudSourceKeys: ['mock-cloudtentacles'],
resolvedSourceKey: 'mock-cloudtentacles',
skuId: primaryItem.cloudSkuId,
skuName: primaryItem.cloudSkuName,
vnKey: '1',
vnId: roleReady ? 900001 : 0,
vnPhone: roleReady ? '13800000000' : '',
bindUrl: roleReady ? `https://example.com/mock-kuaishou-cloud-bind/${input.orderNo}` : '',
bindPreparedAt: roleReady ? input.timestamp : null,
bindExpiresAt: roleReady ? addHours(input.timestamp, 24) : null,
bindProbeAt: roleReady ? input.timestamp : null,
bindProbeStatus: roleReady ? 'success' : '',
bindProbeMessage: '',
roleName: roleReady ? '测试角色' : '',
roleId: boundUid,
},
role: {
status: roleReady ? 'ready' : 'pending',
name: roleReady ? '测试角色' : '',
rid: boundUid,
refreshedAt: roleReady ? input.timestamp : null,
errorMessage: '',
rawInfo: roleReady ? { mock: true } : null,
defaultName: roleReady ? '默认机位角色' : '',
defaultRid: roleReady ? 'DEFAULT-000' : '',
defaultCapturedAt: roleReady ? input.timestamp : null,
defaultCaptureStatus: roleReady ? 'captured' : '',
defaultErrorMessage: '',
isDefaultRole: false,
},
purchase: {
autoBuyEnabled: true,
minAssetReserve: 0,
usedKnapsack: false,
purchaseTriggered: false,
assetBefore: 0,
assetAfter: 0,
purchaseAt: null,
items: [],
},
dispatch: {
status: completed ? 'success' : 'pending',
dispatchAt: completed ? input.timestamp : null,
dispatchBy: completed ? { source: 'dev_mock' } : null,
sendType: 0,
note: completed ? '开发 mock 已模拟发货成功' : '',
items: completed ? input.deliveryItems : [],
},
returnNumber: {
status: completed ? 'success' : 'pending',
returnedAt: completed ? input.timestamp : null,
returnedBy: completed ? { source: 'dev_mock' } : null,
autoReturnEnabled: true,
},
consume: {
status: completed ? 'success' : 'pending',
shopId: input.consumeShopId,
shopName: '',
autoConsumeEnabled: true,
consumedAt: completed ? input.timestamp : null,
errorMessage: '',
},
},
}
}
function resolveLewanTaskStatus(step: LewanMockStep) {
if (step === 'result') return TASK_STATUS.COMPLETED
if (step === 'confirm') return TASK_STATUS.ROLE_CONFIRMED
if (step === 'binding') return TASK_STATUS.WAITING_BINDING
return TASK_STATUS.LINK_GENERATED
}
function normalizeLewanStep(value: unknown): LewanMockStep {
const step = String(value || 'uid').trim()
if (['uid', 'binding', 'confirm', 'result'].includes(step)) {
return step as LewanMockStep
}
// 兼容旧 ticket 命名
if (step === 'ticket') return 'uid'
return 'uid'
}
function normalizeFeifeiStep(value: unknown): FeifeiMockStep {
const step = String(value || 'uid').trim()
if (['uid', 'ready', 'completed', 'failed'].includes(step)) {
return step as FeifeiMockStep
}
return 'uid'
}
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 parseDeliveryItems(value: unknown): DeliveryItem[] {
if (!value) {
return [
{
cloudSkuId: 910001,
cloudSkuName: 'Mock 商品',
quantity: 1,
},
]
}
if (typeof value === 'string') {
try {
return parseDeliveryItems(JSON.parse(value))
} catch {
return parseDeliveryItems(null)
}
}
if (!Array.isArray(value)) {
return parseDeliveryItems(null)
}
const items = value
.map((item) => {
const source = item && typeof item === 'object' ? (item as Record<string, unknown>) : {}
const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0
const quantity = Number(source.quantity || 1) || 1
if (!cloudSkuId) return null
return {
cloudSkuId,
cloudSkuName: String(source.cloudSkuName || source.skuName || 'Mock 商品').trim(),
quantity: quantity > 0 ? quantity : 1,
}
})
.filter((item): item is DeliveryItem => Boolean(item))
return items.length > 0 ? items : parseDeliveryItems(null)
}
function buildCreateResult(input: {
platform: 'lewan' | 'feifei' | '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: buildFrontendClaimUrl(input.claimToken, input.frontendBaseUrl),
expectedUid: input.expectedUid,
step: input.step,
productName: input.productName,
tips: input.tips,
open91QueryHint: `可在 Mock 页「91 查单」使用 orderNo=${input.orderNo},或 CLInpm run mock:open91 -- --mode=query --orderNo=${input.orderNo}`,
}
}
function buildFrontendClaimUrl(token: string, frontendBaseUrl?: unknown) {
const base = String(frontendBaseUrl || '').trim()
if (base) {
return `${base.replace(/\/+$/, '')}/#/claim/${token}`
}
return `http://127.0.0.1/#/claim/${token}`
}
function buildLewanTips(step: LewanMockStep, uid: string) {
if (step === 'uid') {
return [
`打开领取链接后,Step1 填写 UID:${uid}`,
'提交后进入绑定步;mock 绑定链接仅作展示,点击刷新/确认前请保证 UID 与角色 ID 一致',
]
}
if (step === 'binding') {
return [
`已预填 UID=${uid} 且角色 ID 已匹配`,
'可直接点「UID 已匹配,下一步」→ 确认兑换(mock 不会真实发货)',
]
}
if (step === 'confirm') {
return ['已进入确认兑换步,点击确认兑换即可模拟成功']
}
return ['已模拟兑换完成,可直接看结果页']
}
function buildFeifeiTips(step: FeifeiMockStep, uid: string) {
if (step === 'uid') {
return [`打开领取链接,Step1 填 UID:${uid}`, '提交后点「打开领取链接」,URL 应带 uid 参数']
}
if (step === 'ready') {
return [`已预填 UID=${uid},可直接打开带 uid 的 H5(mock 链接,无需真实平台)`]
}
if (step === 'failed') {
return ['已模拟 feifei 失败态,用于看领取页结果展示']
}
return ['已模拟 feifei 成功完成态']
}
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()
}