订单mock和领取页面优化
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"db:migrate": "tsx src/db/migrate.ts",
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
"mock:claim": "tsx scripts/mock-kuaishou-cloud-claim.ts",
|
||||||
"mock:open91": "tsx scripts/mock-open91-order.ts",
|
"mock:open91": "tsx scripts/mock-open91-order.ts",
|
||||||
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
|
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, unknown>
|
||||||
|
type DeliveryItem = {
|
||||||
|
cloudSkuId: number
|
||||||
|
cloudSkuName: string
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
|
||||||
|
if (args.help) {
|
||||||
|
printHelp()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProductionLike() && args.force !== true) {
|
||||||
|
console.error('当前是生产环境,已拒绝生成 mock 数据。如确实要执行,请追加 --force。')
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('已生成快手 Cloud 领取 mock 数据:')
|
||||||
|
console.log(` 91订单号:${orderNo}`)
|
||||||
|
console.log(` 商品:${productName}`)
|
||||||
|
console.log(` 当前步骤:${step}`)
|
||||||
|
console.log(` 领取链接:${claimUrl}`)
|
||||||
|
console.log(` 前端本地链接:${buildFrontendClaimUrl(claimToken.token, args.frontendBaseUrl)}`)
|
||||||
|
console.log('')
|
||||||
|
console.log('页面核销码可填:MOCK')
|
||||||
|
console.log(`91 查询接口可用这个订单号测试:npm run mock:open91 -- --mode=query --orderNo=${orderNo}`)
|
||||||
|
} 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: '快手 cloud 履约',
|
||||||
|
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: '快手 cloud 履约',
|
||||||
|
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
|
||||||
|
if (arg === '--help' || arg === '-h') {
|
||||||
|
parsed.help = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
{ cloudSkuId: 910003, cloudSkuName: '套餐商品 C', quantity: 1 },
|
||||||
|
]
|
||||||
|
|
||||||
|
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(`
|
||||||
|
生成快手 Cloud 领取 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
|
||||||
|
|
||||||
|
参数:
|
||||||
|
--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 执行
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -335,7 +335,7 @@ function isOrderItemIdentifierName(value: string, skuCode: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedSkuCode = String(skuCode || '').trim()
|
const normalizedSkuCode = String(skuCode || '').trim()
|
||||||
if (normalizedSkuCode && normalized === normalizedSkuCode) {
|
if (normalizedSkuCode && normalized === normalizedSkuCode && /^\d{8,}$/.test(normalizedSkuCode)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PROJECT_ROOT } from '../../config/runtime.js'
|
|||||||
import { createTaskEvent } from '../../repositories/task-event-repo.js'
|
import { createTaskEvent } from '../../repositories/task-event-repo.js'
|
||||||
import { getTaskById, updateTask, updateTaskStatusIfCurrent } from '../../repositories/task-repo.js'
|
import { getTaskById, updateTask, updateTaskStatusIfCurrent } from '../../repositories/task-repo.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { addHours, nowIso } from '../../utils/time.js'
|
||||||
import {
|
import {
|
||||||
getKuaishouEticketSourceConfig,
|
getKuaishouEticketSourceConfig,
|
||||||
listKuaishouEticketShopConfigs,
|
listKuaishouEticketShopConfigs,
|
||||||
@@ -83,8 +83,9 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const taskContext = parseTaskContext(context.task)
|
||||||
const nextContext = {
|
const nextContext = {
|
||||||
...parseTaskContext(context.task),
|
...taskContext,
|
||||||
kuaishouCloudFulfillment: {
|
kuaishouCloudFulfillment: {
|
||||||
...flow,
|
...flow,
|
||||||
ticket: {
|
ticket: {
|
||||||
@@ -108,30 +109,47 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment)
|
if (isKuaishouCloudMockContext(nextContext)) {
|
||||||
if (preparedFlow.binding.prepareStatus !== 'ready' || !preparedFlow.binding.bindUrl) {
|
const mockFlow = buildMockVerifiedKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment, now)
|
||||||
await prepareKuaishouCloudFulfillmentTask({
|
|
||||||
...context.task,
|
|
||||||
context_json: JSON.stringify(nextContext),
|
|
||||||
}, {
|
|
||||||
source: 'claim_page_ticket_verified',
|
|
||||||
actor: { source: 'claim_page' },
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await updateTask(context.task.id, {
|
await updateTask(context.task.id, {
|
||||||
claim_token: context.claimToken.token,
|
claim_token: context.claimToken.token,
|
||||||
claim_expires_at: context.claimToken.expired_at,
|
claim_expires_at: context.claimToken.expired_at,
|
||||||
context_json: JSON.stringify(nextContext),
|
context_json: JSON.stringify({
|
||||||
|
...nextContext,
|
||||||
|
kuaishouCloudFulfillment: mockFlow,
|
||||||
|
}),
|
||||||
|
claimed_at: context.task.claimed_at || now,
|
||||||
|
task_status: 'waiting_binding',
|
||||||
|
user_action_status: 'pending_claim',
|
||||||
last_error: '',
|
last_error: '',
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
}
|
} else {
|
||||||
|
const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment)
|
||||||
|
if (preparedFlow.binding.prepareStatus !== 'ready' || !preparedFlow.binding.bindUrl) {
|
||||||
|
await prepareKuaishouCloudFulfillmentTask({
|
||||||
|
...context.task,
|
||||||
|
context_json: JSON.stringify(nextContext),
|
||||||
|
}, {
|
||||||
|
source: 'claim_page_ticket_verified',
|
||||||
|
actor: { source: 'claim_page' },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await updateTask(context.task.id, {
|
||||||
|
claim_token: context.claimToken.token,
|
||||||
|
claim_expires_at: context.claimToken.expired_at,
|
||||||
|
context_json: JSON.stringify(nextContext),
|
||||||
|
last_error: '',
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!context.task.claimed_at) {
|
if (!context.task.claimed_at) {
|
||||||
await updateTask(context.task.id, {
|
await updateTask(context.task.id, {
|
||||||
claimed_at: now,
|
claimed_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await createTaskEvent(context.task.id, 'kuaishou_cloud_ticket_verified', {
|
await createTaskEvent(context.task.id, 'kuaishou_cloud_ticket_verified', {
|
||||||
@@ -169,7 +187,10 @@ export async function getKuaishouCloudClaimDetail(token: unknown) {
|
|||||||
const context = await getClaimContext(token)
|
const context = await getClaimContext(token)
|
||||||
let task = context.task
|
let task = context.task
|
||||||
|
|
||||||
if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') {
|
if (
|
||||||
|
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
||||||
|
&& !isKuaishouCloudMockTask(task)
|
||||||
|
) {
|
||||||
task = await syncKuaishouCloudRoleInfo(task) || task
|
task = await syncKuaishouCloudRoleInfo(task) || task
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,12 +361,16 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) {
|
|||||||
return getKuaishouCloudClaimDetail(token)
|
return getKuaishouCloudClaimDetail(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshed = await refreshKuaishouCloudTaskRoleInfo(context.task, {
|
const contextSource = parseTaskContext(context.task)
|
||||||
source: 'claim_page_role_confirm',
|
const mockMode = isKuaishouCloudMockContext(contextSource)
|
||||||
actor: { source: 'claim_page' },
|
const refreshed = mockMode
|
||||||
recordEvent: false,
|
? { task: context.task }
|
||||||
forceProbe: true,
|
: await refreshKuaishouCloudTaskRoleInfo(context.task, {
|
||||||
})
|
source: 'claim_page_role_confirm',
|
||||||
|
actor: { source: 'claim_page' },
|
||||||
|
recordEvent: false,
|
||||||
|
forceProbe: true,
|
||||||
|
})
|
||||||
const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment)
|
const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment)
|
||||||
|
|
||||||
if (flow.ticket.status !== 'verified') {
|
if (flow.ticket.status !== 'verified') {
|
||||||
@@ -415,6 +440,11 @@ export async function redeemKuaishouCloudClaim(token: unknown) {
|
|||||||
return getKuaishouCloudClaimDetail(token)
|
return getKuaishouCloudClaimDetail(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isKuaishouCloudMockTask(lockedTask)) {
|
||||||
|
await completeMockKuaishouCloudClaimTask(lockedTask, now)
|
||||||
|
return getKuaishouCloudClaimDetail(token)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await dispatchKuaishouCloudFulfillmentTask(lockedTask, {
|
await dispatchKuaishouCloudFulfillmentTask(lockedTask, {
|
||||||
source: 'claim_page_redeem',
|
source: 'claim_page_redeem',
|
||||||
@@ -447,3 +477,114 @@ export async function redeemKuaishouCloudClaim(token: unknown) {
|
|||||||
|
|
||||||
return getKuaishouCloudClaimDetail(token)
|
return getKuaishouCloudClaimDetail(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isKuaishouCloudMockTask(task: Partial<TaskRow> | null | undefined) {
|
||||||
|
return isKuaishouCloudMockContext(parseTaskContext(task))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKuaishouCloudMockContext(context: JsonObject = {}) {
|
||||||
|
const flow = context.kuaishouCloudFulfillment
|
||||||
|
&& typeof context.kuaishouCloudFulfillment === 'object'
|
||||||
|
? context.kuaishouCloudFulfillment
|
||||||
|
: {}
|
||||||
|
const mock = flow.mock && typeof flow.mock === 'object' ? flow.mock : context.mock
|
||||||
|
|
||||||
|
return Boolean(mock && typeof mock === 'object' && mock.enabled === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMockVerifiedKuaishouCloudFlow(value: unknown, timestamp: string) {
|
||||||
|
const flow = normalizeKuaishouCloudFlow(value)
|
||||||
|
const source = flow as JsonObject
|
||||||
|
const bindUrl = flow.binding.bindUrl
|
||||||
|
|| `https://example.com/mock-kuaishou-cloud-bind?task=mock&ts=${encodeURIComponent(timestamp)}`
|
||||||
|
const vnPhone = flow.binding.vnPhone || '13800000000'
|
||||||
|
const roleName = flow.binding.roleName || flow.role.name || '测试角色'
|
||||||
|
const roleId = flow.binding.roleId || flow.role.rid || '10001'
|
||||||
|
|
||||||
|
return {
|
||||||
|
...flow,
|
||||||
|
mock: {
|
||||||
|
...(source.mock && typeof source.mock === 'object' ? source.mock : {}),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
binding: {
|
||||||
|
...flow.binding,
|
||||||
|
prepareStatus: 'ready',
|
||||||
|
vnId: flow.binding.vnId || 900001,
|
||||||
|
vnPhone,
|
||||||
|
bindUrl,
|
||||||
|
bindPreparedAt: flow.binding.bindPreparedAt || timestamp,
|
||||||
|
bindExpiresAt: flow.binding.bindExpiresAt || addHours(timestamp, 24),
|
||||||
|
roleName,
|
||||||
|
roleId,
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
...flow.role,
|
||||||
|
status: 'ready',
|
||||||
|
name: roleName,
|
||||||
|
rid: roleId,
|
||||||
|
refreshedAt: flow.role.refreshedAt || timestamp,
|
||||||
|
errorMessage: '',
|
||||||
|
rawInfo: flow.role.rawInfo || {
|
||||||
|
mock: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeMockKuaishouCloudClaimTask(task: TaskRow, timestamp: string) {
|
||||||
|
const taskContext = parseTaskContext(task)
|
||||||
|
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||||||
|
const source = flow as JsonObject
|
||||||
|
const nextFlow = {
|
||||||
|
...flow,
|
||||||
|
mock: {
|
||||||
|
...(source.mock && typeof source.mock === 'object' ? source.mock : {}),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
dispatch: {
|
||||||
|
...flow.dispatch,
|
||||||
|
status: 'success',
|
||||||
|
dispatchAt: timestamp,
|
||||||
|
dispatchBy: {
|
||||||
|
source: 'claim_page_mock',
|
||||||
|
},
|
||||||
|
note: '开发 mock 已模拟发货成功',
|
||||||
|
items: flow.deliveryItems,
|
||||||
|
},
|
||||||
|
returnNumber: {
|
||||||
|
...flow.returnNumber,
|
||||||
|
status: 'success',
|
||||||
|
returnedAt: timestamp,
|
||||||
|
returnedBy: {
|
||||||
|
source: 'claim_page_mock',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
consume: {
|
||||||
|
...flow.consume,
|
||||||
|
status: 'success',
|
||||||
|
consumedAt: timestamp,
|
||||||
|
errorMessage: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTask(task.id, {
|
||||||
|
task_status: 'completed',
|
||||||
|
delivery_status: 'success',
|
||||||
|
result_code: 'mock_success',
|
||||||
|
result_message: '开发 mock 已模拟兑换成功',
|
||||||
|
user_action_status: 'not_required',
|
||||||
|
last_error: '',
|
||||||
|
context_json: JSON.stringify({
|
||||||
|
...taskContext,
|
||||||
|
kuaishouCloudFulfillment: nextFlow,
|
||||||
|
}),
|
||||||
|
redeemed_at: timestamp,
|
||||||
|
updated_at: timestamp,
|
||||||
|
})
|
||||||
|
|
||||||
|
await createTaskEvent(task.id, 'kuaishou_cloud_mock_redeemed', {
|
||||||
|
source: 'claim_page_mock',
|
||||||
|
deliveryItems: flow.deliveryItems,
|
||||||
|
}, timestamp)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import ClaimBindingStep from './components/ClaimBindingStep.vue'
|
|||||||
import ClaimConfirmStep from './components/ClaimConfirmStep.vue'
|
import ClaimConfirmStep from './components/ClaimConfirmStep.vue'
|
||||||
import ClaimHeaderCard from './components/ClaimHeaderCard.vue'
|
import ClaimHeaderCard from './components/ClaimHeaderCard.vue'
|
||||||
import ClaimResultStep from './components/ClaimResultStep.vue'
|
import ClaimResultStep from './components/ClaimResultStep.vue'
|
||||||
import ClaimSummaryCard from './components/ClaimSummaryCard.vue'
|
|
||||||
import ClaimTicketStep from './components/ClaimTicketStep.vue'
|
import ClaimTicketStep from './components/ClaimTicketStep.vue'
|
||||||
import { useKuaishouCloudClaim } from './composables/useKuaishouCloudClaim'
|
import { useKuaishouCloudClaim } from './composables/useKuaishouCloudClaim'
|
||||||
|
|
||||||
@@ -22,6 +21,7 @@ const claim = useKuaishouCloudClaim(() => props.token)
|
|||||||
:order="claim.order.value"
|
:order="claim.order.value"
|
||||||
:product="claim.product.value"
|
:product="claim.product.value"
|
||||||
:current-step="claim.currentStep.value"
|
:current-step="claim.currentStep.value"
|
||||||
|
:progress-text="claim.progressText.value"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="claim.loading.value" class="content-card loading-card">
|
<div v-if="claim.loading.value" class="content-card loading-card">
|
||||||
@@ -35,13 +35,6 @@ const claim = useKuaishouCloudClaim(() => props.token)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="claim.detail.value && claim.flow.value">
|
<template v-else-if="claim.detail.value && claim.flow.value">
|
||||||
<ClaimSummaryCard
|
|
||||||
:progress-text="claim.progressText.value"
|
|
||||||
:role-name="claim.roleName.value"
|
|
||||||
:role-id="claim.roleId.value"
|
|
||||||
:show-role-info="claim.currentStep.value !== 1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ClaimTicketStep
|
<ClaimTicketStep
|
||||||
v-if="claim.currentStep.value === 1"
|
v-if="claim.currentStep.value === 1"
|
||||||
:ticket-code="claim.ticketCode.value"
|
:ticket-code="claim.ticketCode.value"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ defineProps<{
|
|||||||
order: ClaimOrderInfo | null
|
order: ClaimOrderInfo | null
|
||||||
product: ClaimProductInfo | null
|
product: ClaimProductInfo | null
|
||||||
currentStep: number
|
currentStep: number
|
||||||
|
progressText: string
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -19,14 +20,17 @@ defineProps<{
|
|||||||
<ClaimProductItems :product="product" compact />
|
<ClaimProductItems :product="product" compact />
|
||||||
</div>
|
</div>
|
||||||
<div class="step-indicator">
|
<div class="step-indicator">
|
||||||
<div
|
<div class="step-dots">
|
||||||
v-for="step in 4"
|
<div
|
||||||
:key="step"
|
v-for="step in 4"
|
||||||
class="step-dot"
|
:key="step"
|
||||||
:class="{ active: currentStep >= step }"
|
class="step-dot"
|
||||||
>
|
:class="{ active: currentStep >= step }"
|
||||||
{{ step }}
|
>
|
||||||
|
{{ step }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="step-progress-text">{{ progressText }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -41,43 +45,66 @@ defineProps<{
|
|||||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
padding: 24px 28px;
|
padding: 24px 28px;
|
||||||
display: flex;
|
position: relative;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-copy h1 {
|
.header-copy h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
max-width: calc(100% - 220px);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-copy p {
|
.header-copy p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
max-width: calc(100% - 220px);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-copy {
|
.header-copy {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
max-width: calc(100% - 220px);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-indicator {
|
.step-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 28px;
|
||||||
|
right: 28px;
|
||||||
|
max-width: 220px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dots {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.step-progress-text {
|
||||||
|
max-width: 100%;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.step-dot {
|
.step-dot {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
@@ -99,36 +126,47 @@ defineProps<{
|
|||||||
.header-card {
|
.header-card {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-copy h1 {
|
.header-copy h1 {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
line-height: 1.22;
|
line-height: 1.22;
|
||||||
|
max-width: calc(100% - 154px);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-copy p {
|
.header-copy p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
max-width: calc(100% - 154px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
|
max-width: calc(100% - 154px);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-indicator {
|
.step-indicator {
|
||||||
gap: 8px;
|
top: 14px;
|
||||||
|
right: 16px;
|
||||||
|
max-width: 148px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dots {
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-dot {
|
.step-dot {
|
||||||
width: 30px;
|
width: 28px;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-progress-text {
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,13 +176,33 @@ defineProps<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-copy h1 {
|
.header-copy h1 {
|
||||||
|
max-width: calc(100% - 132px);
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-copy p {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
max-width: calc(100% - 132px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator {
|
||||||
|
top: 12px;
|
||||||
|
right: 14px;
|
||||||
|
max-width: 128px;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dots {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.step-dot {
|
.step-dot {
|
||||||
width: 28px;
|
width: 26px;
|
||||||
height: 28px;
|
height: 26px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ const activeGuideNames = ref(['guide'])
|
|||||||
<section class="content-card">
|
<section class="content-card">
|
||||||
<h2>第 1 步:提交核销码</h2>
|
<h2>第 1 步:提交核销码</h2>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
请先从快手小店复制核销码。验证通过后,系统会自动准备 Cloud
|
请您先从快手小店复制核销码。验证通过后,系统会自动准备绑定资源.
|
||||||
绑定资源,不需要再让客服手工点"准备绑定资源"。
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<el-input
|
<el-input
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# 快手 Cloud 领取 mock
|
||||||
|
|
||||||
|
这份文档用于本地/开发环境验证快手 Cloud 客户领取流程,尤其是套餐商品展示和 91 卡券查询返回领取链接。
|
||||||
|
|
||||||
|
mock 流程不会调用 cloudtentacles 发货平台,也不会依赖真实快手小店核销 Cookie。它会直接写入开发数据库,生成一条 91 卡券来源订单、履约任务和领取 token。
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- 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` | 已模拟兑换成功,直接看结果页 |
|
||||||
|
|
||||||
|
### 指定 91 商品名和快手小店 ID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml exec -T backend npm run mock:claim -- --productNo=套餐_1----3676797936
|
||||||
|
```
|
||||||
|
|
||||||
|
`productNo` 中 `----` 后面的 `3676797936` 是快手小店 ID,只跟核销配置有关;91 卡券订单本身统一使用 `shopId=91kaquan`。
|
||||||
|
|
||||||
|
### 指定套餐明细
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
格式是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cloudSkuId:商品名:数量,cloudSkuId:商品名:数量
|
||||||
|
```
|
||||||
|
|
||||||
|
不传 `--items` 时,默认生成 3 个套餐商品:
|
||||||
|
|
||||||
|
| cloudSkuId | 商品名 | 数量 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `910001` | 套餐商品 A | `1` |
|
||||||
|
| `910002` | 套餐商品 B | `2` |
|
||||||
|
| `910003` | 套餐商品 C | `1` |
|
||||||
|
|
||||||
|
### 指定 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/views/claim/kuaishou-cloud/`
|
||||||
Reference in New Issue
Block a user