新增开发 Mock 后台菜单与造单能力
非生产环境在后台提供 lewan/feifei 领取造单与 91 查单模拟,CLI 复用同一服务,避免依赖真实平台联调。
This commit is contained in:
@@ -20,7 +20,8 @@
|
||||
"test:industry:curl": "tsx scripts/curl-send-callback.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"start": "node dist/index.js",
|
||||
"start:src": "tsx src/index.ts"
|
||||
"start:src": "tsx src/index.ts",
|
||||
"mock:feifei": "tsx scripts/mock-feifei-claim.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* CLI 入口:开发环境生成 feifei 领取 mock。
|
||||
*/
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb } from '../src/db/client.js'
|
||||
import {
|
||||
createFeifeiMockClaim,
|
||||
isDevMockEnabled,
|
||||
} from '../src/services/dev-mock/dev-mock-service.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help) {
|
||||
console.log(`
|
||||
生成 feifei 领取 mock(CLI)。推荐使用后台 /admin/dev-mock。
|
||||
|
||||
npm run mock:feifei -- --step=ready --uid=166909256
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!isDevMockEnabled() && args.force !== true) {
|
||||
console.error('当前是生产环境,已拒绝生成 mock 数据。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.force === true) {
|
||||
process.env.ENABLE_DEV_MOCK = '1'
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createFeifeiMockClaim({
|
||||
step: args.step,
|
||||
orderNo: args.orderNo,
|
||||
productName: args.productName,
|
||||
productCode: args.productCode,
|
||||
uid: args.uid,
|
||||
h5Url: args.h5Url,
|
||||
frontendBaseUrl: args.frontendBaseUrl,
|
||||
})
|
||||
|
||||
console.log('已生成 kuaishou-feifei 领取 mock 数据:')
|
||||
console.log(` 91订单号:${result.orderNo}`)
|
||||
console.log(` 商品:${result.productName}`)
|
||||
console.log(` 当前步骤:${result.step}`)
|
||||
console.log(` 期望 UID:${result.expectedUid}`)
|
||||
console.log(` 领取链接:${result.claimUrl}`)
|
||||
for (const tip of result.tips) {
|
||||
console.log(` · ${tip}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await closeDb()
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs: string[]) {
|
||||
const parsed: JsonObject = {}
|
||||
for (const rawArg of rawArgs) {
|
||||
const arg = String(rawArg || '').trim()
|
||||
if (!arg) continue
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true
|
||||
continue
|
||||
}
|
||||
if (arg === '--force') {
|
||||
parsed.force = true
|
||||
continue
|
||||
}
|
||||
const normalized = arg.startsWith('--') ? arg.slice(2) : arg
|
||||
const separatorIndex = normalized.indexOf('=')
|
||||
if (separatorIndex < 0) {
|
||||
parsed[normalized] = true
|
||||
continue
|
||||
}
|
||||
const key = normalized.slice(0, separatorIndex).trim()
|
||||
const value = normalized.slice(separatorIndex + 1).trim()
|
||||
if (key) parsed[key] = value
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -1,26 +1,16 @@
|
||||
/**
|
||||
* CLI 入口:开发环境生成 lewan 领取 mock。
|
||||
* 推荐使用后台「开发 Mock」页面。
|
||||
*/
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb } from '../src/db/client.js'
|
||||
import { createOrder, findOrderByPlatformOrderId } from '../src/repositories/order-repo.js'
|
||||
import { replaceOrderItems } from '../src/repositories/order-item-repo.js'
|
||||
import { createTask, updateTask } from '../src/repositories/task-repo.js'
|
||||
import {
|
||||
getFulfillmentProfileByKey,
|
||||
upsertFulfillmentProfile,
|
||||
} from '../src/repositories/fulfillment-profile-repo.js'
|
||||
import { buildClaimUrl, createTaskClaimToken } from '../src/services/claim/claim-service.js'
|
||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../src/services/open-91/config.js'
|
||||
import { parseOpen91ProductNo } from '../src/services/platforms/ninetyone/order-service.js'
|
||||
import { isProductionLike } from '../src/config/runtime-validation.js'
|
||||
import { addHours, nowIso } from '../src/utils/time.js'
|
||||
import { randomId } from '../src/utils/random.js'
|
||||
createLewanMockClaim,
|
||||
isDevMockEnabled,
|
||||
} from '../src/services/dev-mock/dev-mock-service.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
type DeliveryItem = {
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
@@ -29,312 +19,46 @@ if (args.help) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (isProductionLike() && args.force !== true) {
|
||||
console.error('当前是生产环境,已拒绝生成 mock 数据。如确实要执行,请追加 --force。')
|
||||
if (!isDevMockEnabled() && args.force !== true) {
|
||||
console.error('当前是生产环境,已拒绝生成 mock 数据。如确实要执行,请追加 --force,或设置 ENABLE_DEV_MOCK=1。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const step = normalizeStep(args.step)
|
||||
const orderNo = String(args.orderNo || `MOCK91${Date.now()}`).trim()
|
||||
const productNo = String(args.productNo || '套餐_1----3676797936').trim()
|
||||
const productInfo = parseOpen91ProductNo(productNo)
|
||||
const productName = productInfo.productName || productInfo.rawProductNo || '套餐_1'
|
||||
const consumeShopId = productInfo.shopId || String(args.kuaishouShopId || '3676797936').trim()
|
||||
const deliveryItems = parseDeliveryItems(args.items)
|
||||
const now = nowIso()
|
||||
const existingOrder = await findOrderByPlatformOrderId({
|
||||
provider: OPEN_91_PROVIDER,
|
||||
platform: OPEN_91_PLATFORM,
|
||||
shopId: OPEN_91_PROVIDER,
|
||||
platformOrderId: orderNo,
|
||||
})
|
||||
|
||||
if (existingOrder) {
|
||||
console.error(`订单号已存在:${orderNo}`)
|
||||
console.error('请换一个 --orderNo,或不传 orderNo 让脚本自动生成。')
|
||||
await closeDb()
|
||||
process.exit(1)
|
||||
if (args.force === true) {
|
||||
process.env.ENABLE_DEV_MOCK = '1'
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await ensureKuaishouCloudProfile(now)
|
||||
const order = await createOrder({
|
||||
provider: OPEN_91_PROVIDER,
|
||||
platform: OPEN_91_PLATFORM,
|
||||
shopId: OPEN_91_PROVIDER,
|
||||
shopName: '91卡券',
|
||||
platformOrderId: orderNo,
|
||||
orderStatus: 'paid',
|
||||
payStatus: 'paid',
|
||||
buyerId: 'mock-buyer',
|
||||
buyerName: 'mock 领取客户',
|
||||
receiverContact: '',
|
||||
totalAmount: 1,
|
||||
currency: 'CNY',
|
||||
rawPayloadJson: JSON.stringify({
|
||||
source: OPEN_91_PROVIDER,
|
||||
mock: true,
|
||||
receivedAt: now,
|
||||
body: {
|
||||
orderNo,
|
||||
productNo,
|
||||
productName,
|
||||
buyNum: 1,
|
||||
maxAmount: '0.01',
|
||||
},
|
||||
}),
|
||||
paidAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw new Error('订单创建失败')
|
||||
}
|
||||
|
||||
const [orderItem] = await replaceOrderItems(order.id, [{
|
||||
skuCode: productName,
|
||||
skuName: productName,
|
||||
quantity: 1,
|
||||
specJson: JSON.stringify({
|
||||
source: OPEN_91_PROVIDER,
|
||||
orderNo,
|
||||
productNo,
|
||||
productName,
|
||||
shopId: consumeShopId,
|
||||
}),
|
||||
itemSnapshotJson: JSON.stringify({
|
||||
source: OPEN_91_PROVIDER,
|
||||
orderNo,
|
||||
productNo,
|
||||
productName,
|
||||
shopId: consumeShopId,
|
||||
cloudtentacles: {
|
||||
matchMode: 'mock',
|
||||
normalizedProductName: productName,
|
||||
cloudSourceKeys: ['mock-cloudtentacles'],
|
||||
resolvedSourceKey: 'mock-cloudtentacles',
|
||||
deliveryItems,
|
||||
},
|
||||
}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}])
|
||||
|
||||
if (!orderItem) {
|
||||
throw new Error('订单商品创建失败')
|
||||
}
|
||||
|
||||
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: resolveInitialTaskStatus(step),
|
||||
deliveryStatus: step === 'result' ? 'success' : 'pending',
|
||||
resultCode: step === 'result' ? 'mock_success' : '',
|
||||
resultMessage: step === 'result' ? '开发 mock 已模拟兑换成功' : '',
|
||||
claimToken: '',
|
||||
claimExpiresAt: null,
|
||||
automationMode: 'manual',
|
||||
requiresClaim: false,
|
||||
userActionStatus: step === 'result' ? 'not_required' : 'pending_claim',
|
||||
attemptCount: 0,
|
||||
lastError: '',
|
||||
contextJson: JSON.stringify(buildMockTaskContext({
|
||||
step,
|
||||
orderNo,
|
||||
productName,
|
||||
productNo,
|
||||
consumeShopId,
|
||||
deliveryItems,
|
||||
timestamp: now,
|
||||
})),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
throw new Error('履约任务创建失败')
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const claimUrl = buildClaimUrl(claimToken.token)
|
||||
await updateTask(task.id, {
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
...(step !== 'ticket' ? { claimed_at: now } : {}),
|
||||
...(step === 'confirm' || step === 'result' ? { role_confirmed_at: now } : {}),
|
||||
...(step === 'result' ? { redeemed_at: now } : {}),
|
||||
updated_at: now,
|
||||
const result = await createLewanMockClaim({
|
||||
step: args.step,
|
||||
orderNo: args.orderNo,
|
||||
productNo: args.productNo,
|
||||
uid: args.uid,
|
||||
items: args.items,
|
||||
frontendBaseUrl: args.frontendBaseUrl,
|
||||
})
|
||||
|
||||
console.log('已生成 kuaishou-lewan 领取 mock 数据:')
|
||||
console.log(` 91订单号:${orderNo}`)
|
||||
console.log(` 商品:${productName}`)
|
||||
console.log(` 当前步骤:${step}`)
|
||||
console.log(` 领取链接:${claimUrl}`)
|
||||
console.log(` 前端本地链接:${buildFrontendClaimUrl(claimToken.token, args.frontendBaseUrl)}`)
|
||||
console.log(` 91订单号:${result.orderNo}`)
|
||||
console.log(` 商品:${result.productName}`)
|
||||
console.log(` 当前步骤:${result.step}`)
|
||||
console.log(` 期望 UID:${result.expectedUid}`)
|
||||
console.log(` 领取链接:${result.claimUrl}`)
|
||||
console.log(` 前端本地链接:${result.frontendClaimUrl}`)
|
||||
console.log('')
|
||||
console.log('页面核销码可填:MOCK')
|
||||
console.log(`91 查询接口可用这个订单号测试:npm run mock:open91 -- --mode=query --orderNo=${orderNo}`)
|
||||
for (const tip of result.tips) {
|
||||
console.log(` · ${tip}`)
|
||||
}
|
||||
console.log(` ${result.open91QueryHint}`)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await closeDb()
|
||||
}
|
||||
|
||||
async function ensureKuaishouCloudProfile(timestamp: string) {
|
||||
const existing = await getFulfillmentProfileByKey('kuaishou_ct_assisted')
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const profile = await upsertFulfillmentProfile({
|
||||
profileKey: 'kuaishou_ct_assisted',
|
||||
name: 'kuaishou-lewan 履约',
|
||||
executorKey: 'kuaishou_ct_assisted',
|
||||
requiresClaim: false,
|
||||
autoDispatch: false,
|
||||
inventoryStrategy: 'external_platform',
|
||||
configJson: '{}',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('履约配置创建失败')
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
function buildMockTaskContext({
|
||||
step,
|
||||
orderNo,
|
||||
productName,
|
||||
productNo,
|
||||
consumeShopId,
|
||||
deliveryItems,
|
||||
timestamp,
|
||||
}: {
|
||||
step: string
|
||||
orderNo: string
|
||||
productName: string
|
||||
productNo: string
|
||||
consumeShopId: string
|
||||
deliveryItems: DeliveryItem[]
|
||||
timestamp: string
|
||||
}) {
|
||||
const ticketVerified = ['binding', 'confirm', 'result'].includes(step)
|
||||
const roleReady = ['binding', 'confirm', 'result'].includes(step)
|
||||
const roleConfirmed = ['confirm', 'result'].includes(step)
|
||||
const completed = step === 'result'
|
||||
const primaryItem = deliveryItems[0] || {
|
||||
cloudSkuId: 910001,
|
||||
cloudSkuName: 'Mock 商品',
|
||||
quantity: 1,
|
||||
}
|
||||
|
||||
return {
|
||||
profileKey: 'kuaishou_ct_assisted',
|
||||
profileName: 'kuaishou-lewan 履约',
|
||||
skuCode: productName,
|
||||
skuName: productName,
|
||||
kuaishouCloudFulfillment: {
|
||||
flowType: 'kuaishou_cloud_fulfillment',
|
||||
configId: `mock:${productName}`,
|
||||
internalSkuCode: productName,
|
||||
internalSkuName: productName,
|
||||
deliveryItems,
|
||||
mock: {
|
||||
enabled: true,
|
||||
orderNo,
|
||||
productNo,
|
||||
createdAt: timestamp,
|
||||
},
|
||||
ticket: {
|
||||
code: ticketVerified ? `MOCK-${orderNo}` : '',
|
||||
status: ticketVerified ? 'verified' : 'pending',
|
||||
capturedAt: ticketVerified ? timestamp : null,
|
||||
capturedBy: ticketVerified ? { source: 'mock_script' } : null,
|
||||
verifiedAt: ticketVerified ? timestamp : null,
|
||||
oid: ticketVerified ? `MOCK-OID-${orderNo}` : '',
|
||||
formToken: ticketVerified ? `MOCK-FORM-${orderNo}` : '',
|
||||
leftCount: ticketVerified ? 1 : 0,
|
||||
goodsTitle: ticketVerified ? 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/${orderNo}` : '',
|
||||
bindPreparedAt: roleReady ? timestamp : null,
|
||||
bindExpiresAt: roleReady ? addHours(timestamp, 24) : null,
|
||||
bindProbeAt: roleReady ? timestamp : null,
|
||||
bindProbeStatus: roleReady ? 'success' : '',
|
||||
bindProbeMessage: '',
|
||||
roleName: roleReady ? '测试角色' : '',
|
||||
roleId: roleReady ? '10001' : '',
|
||||
},
|
||||
role: {
|
||||
status: roleReady ? 'ready' : 'pending',
|
||||
name: roleReady ? '测试角色' : '',
|
||||
rid: roleReady ? '10001' : '',
|
||||
refreshedAt: roleReady ? timestamp : null,
|
||||
errorMessage: '',
|
||||
rawInfo: roleReady ? { mock: true } : null,
|
||||
},
|
||||
purchase: {
|
||||
autoBuyEnabled: true,
|
||||
minAssetReserve: 0,
|
||||
usedKnapsack: false,
|
||||
purchaseTriggered: false,
|
||||
assetBefore: 0,
|
||||
assetAfter: 0,
|
||||
purchaseAt: null,
|
||||
items: [],
|
||||
},
|
||||
dispatch: {
|
||||
status: completed ? 'success' : 'pending',
|
||||
dispatchAt: completed ? timestamp : null,
|
||||
dispatchBy: completed ? { source: 'mock_script' } : null,
|
||||
sendType: 0,
|
||||
note: completed ? '开发 mock 已模拟发货成功' : '',
|
||||
items: completed ? deliveryItems : [],
|
||||
},
|
||||
returnNumber: {
|
||||
status: completed ? 'success' : 'pending',
|
||||
returnedAt: completed ? timestamp : null,
|
||||
returnedBy: completed ? { source: 'mock_script' } : null,
|
||||
autoReturnEnabled: true,
|
||||
},
|
||||
consume: {
|
||||
status: completed ? 'success' : 'pending',
|
||||
shopId: consumeShopId,
|
||||
shopName: 'Mock 快手小店',
|
||||
autoConsumeEnabled: true,
|
||||
consumedAt: completed ? timestamp : null,
|
||||
errorMessage: '',
|
||||
},
|
||||
notes: '开发 mock:不调用 cloudtentacles,只用于领取页流程验证',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs: string[]) {
|
||||
const parsed: JsonObject = {}
|
||||
|
||||
for (const rawArg of rawArgs) {
|
||||
const arg = String(rawArg || '').trim()
|
||||
if (!arg) continue
|
||||
@@ -342,103 +66,35 @@ function parseArgs(rawArgs: string[]) {
|
||||
parsed.help = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--force') {
|
||||
parsed.force = true
|
||||
continue
|
||||
}
|
||||
const normalized = arg.startsWith('--') ? arg.slice(2) : arg
|
||||
const separatorIndex = normalized.indexOf('=')
|
||||
if (separatorIndex < 0) {
|
||||
parsed[normalized] = true
|
||||
continue
|
||||
}
|
||||
|
||||
const key = normalized.slice(0, separatorIndex).trim()
|
||||
const value = normalized.slice(separatorIndex + 1).trim()
|
||||
if (key) {
|
||||
parsed[key] = value
|
||||
if (key) parsed[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeStep(value: unknown) {
|
||||
const normalized = String(value || 'ticket').trim()
|
||||
if (['ticket', 'binding', 'confirm', 'result'].includes(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
throw new Error('--step 只能是 ticket、binding、confirm、result')
|
||||
}
|
||||
|
||||
function resolveInitialTaskStatus(step: string) {
|
||||
if (step === 'ticket') return 'pending_binding_prepare'
|
||||
if (step === 'binding') return 'waiting_binding'
|
||||
if (step === 'confirm') return 'role_confirmed'
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
function parseDeliveryItems(value: unknown): DeliveryItem[] {
|
||||
const text = String(value || '').trim()
|
||||
const fallback = [
|
||||
{ cloudSkuId: 910001, cloudSkuName: '套餐商品 A', quantity: 1 },
|
||||
{ cloudSkuId: 910002, cloudSkuName: '套餐商品 B', quantity: 2 },
|
||||
]
|
||||
|
||||
if (!text) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const items = text
|
||||
.split(',')
|
||||
.map((segment) => {
|
||||
const [id, name, quantity] = segment.split(':')
|
||||
const cloudSkuId = Number(id)
|
||||
return {
|
||||
cloudSkuId,
|
||||
cloudSkuName: String(name || '').trim(),
|
||||
quantity: Math.max(1, Number(quantity || 1) || 1),
|
||||
}
|
||||
})
|
||||
.filter((item) => Number.isInteger(item.cloudSkuId) && item.cloudSkuId > 0 && item.cloudSkuName)
|
||||
|
||||
return items.length > 0 ? mergeDeliveryItems(items) : fallback
|
||||
}
|
||||
|
||||
function mergeDeliveryItems(items: DeliveryItem[]) {
|
||||
const merged = new Map<number, DeliveryItem>()
|
||||
|
||||
for (const item of items) {
|
||||
const existing = merged.get(item.cloudSkuId)
|
||||
if (existing) {
|
||||
existing.quantity += item.quantity
|
||||
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
|
||||
continue
|
||||
}
|
||||
merged.set(item.cloudSkuId, { ...item })
|
||||
}
|
||||
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
function buildFrontendClaimUrl(token: string, rawBaseUrl: unknown) {
|
||||
const baseUrl = String(rawBaseUrl || process.env.MOCK_CLAIM_FRONTEND_BASE_URL || 'http://127.0.0.1:5173').replace(/\/+$/, '')
|
||||
return `${baseUrl}/#/claim/${token}`
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
生成 kuaishou-lewan 领取 mock 数据:
|
||||
生成 lewan 领取 mock(CLI)。推荐使用后台 /admin/dev-mock。
|
||||
|
||||
npm run mock:claim
|
||||
npm run mock:claim -- --step=binding
|
||||
npm run mock:claim -- --step=result --productNo=套餐_1----3676797936
|
||||
npm run mock:claim -- --items=910001:商品A:1,910002:商品B:2
|
||||
npm run mock:claim -- --step=binding --uid=10001
|
||||
|
||||
参数:
|
||||
--step ticket、binding、confirm、result,默认 ticket
|
||||
--orderNo 91 订单号,默认自动生成
|
||||
--productNo 91 商品名,可带 ----快手小店ID,默认 套餐_1----3676797936
|
||||
--items 套餐明细,格式 skuId:名称:数量,skuId:名称:数量
|
||||
--frontendBaseUrl 前端地址,默认 http://127.0.0.1:5173
|
||||
--force 允许在 NODE_ENV=production 执行
|
||||
--step uid | binding | confirm | result(默认 uid)
|
||||
--uid 游戏 UID(binding 起默认 10001)
|
||||
--orderNo 订单号,默认自动生成
|
||||
--productNo 91 商品名,默认 套餐_1----3676797936
|
||||
--frontendBaseUrl 前端 base,用于打印本地 claim 链接
|
||||
--force 允许在 production 执行(仍需 ENABLE_DEV_MOCK 或 force 打开开关)
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import authRouter from "./admin/auth.js";
|
||||
import auditLogsRouter from "./admin/audit-logs.js";
|
||||
import cloudtentaclesRecordsRouter from "./admin/cloudtentacles-records.js";
|
||||
import dashboardRouter from "./admin/dashboard.js";
|
||||
import devMockRouter from "./admin/dev-mock.js";
|
||||
import kuaishouIndustryRouter from "./admin/kuaishou-industry.js";
|
||||
import ordersRouter from "./admin/orders.js";
|
||||
import platformConfigRouter from "./admin/platform-config.js";
|
||||
@@ -24,6 +25,7 @@ router.use(kuaishouIndustryRouter);
|
||||
router.use(ordersRouter);
|
||||
router.use(tasksRouter);
|
||||
router.use(cloudtentaclesRecordsRouter);
|
||||
router.use(devMockRouter);
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req));
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
buildOpen91QueryMock,
|
||||
createFeifeiMockClaim,
|
||||
createLewanMockClaim,
|
||||
getDevMockStatus,
|
||||
isDevMockEnabled,
|
||||
} from '../../services/dev-mock/dev-mock-service.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function requireDevMockEnabled() {
|
||||
return (
|
||||
_req: unknown,
|
||||
_res: unknown,
|
||||
next: (error?: unknown) => void,
|
||||
) => {
|
||||
if (!isDevMockEnabled()) {
|
||||
next(
|
||||
createHttpError('开发 Mock 仅在非 production 环境可用(或设置 ENABLE_DEV_MOCK=1)', {
|
||||
statusCode: 403,
|
||||
errorCode: 'dev_mock_disabled',
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/dev-mock/status',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(() => getDevMockStatus(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 Mock 状态失败',
|
||||
scope: '[admin/dev-mock/status]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.use('/dev-mock', requireAdminRoles(['admin', 'operator']), requireDevMockEnabled())
|
||||
|
||||
router.post(
|
||||
'/dev-mock/lewan',
|
||||
createJsonHandler((req) => createLewanMockClaim(req.body || {}), {
|
||||
successMessage: 'lewan mock 已生成',
|
||||
errorMessage: '生成 lewan mock 失败',
|
||||
scope: '[admin/dev-mock/lewan]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'dev_mock_create_lewan',
|
||||
targetType: 'dev_mock',
|
||||
targetId: String((data as { orderNo?: string })?.orderNo || ''),
|
||||
data: {
|
||||
platform: 'lewan',
|
||||
orderNo: (data as { orderNo?: string })?.orderNo,
|
||||
step: (data as { step?: string })?.step,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/dev-mock/feifei',
|
||||
createJsonHandler((req) => createFeifeiMockClaim(req.body || {}), {
|
||||
successMessage: 'feifei mock 已生成',
|
||||
errorMessage: '生成 feifei mock 失败',
|
||||
scope: '[admin/dev-mock/feifei]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'dev_mock_create_feifei',
|
||||
targetType: 'dev_mock',
|
||||
targetId: String((data as { orderNo?: string })?.orderNo || ''),
|
||||
data: {
|
||||
platform: 'feifei',
|
||||
orderNo: (data as { orderNo?: string })?.orderNo,
|
||||
step: (data as { step?: string })?.step,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/dev-mock/open91/query',
|
||||
createJsonHandler((req) => buildOpen91QueryMock(req.body || {}), {
|
||||
successMessage: '91 查单 mock 已生成',
|
||||
errorMessage: '生成 91 查单 mock 失败',
|
||||
scope: '[admin/dev-mock/open91/query]',
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,858 @@
|
||||
/**
|
||||
* 开发环境专用 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 {
|
||||
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 { 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'
|
||||
orderId: number
|
||||
orderNo: string
|
||||
taskId: number
|
||||
taskNo: string
|
||||
claimToken: string
|
||||
claimUrl: string
|
||||
frontendClaimUrl: string
|
||||
expectedUid: string
|
||||
step: string
|
||||
productName: string
|
||||
tips: string[]
|
||||
open91QueryHint: 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'],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
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'),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 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'
|
||||
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',
|
||||
},
|
||||
}
|
||||
: {
|
||||
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 resolveMockUid(value: unknown, fallback: string) {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) {
|
||||
return normalizeClaimUid(fallback)
|
||||
}
|
||||
return assertValidClaimUid(raw)
|
||||
}
|
||||
|
||||
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'
|
||||
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},或 CLI:npm 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 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()
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const AdminPlatformShopsPage = lazy(() => import('@/pages/admin/platform/AdminPl
|
||||
const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage'))
|
||||
const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage'))
|
||||
const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage'))
|
||||
const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage'))
|
||||
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
|
||||
@@ -67,6 +68,7 @@ export default function App() {
|
||||
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
|
||||
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
|
||||
<Route path="cloudtentacles-records" element={<AdminCloudtentaclesRecordsPage />} />
|
||||
<Route path="dev-mock" element={<AdminDevMockPage />} />
|
||||
<Route element={<RequireRole roles={['admin']} />}>
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="platform-shops" element={<AdminPlatformShopsPage />} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AuditOutlined,
|
||||
BugOutlined,
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
LogoutOutlined,
|
||||
@@ -15,10 +16,11 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { App, Button, Dropdown, Layout, Menu, Space, Typography } from 'antd'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { logoutAdmin } from '@/services/admin'
|
||||
import { fetchDevMockStatus, logoutAdmin } from '@/services/admin'
|
||||
import {
|
||||
clearAdminSession,
|
||||
getAdminRole,
|
||||
@@ -40,6 +42,14 @@ export default function AdminLayout() {
|
||||
const username = getAdminUsername() || 'admin'
|
||||
const expiresAt = getAdminTokenExpiresAt()
|
||||
|
||||
const devMockStatusQuery = useQuery({
|
||||
queryKey: ['admin-dev-mock-status'],
|
||||
queryFn: () => fetchDevMockStatus(),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
})
|
||||
const devMockEnabled = Boolean(devMockStatusQuery.data?.data?.enabled)
|
||||
|
||||
const menuItems = useMemo<MenuProps['items']>(() => {
|
||||
const operationItems: MenuProps['items'] = [
|
||||
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
|
||||
@@ -58,6 +68,17 @@ export default function AdminLayout() {
|
||||
},
|
||||
]
|
||||
|
||||
if (devMockEnabled) {
|
||||
items.push({
|
||||
key: 'group-dev',
|
||||
type: 'group',
|
||||
label: '开发',
|
||||
children: [
|
||||
{ key: '/admin/dev-mock', icon: <BugOutlined />, label: '开发 Mock' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (role === 'admin') {
|
||||
items.push(
|
||||
{
|
||||
@@ -80,7 +101,7 @@ export default function AdminLayout() {
|
||||
}
|
||||
|
||||
return items
|
||||
}, [role])
|
||||
}, [devMockEnabled, role])
|
||||
|
||||
async function submitLogout() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { BugOutlined, CopyOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
Result,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import {
|
||||
buildOpen91QueryDevMock,
|
||||
createFeifeiDevMock,
|
||||
createLewanDevMock,
|
||||
fetchDevMockStatus,
|
||||
type DevMockCreateResult,
|
||||
type DevMockOpen91QueryResult,
|
||||
} from '@/services/admin'
|
||||
|
||||
export default function AdminDevMockPage() {
|
||||
const { message } = App.useApp()
|
||||
const [lastCreate, setLastCreate] = useState<DevMockCreateResult | null>(null)
|
||||
const [lastQuery, setLastQuery] = useState<DevMockOpen91QueryResult | null>(null)
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['admin-dev-mock-status'],
|
||||
queryFn: () => fetchDevMockStatus(),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const status = statusQuery.data?.data
|
||||
const enabled = Boolean(status?.enabled)
|
||||
|
||||
const lewanMutation = useMutation({
|
||||
mutationFn: createLewanDevMock,
|
||||
onSuccess: (response) => {
|
||||
setLastCreate(response.data)
|
||||
message.success('lewan mock 已生成')
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '生成失败')
|
||||
},
|
||||
})
|
||||
|
||||
const feifeiMutation = useMutation({
|
||||
mutationFn: createFeifeiDevMock,
|
||||
onSuccess: (response) => {
|
||||
setLastCreate(response.data)
|
||||
message.success('feifei mock 已生成')
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '生成失败')
|
||||
},
|
||||
})
|
||||
|
||||
const open91Mutation = useMutation({
|
||||
mutationFn: buildOpen91QueryDevMock,
|
||||
onSuccess: (response) => {
|
||||
setLastQuery(response.data)
|
||||
message.success('91 查单请求已生成')
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '生成失败')
|
||||
},
|
||||
})
|
||||
|
||||
const defaultOrderNo = useMemo(
|
||||
() => lastCreate?.orderNo || '',
|
||||
[lastCreate?.orderNo],
|
||||
)
|
||||
|
||||
if (statusQuery.isLoading) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="开发 Mock" description="加载中…" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (statusQuery.error || !enabled) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="开发 Mock" description="仅开发环境可用。" />
|
||||
<Result
|
||||
status="403"
|
||||
title="当前环境未开启开发 Mock"
|
||||
subTitle={
|
||||
statusQuery.error instanceof Error
|
||||
? statusQuery.error.message
|
||||
: '生产环境默认关闭。本地开发请使用 docker-compose.dev 或设置 ENABLE_DEV_MOCK=1。'
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="开发 Mock"
|
||||
description="一键造单、测领取页与 91 查单,无需真实 91 / 快手 / feifei 环境。"
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
NODE_ENV={status?.nodeEnv || '-'}
|
||||
</Typography.Text>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<BugOutlined />}
|
||||
message="Mock 会写入开发库真实订单/任务,但 lewan 兑换走 mock 成功路径,feifei 不调用真实平台下单。"
|
||||
description="建议 UID 与默认角色一致:lewan 默认 10001,feifei 默认 166909256。"
|
||||
/>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} xl={12}>
|
||||
<Card title="1. lewan 领取(强制 UID)" bordered={false}>
|
||||
<Form
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
step: 'binding',
|
||||
uid: status?.defaultUid || '10001',
|
||||
productNo: '套餐_1----3676797936',
|
||||
}}
|
||||
onFinish={(values) => lewanMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item
|
||||
label="页面步骤"
|
||||
name="step"
|
||||
tooltip="uid=只到填 UID;binding=已预填 UID 且角色匹配;confirm/result=后续步骤"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'uid', label: 'uid · 待填 UID' },
|
||||
{ value: 'binding', label: 'binding · 绑定且 UID 已匹配(推荐)' },
|
||||
{ value: 'confirm', label: 'confirm · 待确认兑换' },
|
||||
{ value: 'result', label: 'result · 已完成' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="游戏 UID" name="uid" rules={[{ required: true, message: '请填 UID' }]}>
|
||||
<Input placeholder="10001" />
|
||||
</Form.Item>
|
||||
<Form.Item label="91 商品 productNo" name="productNo">
|
||||
<Input placeholder="套餐_1----3676797936" />
|
||||
</Form.Item>
|
||||
<Form.Item label="订单号 orderNo(可空自动生成)" name="orderNo">
|
||||
<Input placeholder="留空自动 MOCK91…" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={lewanMutation.isPending} block>
|
||||
生成 lewan Mock
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} xl={12}>
|
||||
<Card title="2. feifei 领取(拼 UID 到 H5)" bordered={false}>
|
||||
<Form
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
step: 'ready',
|
||||
uid: '166909256',
|
||||
productName: '套装-浪漫天命',
|
||||
}}
|
||||
onFinish={(values) => feifeiMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item label="页面步骤" name="step">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'uid', label: 'uid · 待填 UID' },
|
||||
{ value: 'ready', label: 'ready · 已填 UID,可打开 H5(推荐)' },
|
||||
{ value: 'completed', label: 'completed · 模拟成功' },
|
||||
{ value: 'failed', label: 'failed · 模拟失败' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="游戏 UID" name="uid" rules={[{ required: true, message: '请填 UID' }]}>
|
||||
<Input placeholder="166909256" />
|
||||
</Form.Item>
|
||||
<Form.Item label="商品名" name="productName">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="H5 链接(可空用 mock 地址)" name="h5Url">
|
||||
<Input placeholder="http://skin-exchange.../h5/bind?code=..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="订单号 orderNo(可空)" name="orderNo">
|
||||
<Input placeholder="留空自动 MOCKFF…" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={feifeiMutation.isPending} block>
|
||||
生成 feifei Mock
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24}>
|
||||
<Card title="3. 模拟 91 查单(open-91/query)" bordered={false}>
|
||||
<Form
|
||||
layout="inline"
|
||||
initialValues={{ orderNo: defaultOrderNo, execute: '1' }}
|
||||
key={defaultOrderNo}
|
||||
onFinish={(values) =>
|
||||
open91Mutation.mutate({
|
||||
orderNo: values.orderNo,
|
||||
execute: values.execute === '1',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
label="orderNo"
|
||||
name="orderNo"
|
||||
rules={[{ required: true, message: '填写上方生成的订单号' }]}
|
||||
>
|
||||
<Input style={{ width: 280 }} placeholder="MOCK91…" />
|
||||
</Form.Item>
|
||||
<Form.Item label="执行" name="execute">
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
options={[
|
||||
{ value: '1', label: '生成并请求本机' },
|
||||
{ value: '0', label: '仅生成请求体' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={open91Mutation.isPending}>
|
||||
模拟 91 查单
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
对应日志:POST /api/v1/open/91/orders/query。需配置 KAQUAN91_SECRET(32 位)。
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{lastCreate ? (
|
||||
<Card
|
||||
title="最近生成结果"
|
||||
extra={
|
||||
<Space>
|
||||
<Link to={`/admin/orders/${lastCreate.orderId}`}>订单 #{lastCreate.orderId}</Link>
|
||||
<Link to={`/admin/tasks/${lastCreate.taskId}`}>任务 #{lastCreate.taskId}</Link>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Typography.Text type="secondary">平台</Typography.Text>
|
||||
<div>
|
||||
<Typography.Text strong>{lastCreate.platform}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Typography.Text type="secondary">步骤 / UID</Typography.Text>
|
||||
<div>
|
||||
<Typography.Text strong>
|
||||
{lastCreate.step} / {lastCreate.expectedUid}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Typography.Text type="secondary">91 订单号</Typography.Text>
|
||||
<div>
|
||||
<Typography.Text copyable>{lastCreate.orderNo}</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Typography.Text type="secondary">商品</Typography.Text>
|
||||
<div>{lastCreate.productName}</div>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Typography.Text type="secondary">领取链接</Typography.Text>
|
||||
<div>
|
||||
<Typography.Link href={lastCreate.claimUrl} target="_blank" rel="noreferrer">
|
||||
<LinkOutlined /> {lastCreate.claimUrl}
|
||||
</Typography.Link>
|
||||
</div>
|
||||
<Space style={{ marginTop: 8 }} wrap>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(lastCreate.claimUrl)
|
||||
message.success('已复制领取链接')
|
||||
}}
|
||||
>
|
||||
复制链接
|
||||
</Button>
|
||||
<Button type="primary" href={lastCreate.claimUrl} target="_blank">
|
||||
打开领取页
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message="操作提示"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{lastCreate.tips.map((tip) => (
|
||||
<li key={tip}>{tip}</li>
|
||||
))}
|
||||
<li>{lastCreate.open91QueryHint}</li>
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{lastQuery ? (
|
||||
<Card title="91 查单结果">
|
||||
<Typography.Paragraph>
|
||||
<Typography.Text type="secondary">Endpoint:</Typography.Text>
|
||||
<Typography.Text code copyable>
|
||||
{lastQuery.endpoint}
|
||||
</Typography.Text>
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph>
|
||||
<Typography.Text type="secondary">Request:</Typography.Text>
|
||||
</Typography.Paragraph>
|
||||
<pre className="dev-mock-pre">{JSON.stringify(lastQuery.requestBody, null, 2)}</pre>
|
||||
<Typography.Paragraph>
|
||||
<Typography.Text type="secondary">curl:</Typography.Text>
|
||||
<Typography.Text code copyable>
|
||||
{lastQuery.curl}
|
||||
</Typography.Text>
|
||||
</Typography.Paragraph>
|
||||
{lastQuery.response ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Typography.Paragraph>
|
||||
<Typography.Text type="secondary">Response:</Typography.Text>
|
||||
</Typography.Paragraph>
|
||||
<pre className="dev-mock-pre">{JSON.stringify(lastQuery.response, null, 2)}</pre>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
|
||||
export type DevMockStatus = {
|
||||
enabled: boolean
|
||||
nodeEnv: string
|
||||
defaultUid: string
|
||||
platforms: Array<{
|
||||
key: string
|
||||
name: string
|
||||
description: string
|
||||
steps: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export type DevMockCreateResult = {
|
||||
platform: 'lewan' | 'feifei'
|
||||
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 DevMockOpen91QueryResult = {
|
||||
endpoint: string
|
||||
requestBody: Record<string, unknown>
|
||||
curl: string
|
||||
response: unknown
|
||||
}
|
||||
|
||||
export function fetchDevMockStatus() {
|
||||
return apiGet<DevMockStatus>('/api/v1/admin/dev-mock/status')
|
||||
}
|
||||
|
||||
export function createLewanDevMock(payload: {
|
||||
step?: string
|
||||
uid?: string
|
||||
orderNo?: string
|
||||
productNo?: string
|
||||
}) {
|
||||
return apiPost<DevMockCreateResult>('/api/v1/admin/dev-mock/lewan', payload)
|
||||
}
|
||||
|
||||
export function createFeifeiDevMock(payload: {
|
||||
step?: string
|
||||
uid?: string
|
||||
orderNo?: string
|
||||
productName?: string
|
||||
productCode?: string
|
||||
h5Url?: string
|
||||
}) {
|
||||
return apiPost<DevMockCreateResult>('/api/v1/admin/dev-mock/feifei', payload)
|
||||
}
|
||||
|
||||
export function buildOpen91QueryDevMock(payload: {
|
||||
orderNo: string
|
||||
execute?: boolean
|
||||
baseUrl?: string
|
||||
}) {
|
||||
return apiPost<DevMockOpen91QueryResult>('/api/v1/admin/dev-mock/open91/query', payload)
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from './platform-config'
|
||||
export * from './kuaishou-industry'
|
||||
export * from './orders'
|
||||
export * from './tasks'
|
||||
export * from './dev-mock'
|
||||
|
||||
@@ -1263,3 +1263,15 @@ select {
|
||||
max-width: 116px;
|
||||
}
|
||||
}
|
||||
|
||||
.dev-mock-pre {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
overflow: auto;
|
||||
max-height: 360px;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
+44
-142
@@ -1,159 +1,61 @@
|
||||
# 快手 Cloud 领取 mock
|
||||
# 开发 Mock(领取 / 91 查单)
|
||||
|
||||
这份文档用于本地/开发环境验证快手 Cloud 客户领取流程,尤其是套餐商品展示和 91 卡券查询返回领取链接。
|
||||
优先使用 **后台 GUI**:登录运营后台 → 左侧 **开发 / 开发 Mock**(仅非 production 环境显示)。
|
||||
|
||||
mock 流程不会调用 cloudtentacles 发货平台,也不会依赖真实快手小店核销 Cookie。它会直接写入开发数据库,生成一条 91 卡券来源订单、履约任务和领取 token。
|
||||
生产镜像默认 `NODE_ENV=production`,接口会 403。本地 docker-compose.dev 默认可用;特殊环境可设 `ENABLE_DEV_MOCK=1`。
|
||||
|
||||
## 适用场景
|
||||
## GUI 能做什么
|
||||
|
||||
- cloudtentacles 账号过期,想先看领取页是否正常。
|
||||
- 验证套餐商品是否能展示多条发货明细。
|
||||
- 验证 91 卡券查询订单接口是否能拿到系统生成的领取链接。
|
||||
- 演示客户从提交核销码到兑换成功的完整页面流程。
|
||||
|
||||
## 生成领取链接
|
||||
|
||||
Docker 开发环境使用下面命令:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run mock:claim -- --step=ticket
|
||||
```
|
||||
|
||||
执行后会输出类似内容:
|
||||
|
||||
```text
|
||||
已生成快手 Cloud 领取 mock 数据:
|
||||
91订单号:MOCK911779929445388
|
||||
商品:套餐_1
|
||||
当前步骤:ticket
|
||||
领取链接:http://221329.cc.cd/#/claim/113bab7dbdbf391d2024e6dfac0af3573a36bc01c629f8f4
|
||||
前端本地链接:http://127.0.0.1:5173/#/claim/113bab7dbdbf391d2024e6dfac0af3573a36bc01c629f8f4
|
||||
|
||||
页面核销码可填:MOCK
|
||||
91 查询接口可用这个订单号测试:npm run mock:open91 -- --mode=query --orderNo=MOCK911779929445388
|
||||
```
|
||||
|
||||
打开 `领取链接` 即可测试。如果当前是 Docker + Caddy 开发环境,优先使用输出里的 `领取链接`;`前端本地链接` 只有在本机直接暴露 Vite 端口时才可用。
|
||||
|
||||
## 页面怎么操作
|
||||
|
||||
默认 `--step=ticket` 会从第 1 步开始:
|
||||
|
||||
1. 打开领取链接。
|
||||
2. 在核销码输入框填 `MOCK`。
|
||||
3. 点击验证核销码。
|
||||
4. 页面会进入扫码绑定步骤,并展示 mock 绑定二维码/链接。
|
||||
5. 点击刷新/确认角色,角色会显示为 `测试角色`、`10001`。
|
||||
6. 点击兑换,系统会模拟发货成功、退号成功、核销成功。
|
||||
|
||||
## 验证 91 查询接口
|
||||
|
||||
脚本生成的订单就是 `91kaquan/kuaishou` 来源订单,所以可以用 91 查询接口验证是否返回领取链接。
|
||||
|
||||
Docker 开发环境中执行:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run mock:open91 -- --baseUrl=http://127.0.0.1:3000 --mode=query --orderNo=MOCK911779929445388
|
||||
```
|
||||
|
||||
正常结果中会看到:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "接口调用成功",
|
||||
"data": {
|
||||
"orderStatus": 20,
|
||||
"failCode": 0,
|
||||
"cards": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`orderStatus: 20` 表示订单已发货,`cards` 里是 91 卡券协议要求的加密卡密内容,解密后会包含领取链接。
|
||||
|
||||
## 常用参数
|
||||
|
||||
### 指定页面步骤
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run mock:claim -- --step=binding
|
||||
```
|
||||
|
||||
`--step` 支持:
|
||||
|
||||
| step | 页面状态 |
|
||||
| 卡片 | 作用 |
|
||||
| --- | --- |
|
||||
| `ticket` | 第 1 步,等待提交核销码。默认值 |
|
||||
| `binding` | 已验证核销码,绑定资源和角色已准备好 |
|
||||
| `confirm` | 已确认角色,等待兑换 |
|
||||
| `result` | 已模拟兑换成功,直接看结果页 |
|
||||
| lewan 领取 | 写库生成 91 订单 + claim,强制 UID 匹配流(不调 CloudTentacles) |
|
||||
| feifei 领取 | 写库生成 feifei 任务 + 本站 claim;H5 带 uid(不调真实 feifei) |
|
||||
| 91 查单 | 生成带签名的 `POST /api/v1/open/91/orders/query`,可本机执行 |
|
||||
|
||||
### 指定 91 商品名和快手小店 ID
|
||||
推荐路径:
|
||||
|
||||
1. lewan 选 `binding` + UID `10001` → 生成 → 打开领取链接 → 确认兑换
|
||||
2. 用同一 orderNo 点「模拟 91 查单」验证 cards / claimUrl
|
||||
|
||||
## CLI(可选)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run mock:claim -- --productNo=套餐_1----3676797936
|
||||
# lewan
|
||||
docker compose -f docker-compose.dev.yml exec -T backend \
|
||||
npm run mock:claim -- --step=binding --uid=10001
|
||||
|
||||
# feifei
|
||||
docker compose -f docker-compose.dev.yml exec -T backend \
|
||||
npm run mock:feifei -- --step=ready --uid=166909256
|
||||
|
||||
# 91 查单(需 KAQUAN91_SECRET)
|
||||
docker compose -f docker-compose.dev.yml exec -T backend \
|
||||
npm run mock:open91 -- --baseUrl=http://127.0.0.1:3000 --mode=query --orderNo=MOCK91...
|
||||
```
|
||||
|
||||
`productNo` 中 `----` 后面的 `3676797936` 是快手小店 ID,只跟核销配置有关;91 卡券订单本身统一使用 `shopId=91kaquan`。
|
||||
## 步骤说明
|
||||
|
||||
### 指定套餐明细
|
||||
### lewan `--step`
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run mock:claim -- --items=910001:套餐商品A:1,910002:套餐商品B:2,910003:套餐商品C:1
|
||||
```
|
||||
| step | 含义 |
|
||||
| --- | --- |
|
||||
| `uid` | 待填 UID(Step1) |
|
||||
| `binding` | 已预填 UID,角色 ID 已匹配(推荐) |
|
||||
| `confirm` | 待确认兑换 |
|
||||
| `result` | 已模拟完成 |
|
||||
|
||||
格式是:
|
||||
兑换走 `context.mock.enabled=true`,不会真实发货。
|
||||
|
||||
```text
|
||||
cloudSkuId:商品名:数量,cloudSkuId:商品名:数量
|
||||
```
|
||||
### feifei `--step`
|
||||
|
||||
不传 `--items` 时,默认生成 3 个套餐商品:
|
||||
| step | 含义 |
|
||||
| --- | --- |
|
||||
| `uid` | 待填 UID |
|
||||
| `ready` | 已填 UID,可打开带 uid 的 H5 |
|
||||
| `completed` / `failed` | 模拟终态 |
|
||||
|
||||
| cloudSkuId | 商品名 | 数量 |
|
||||
| --- | --- | --- |
|
||||
| `910001` | 套餐商品 A | `1` |
|
||||
| `910002` | 套餐商品 B | `2` |
|
||||
## 与真实环境边界
|
||||
|
||||
### 指定 91 订单号
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run mock:claim -- --orderNo=MOCK91TEST001
|
||||
```
|
||||
|
||||
订单号不能重复。如果重复,脚本会拒绝写入,避免覆盖已有测试数据。
|
||||
|
||||
## 本机直连数据库时怎么跑
|
||||
|
||||
如果不是在 Docker 容器里执行,而是在 `apps/backend` 目录直接执行:
|
||||
|
||||
```bash
|
||||
npm run mock:claim -- --step=ticket
|
||||
```
|
||||
|
||||
需要确保 `.env` 里的 `DATABASE_URL` 能从本机访问。例如 Docker Compose 默认给宿主机暴露了 Postgres 端口时,可以使用:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/order_site
|
||||
```
|
||||
|
||||
如果 `.env` 中是 `postgres://...@postgres:5432/...`,这个主机名只能在 Docker 网络内解析,本机直接跑会报 `getaddrinfo ENOTFOUND postgres`。
|
||||
|
||||
## 安全限制
|
||||
|
||||
脚本默认拒绝在 `NODE_ENV=production` 下执行,防止误写生产库。只有明确追加 `--force` 才会继续:
|
||||
|
||||
```bash
|
||||
npm run mock:claim -- --force
|
||||
```
|
||||
|
||||
正常不要在生产环境使用 mock 脚本。
|
||||
|
||||
## 相关文件
|
||||
|
||||
- mock 脚本:`apps/backend/scripts/mock-kuaishou-cloud-claim.ts`
|
||||
- 91 请求脚本:`apps/backend/scripts/mock-open91-order.ts`
|
||||
- 领取服务:`apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts`
|
||||
- 领取页:`apps/frontend/src/pages/claim/ClaimPage.tsx`
|
||||
- 旧 Vue 领取页:`apps/frontend-vue/src/views/claim/kuaishou-cloud/`
|
||||
- Mock **不**替代真实 CloudTentacles / feifei 联调
|
||||
- 91 `create` CLI 仍会走真实商品匹配;造单测领取请用 GUI / `mock:claim` / `mock:feifei`
|
||||
- 写入的是开发库真实行,注意别在生产 volume 上开 Mock
|
||||
|
||||
Reference in New Issue
Block a user