diff --git a/apps/backend/package.json b/apps/backend/package.json index df0a4c0f..80dbf35c 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/scripts/mock-feifei-claim.ts b/apps/backend/scripts/mock-feifei-claim.ts new file mode 100644 index 00000000..c8c2912a --- /dev/null +++ b/apps/backend/scripts/mock-feifei-claim.ts @@ -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 + +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 +} diff --git a/apps/backend/scripts/mock-kuaishou-cloud-claim.ts b/apps/backend/scripts/mock-kuaishou-cloud-claim.ts index b98341a7..2c4665c8 100644 --- a/apps/backend/scripts/mock-kuaishou-cloud-claim.ts +++ b/apps/backend/scripts/mock-kuaishou-cloud-claim.ts @@ -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 -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() - - 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 打开开关) `) } diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 6b9861f4..1e12d1d1 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -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)); diff --git a/apps/backend/src/routes/admin/dev-mock.ts b/apps/backend/src/routes/admin/dev-mock.ts new file mode 100644 index 00000000..7e344c36 --- /dev/null +++ b/apps/backend/src/routes/admin/dev-mock.ts @@ -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 diff --git a/apps/backend/src/services/dev-mock/dev-mock-service.ts b/apps/backend/src/services/dev-mock/dev-mock-service.ts new file mode 100644 index 00000000..e2022b15 --- /dev/null +++ b/apps/backend/src/services/dev-mock/dev-mock-service.ts @@ -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 { + 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 { + 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) : {} + 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, 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() +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 88594d70..bf9c0614 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> }> } /> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index fc8a8a74..504a3d20 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -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(() => { const operationItems: MenuProps['items'] = [ { key: '/admin/dashboard', icon: , 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: , label: '开发 Mock' }, + ], + }) + } + if (role === 'admin') { items.push( { @@ -80,7 +101,7 @@ export default function AdminLayout() { } return items - }, [role]) + }, [devMockEnabled, role]) async function submitLogout() { try { diff --git a/apps/frontend/src/pages/admin/AdminDevMockPage.tsx b/apps/frontend/src/pages/admin/AdminDevMockPage.tsx new file mode 100644 index 00000000..5a9507fc --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminDevMockPage.tsx @@ -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(null) + const [lastQuery, setLastQuery] = useState(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 ( +
+ +
+ ) + } + + if (statusQuery.error || !enabled) { + return ( +
+ + +
+ ) + } + + return ( +
+ + NODE_ENV={status?.nodeEnv || '-'} + + } + /> + + } + message="Mock 会写入开发库真实订单/任务,但 lewan 兑换走 mock 成功路径,feifei 不调用真实平台下单。" + description="建议 UID 与默认角色一致:lewan 默认 10001,feifei 默认 166909256。" + /> + + + + +
lewanMutation.mutate(values)} + > + + + + + + + + + + +
+
+ + + + +
feifeiMutation.mutate(values)} + > + + + + + + + + + + + + + +
+
+ + + + +
+ open91Mutation.mutate({ + orderNo: values.orderNo, + execute: values.execute === '1', + }) + } + > + + + + +