diff --git a/.gitignore b/.gitignore index 890bd019..5713aca6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .env +.env copy .env.local .DS_Store diff --git a/apps/backend/package.json b/apps/backend/package.json index b752fd7b..515af1cf 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -9,6 +9,7 @@ "db:migrate": "tsx src/db/migrate.ts", "dev": "tsx watch --clear-screen=false src/index.ts", "format": "prettier --write .", + "mock:open91": "tsx scripts/mock-open91-order.ts", "test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)", "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node dist/index.js", diff --git a/apps/backend/scripts/mock-open91-order.ts b/apps/backend/scripts/mock-open91-order.ts new file mode 100644 index 00000000..77d6fc5e --- /dev/null +++ b/apps/backend/scripts/mock-open91-order.ts @@ -0,0 +1,182 @@ +import crypto from 'node:crypto' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { loadEnvFiles } from '../src/config/runtime-env.js' + +type JsonObject = Record + +const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..') +const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..') + +loadEnvFiles([ + path.join(WORKSPACE_ROOT, '.env'), + path.join(PROJECT_ROOT, '.env'), +]) + +const args = parseArgs(process.argv.slice(2)) + +if (args.help) { + printHelp() + process.exit(0) +} + +const secret = readEnv('KAQUAN91_SECRET') +const version = readEnv('KAQUAN91_VERSION') || '1.0' +const baseUrl = String(args.baseUrl || process.env.OPEN91_MOCK_BASE_URL || 'http://127.0.0.1').replace(/\/$/, '') +const mode = String(args.mode || 'create').trim() + +if (!secret || secret.length !== 32) { + console.error('缺少 32 位 91 密钥:请配置 KAQUAN91_SECRET。') + process.exit(1) +} + +if (!['create', 'query'].includes(mode)) { + console.error('mode 只能是 create 或 query。') + process.exit(1) +} + +const orderNo = String(args.orderNo || `MOCK91${Date.now()}`).trim() +const timestamp = Number(args.timestamp || Math.floor(Date.now() / 1000)) + +const payload = mode === 'query' + ? { + orderNo, + timestamp, + version, + } + : { + buyNum: normalizePositiveInteger(args.buyNum, 1), + callbackUrl: String(args.callbackUrl || ''), + maxAmount: String(args.maxAmount || '0.01'), + orderNo, + productNo: String(args.productNo || '套装-浪漫天命').trim(), + timestamp, + version, + } + +const body = { + ...payload, + sign: signOpen91Payload(payload, secret), +} + +const endpoint = `${baseUrl}/api/v1/open/91/orders/${mode}` + +console.log(`POST ${endpoint}`) +console.log(JSON.stringify(maskPayload(body), null, 2)) + +const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), +}).catch((error) => { + console.error('请求 91 mock 接口失败,请确认后端入口可访问。') + console.error(`当前地址:${endpoint}`) + console.error('Docker 开发环境通常使用 http://127.0.0.1;本地直启后端通常使用 http://127.0.0.1:3000。') + throw error +}) + +const responseText = await response.text() +console.log(`HTTP ${response.status}`) + +try { + console.log(JSON.stringify(JSON.parse(responseText), null, 2)) +} catch { + console.log(responseText) +} + +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 readEnv(...keys: string[]) { + for (const key of keys) { + const value = String(process.env[key] || '').trim() + if (value) return value + } + + return '' +} + +function signOpen91Payload(payload: JsonObject, currentSecret: string) { + return crypto + .createHash('md5') + .update(buildOpen91SignSource(payload, currentSecret), 'utf8') + .digest('hex') + .toUpperCase() +} + +function buildOpen91SignSource(payload: JsonObject, currentSecret: string) { + const queryString = Object.entries(payload) + .filter(([key]) => key !== 'sign') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${stringifySignValue(value)}`) + .join('&') + + return `${currentSecret}${queryString}${currentSecret}` +} + +function stringifySignValue(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (value == null) return '' + return String(value) +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function maskPayload(payload: JsonObject) { + return { + ...payload, + sign: String(payload.sign || '').replace(/^(.{6}).+(.{4})$/, '$1***$2'), + } +} + +function printHelp() { + console.log(` +模拟 91 卡券请求: + + npm run mock:open91 -- --productNo=套装-浪漫天命 + npm run mock:open91 -- --productNo=荣耀勋章礼包(30个) --maxAmount=300 --buyNum=1 + npm run mock:open91 -- --mode=query --orderNo=MOCK911779000000000 + +参数: + --baseUrl 后端地址,默认 http://127.0.0.1 + --mode create 或 query,默认 create + --orderNo 91 订单号,默认自动生成 + --productNo 91 商品名,默认 套装-浪漫天命 + --buyNum 购买数量,默认 1 + --maxAmount 最大金额/正常金额,默认 0.01 + --callbackUrl 回调地址,默认空 +`) +} diff --git a/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts b/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts index cccf5c48..d12a5578 100644 --- a/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts +++ b/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts @@ -3,7 +3,6 @@ import { getKuaishouCloudFulfillmentFilePath, saveKuaishouCloudFulfillmentConfig, } from '../../order/kuaishou-cloud-fulfillment-config-service.js' -import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js' import { mapAdminKuaishouCloudFulfillmentSource } from './cloudtentacles/mappers.js' type JsonObject = Record @@ -26,8 +25,6 @@ export async function updateAdminKuaishouCloudFulfillmentConfig( items: Array.isArray(payload.items) ? payload.items : [], }) - await syncConfiguredFulfillmentBindings() - return { filePath: getKuaishouCloudFulfillmentFilePath(), source: mapAdminKuaishouCloudFulfillmentSource(saved), diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index d5bc92b4..3f052e8e 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -13,6 +13,7 @@ import { getCloudtentaclesBindInfo, } from '../../platforms/cloudtentacles/virtual-number-service.js' import { consumeKuaishouEticket } from '../../platforms/kuaishou-eticket/consume-service.js' +import { isKuaishouEticketMockTicketCode } from '../../platforms/kuaishou-eticket/mock-ticket-service.js' import { getKuaishouEticketSourceConfig, resolveKuaishouEticketShopConfig, @@ -489,6 +490,7 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment( const ticketCode = String(flow.ticket.code || '').trim() const shopId = String(flow.consume.shopId || order?.shop_id || '').trim() const shopName = String(flow.consume.shopName || order?.shop_name || '').trim() + const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode) const eticketSource = getKuaishouEticketSourceConfig() const shopConfig = resolveKuaishouEticketShopConfig({ shopId, @@ -507,6 +509,15 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment( } else if (!ticketCode) { consumeStatus = 'failed' consumeErrorMessage = '客户未提交有效核销码,无法执行快手核销' + } else if (mockTicketCode) { + const consumeResult = await consumeKuaishouEticket({ + eTicketId: ticketCode, + oid: String(flow.ticket.oid || '').trim(), + formToken: String(flow.ticket.formToken || '').trim(), + }) + consumeStatus = consumeResult.consumed ? 'success' : 'failed' + consumedAt = consumeResult.consumed ? now : null + consumeErrorMessage = String(consumeResult.errorMessage || '').trim() } else if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) { consumeStatus = 'failed' consumeErrorMessage = '订单对应快手小店缺少可用 Cookie,无法执行快手核销' diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index 8d5c0328..8f514764 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -1,18 +1,9 @@ import { - getFulfillmentProfileByKey, listFulfillmentProfileRequirements, replaceFulfillmentProfileRequirements, upsertFulfillmentProfile, - upsertSkuFulfillmentBinding, type FulfillmentProfileRequirementInput, } from '../../repositories/fulfillment-profile-repo.js' -import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js' -import { normalizeProductName } from '../order/product-match-service.js' -import { query } from '../../db/client.js' -import { - getKuaishouCloudFulfillmentConfig, - mapKuaishouCloudFulfillmentItemsToBindings, -} from '../order/kuaishou-cloud-fulfillment-config-service.js' import { nowIso } from '../../utils/time.js' type JsonObject = Record @@ -48,8 +39,7 @@ const CORE_PROFILES: CoreProfile[] = [ ] export async function ensureFulfillmentCatalogBootstrapped() { - const profileMap = await ensureCoreProfiles() - await syncConfiguredFulfillmentBindings(profileMap) + await ensureCoreProfiles() } async function ensureCoreProfiles() { @@ -82,82 +72,3 @@ async function ensureCoreProfiles() { return profileMap } - -export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) { - const timestamp = nowIso() - const bindingsToApply = mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()) - - await query('DELETE FROM product_match_rules') - await query('DELETE FROM sku_fulfillment_bindings') - - for (const binding of bindingsToApply) { - const profile = profileMap[binding.profileKey] || await getFulfillmentProfileByKey(binding.profileKey) - if (!profile) { - continue - } - - const bindingConfig = resolveBindingRuntimeConfig(binding) - const matchShopIds = resolveBindingMatchShopIds(binding) - const match: JsonObject = isPlainObject(binding.match) ? binding.match : {} - const externalSkuName = String(match.externalSkuName || '').trim() - const externalItemId = String(match.externalItemId || '').trim() - const externalSkuCode = String(match.externalSkuCode || '').trim() - - for (const matchShopId of matchShopIds) { - await upsertSkuFulfillmentBinding({ - skuCode: binding.skuCode, - provider: binding.provider, - platform: binding.platform, - shopId: matchShopId, - profileId: profile.id, - enabled: binding.enabled, - priority: binding.priority, - configJson: JSON.stringify(bindingConfig), - createdAt: timestamp, - updatedAt: timestamp, - }) - - if (!externalSkuName && !externalItemId && !externalSkuCode) { - continue - } - - await upsertProductMatchRule({ - provider: binding.provider, - platform: binding.platform, - shopId: matchShopId, - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized: normalizeProductName(externalSkuName), - resolvedSkuCode: binding.skuCode, - enabled: binding.enabled, - priority: binding.priority, - configJson: JSON.stringify(match.config || {}), - createdAt: timestamp, - updatedAt: timestamp, - }) - } - } -} - -function resolveBindingMatchShopIds(binding: JsonObject = {}) { - return [String(binding.shopId || '').trim()] -} - -function resolveBindingRuntimeConfig(binding: JsonObject = {}) { - const baseConfig = isPlainObject(binding.config) ? { ...binding.config } : {} - - if (String(binding.provider || '').trim() === '91kaquan' && String(binding.platform || '').trim() === 'kuaishou') { - baseConfig.kuaishouShop = { - ...(isPlainObject(baseConfig.kuaishouShop) ? baseConfig.kuaishouShop : {}), - shopId: String(binding.shopId || '').trim(), - shopName: String(binding.shopName || '').trim(), - } - } - - return baseConfig -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 067bd67d..3d1bda31 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -8,9 +8,12 @@ import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import { getKuaishouEticketSourceConfig, + listKuaishouEticketShopConfigs, resolveKuaishouEticketShopConfig, + type KuaishouEticketShopConfig, } from '../platforms/kuaishou-eticket/source-config-service.js' import { queryKuaishouEticketConsumeDetail } from '../platforms/kuaishou-eticket/consume-service.js' +import { isKuaishouEticketMockTicketCode } from '../platforms/kuaishou-eticket/mock-ticket-service.js' import { dispatchKuaishouCloudFulfillmentTask, maskCode, @@ -48,24 +51,28 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js }) } - const shopId = String(flow.consume.shopId || context.order.shop_id || '').trim() - const shopConfig = resolveKuaishouEticketShopConfig({ - shopId, - shopName: String(context.order.shop_name || '').trim(), - }) - if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) { - throw createHttpError('这笔订单对应的快手小店还没有配置可用 Cookie,请联系客服处理', { - statusCode: 409, - errorCode: 'claim_kuaishou_cloud_shop_cookie_missing', - }) - } + const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode) + let shopId = String(flow.consume.shopId || context.order.shop_id || (mockTicketCode ? 'mock' : '')).trim() + let shopName = String(flow.consume.shopName || context.order.shop_name || '').trim() + let detailResult - const eticketSource = getKuaishouEticketSourceConfig() - const detailResult = await queryKuaishouEticketConsumeDetail({ - baseUrl: eticketSource.baseUrl, - cookie: shopConfig.cookie, - eTicketId: ticketCode, - }) + if (mockTicketCode) { + detailResult = await queryKuaishouEticketConsumeDetail({ + eTicketId: ticketCode, + goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code, + }) + } else { + const matched = await queryKuaishouEticketConsumeDetailWithShopFallback({ + ticketCode, + shopId, + shopName, + fallbackShopName: String(context.order.shop_name || '').trim(), + }) + + detailResult = matched.detailResult + shopId = matched.shopConfig.shopId || shopId + shopName = matched.shopConfig.kshopName || shopName + } if (!detailResult.ok || detailResult.alreadyConsumed || !detailResult.detail) { throw createHttpError(detailResult.errorMessage || '核销码校验失败,请确认是否复制完整', { @@ -130,8 +137,9 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js await createTaskEvent(context.task.id, 'kuaishou_cloud_ticket_verified', { ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode), shopId, - shopName: String(context.order.shop_name || '').trim(), + shopName, goodsTitle: String(detailResult.goods?.itemTitle || '').trim(), + mock: mockTicketCode, }, now) return getKuaishouCloudClaimDetail(token) @@ -190,6 +198,133 @@ function parseTaskContext(task: Partial | null | undefined): JsonObject } } +async function queryKuaishouEticketConsumeDetailWithShopFallback({ + ticketCode, + shopId, + shopName, + fallbackShopName = '', +}: { + ticketCode: string + shopId: string + shopName: string + fallbackShopName?: string +}) { + const eticketSource = getKuaishouEticketSourceConfig() + const shopConfigs = resolveKuaishouEticketDetailCandidateShops({ + shopId, + shopName, + fallbackShopName, + }) + + if (shopConfigs.length === 0) { + throw createHttpError('还没有配置可用的快手小店 Cookie,请联系客服处理', { + statusCode: 409, + errorCode: 'claim_kuaishou_cloud_shop_cookie_missing', + }) + } + + let lastResult: JsonObject | null = null + let lastError: unknown = null + + for (const shopConfig of shopConfigs) { + try { + const detailResult = await queryKuaishouEticketConsumeDetail({ + baseUrl: eticketSource.baseUrl, + cookie: shopConfig.cookie, + eTicketId: ticketCode, + }) + + lastResult = detailResult + if (detailResult.ok || detailResult.alreadyConsumed) { + return { + shopConfig, + detailResult, + } + } + } catch (error) { + lastError = error + } + } + + if (lastResult) { + const fallbackShopConfig = shopConfigs[0] + if (!fallbackShopConfig) { + throw createHttpError('还没有配置可用的快手小店 Cookie,请联系客服处理', { + statusCode: 409, + errorCode: 'claim_kuaishou_cloud_shop_cookie_missing', + }) + } + + return { + shopConfig: fallbackShopConfig, + detailResult: lastResult, + } + } + + throw createHttpError(lastError instanceof Error ? lastError.message : '核销码校验失败,请确认是否复制完整', { + statusCode: 409, + errorCode: 'claim_kuaishou_cloud_ticket_invalid', + }) +} + +function resolveKuaishouEticketDetailCandidateShops({ + shopId, + shopName, + fallbackShopName = '', +}: { + shopId: string + shopName: string + fallbackShopName?: string +}) { + const candidates: KuaishouEticketShopConfig[] = [] + const configuredShop = resolveKuaishouEticketShopConfig({ + shopId, + shopName: shopName || fallbackShopName, + }) + + if (isUsableKuaishouEticketShopConfig(configuredShop)) { + candidates.push(configuredShop) + } + + for (const shopConfig of listKuaishouEticketShopConfigs()) { + if (!isUsableKuaishouEticketShopConfig(shopConfig)) { + continue + } + + if (candidates.some((item) => isSameKuaishouEticketShopConfig(item, shopConfig))) { + continue + } + + candidates.push(shopConfig) + } + + return candidates +} + +function isUsableKuaishouEticketShopConfig( + shopConfig: KuaishouEticketShopConfig | null | undefined, +): shopConfig is KuaishouEticketShopConfig { + return Boolean( + shopConfig && + shopConfig.enabled !== false && + String(shopConfig.cookie || '').trim(), + ) +} + +function isSameKuaishouEticketShopConfig( + left: KuaishouEticketShopConfig, + right: KuaishouEticketShopConfig, +) { + const leftShopId = String(left.shopId || '').trim() + const rightShopId = String(right.shopId || '').trim() + + if (leftShopId && rightShopId) { + return leftShopId === rightShopId + } + + return String(left.kshopName || '').trim() === String(right.kshopName || '').trim() +} + export async function confirmKuaishouCloudClaimRole(token: unknown) { const context = await getClaimContext(token) const now = nowIso() diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts index 0d3dded2..61cf10bd 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -7,6 +7,7 @@ import { notifyKuaishouCloudConsumeFailed } from "../../notification/domain-noti import { useCloudtentaclesSku } from "../../platforms/cloudtentacles/catalog-service.js"; import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js"; import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js"; +import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js"; import { getKuaishouEticketSourceConfig, resolveKuaishouEticketShopConfig, @@ -305,6 +306,7 @@ export async function returnKuaishouCloudFulfillmentTask( const shopName = String( flow.consume.shopName || order?.shop_name || "" ).trim(); + const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode); const eticketSource = getKuaishouEticketSourceConfig(); const shopConfig = resolveKuaishouEticketShopConfig({ shopId, @@ -324,6 +326,15 @@ export async function returnKuaishouCloudFulfillmentTask( } else if (!ticketCode) { consumeStatus = "failed"; consumeErrorMessage = "客户未提交有效核销码,无法执行快手核销"; + } else if (mockTicketCode) { + const consumeResult = await consumeKuaishouEticket({ + eTicketId: ticketCode, + oid: String(flow.ticket.oid || "").trim(), + formToken: String(flow.ticket.formToken || "").trim(), + }); + consumeStatus = consumeResult.consumed ? "success" : "failed"; + consumedAt = consumeResult.consumed ? now : null; + consumeErrorMessage = String(consumeResult.errorMessage || "").trim(); } else if ( !shopConfig || shopConfig.enabled === false || diff --git a/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts b/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts new file mode 100644 index 00000000..7056dc91 --- /dev/null +++ b/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + normalizeCloudtentaclesMatchName, + resolveCloudtentaclesSkuByProductName, +} from './cloudtentacles-name-match-service.js' + +test('normalizeCloudtentaclesMatchName normalizes punctuation and spaces', () => { + assert.equal( + normalizeCloudtentaclesMatchName(' 荣耀勋章礼包(30个) '), + normalizeCloudtentaclesMatchName('荣耀勋章礼包(30个)'), + ) +}) + +test('resolveCloudtentaclesSkuByProductName matches SKU name with logged-in sources', async () => { + const result = await resolveCloudtentaclesSkuByProductName('荣耀勋章礼包(30个)', { + listCloudtentaclesSources: () => ({ + enabled: true, + sources: [ + { + key: 'account-a', + label: '账号 A', + enabled: true, + baseUrl: 'https://cloud.example.com', + deviceId: '-', + deviceType: 0, + }, + ], + }), + getCloudtentaclesSessionStateByKey: () => ({ + token: 'token-a', + baseUrl: 'https://cloud.example.com', + username: 'user-a', + phone: '', + loggedInAt: '2026-05-27T12:00:00.000Z', + deviceId: '-', + deviceType: 0, + }), + listCloudtentaclesSku: async () => ({ + baseUrl: 'https://cloud.example.com', + itemCount: 1, + rawItems: [], + items: [ + { + id: 74, + name: '荣耀勋章礼包(30个)', + inventory: 13837, + price: 300, + }, + ], + }), + }) + + assert.equal(result?.cloudSkuId, 74) + assert.equal(result?.cloudSkuName, '荣耀勋章礼包(30个)') + assert.deepEqual(result?.cloudSourceKeys, ['account-a']) + assert.equal(result?.matchMode, 'cloudtentacles_name') +}) diff --git a/apps/backend/src/services/order/cloudtentacles-name-match-service.ts b/apps/backend/src/services/order/cloudtentacles-name-match-service.ts new file mode 100644 index 00000000..425e0156 --- /dev/null +++ b/apps/backend/src/services/order/cloudtentacles-name-match-service.ts @@ -0,0 +1,165 @@ +import { + listCloudtentaclesSources, +} from '../platforms/cloudtentacles/source-config-service.js' +import { + getCloudtentaclesSessionStateByKey, +} from '../platforms/cloudtentacles/session-state-service.js' +import { + listCloudtentaclesSku, +} from '../platforms/cloudtentacles/catalog-service.js' + +type JsonObject = Record + +type CloudtentaclesSource = { + key?: string + label?: string + enabled?: boolean + baseUrl?: string + deviceId?: string + deviceType?: number +} + +type CloudtentaclesSession = { + token?: string + baseUrl?: string + deviceId?: string + deviceType?: number +} + +type CloudtentaclesSku = { + id?: number + name?: string + inventory?: number + price?: number + [key: string]: unknown +} + +type CloudtentaclesNameMatchDeps = { + listCloudtentaclesSources?: typeof listCloudtentaclesSources + getCloudtentaclesSessionStateByKey?: typeof getCloudtentaclesSessionStateByKey + listCloudtentaclesSku?: typeof listCloudtentaclesSku +} + +export type CloudtentaclesNameMatchResult = { + matchMode: 'cloudtentacles_name' + productName: string + normalizedProductName: string + cloudSkuId: number + cloudSkuName: string + cloudSkuPrice: number + cloudSkuInventory: number + cloudSourceKeys: string[] + resolvedSourceKey: string + skuSnapshot: JsonObject +} + +export async function resolveCloudtentaclesSkuByProductName( + productName: unknown, + deps: CloudtentaclesNameMatchDeps = {}, +): Promise { + const normalizedProductName = normalizeCloudtentaclesMatchName(productName) + if (!normalizedProductName) { + return null + } + + const listSources = deps.listCloudtentaclesSources || listCloudtentaclesSources + const getSessionByKey = deps.getCloudtentaclesSessionStateByKey || getCloudtentaclesSessionStateByKey + const listSku = deps.listCloudtentaclesSku || listCloudtentaclesSku + const sourcesConfig = listSources() + + if (sourcesConfig.enabled === false) { + return null + } + + const sourceContexts = (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : []) + .map((source: CloudtentaclesSource) => buildCloudtentaclesSourceContext(source, getSessionByKey)) + .filter((context): context is NonNullable> => Boolean(context)) + + if (sourceContexts.length === 0) { + return null + } + + const cloudSourceKeys = sourceContexts.map((context) => context.sourceKey) + + for (const context of sourceContexts) { + try { + const skuList = await listSku(context) + const matchedSku = findCloudtentaclesSkuByName( + Array.isArray(skuList.items) ? skuList.items : [], + productName, + ) + + if (!matchedSku) { + continue + } + + const cloudSkuId = Number(matchedSku.id || 0) + const cloudSkuName = String(matchedSku.name || '').trim() + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) { + continue + } + + return { + matchMode: 'cloudtentacles_name', + productName: String(productName || '').trim(), + normalizedProductName, + cloudSkuId, + cloudSkuName, + cloudSkuPrice: Number(matchedSku.price || 0) || 0, + cloudSkuInventory: Number(matchedSku.inventory || 0) || 0, + cloudSourceKeys, + resolvedSourceKey: context.sourceKey, + skuSnapshot: { ...matchedSku }, + } + } catch { + // 当前账号不可用时继续尝试下一个已登录账号。 + } + } + + return null +} + +export function normalizeCloudtentaclesMatchName(value: unknown) { + return String(value || '') + .toLowerCase() + .replace(/[【】\[\]()()]/g, ' ') + .replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function buildCloudtentaclesSourceContext( + source: CloudtentaclesSource, + getSessionByKey: typeof getCloudtentaclesSessionStateByKey, +) { + const sourceKey = String(source.key || '').trim() + if (!sourceKey || source.enabled === false) { + return null + } + + const session = getSessionByKey(sourceKey) as CloudtentaclesSession | null + const token = String(session?.token || '').trim() + if (!token) { + return null + } + + return { + sourceKey, + baseUrl: String(session?.baseUrl || source.baseUrl || '').trim(), + token, + deviceId: String(session?.deviceId || source.deviceId || '-').trim() || '-', + deviceType: Number(session?.deviceType ?? source.deviceType ?? 0), + } +} + +function findCloudtentaclesSkuByName(items: CloudtentaclesSku[], productName: unknown) { + const rawName = String(productName || '').trim() + const normalizedName = normalizeCloudtentaclesMatchName(rawName) + + return ( + items.find((item) => String(item.name || '').trim() === rawName) || + items.find((item) => normalizeCloudtentaclesMatchName(item.name) === normalizedName) || + null + ) +} diff --git a/apps/backend/src/services/order/delivery-task-service.test.ts b/apps/backend/src/services/order/delivery-task-service.test.ts index a0b8d4bf..42d805b8 100644 --- a/apps/backend/src/services/order/delivery-task-service.test.ts +++ b/apps/backend/src/services/order/delivery-task-service.test.ts @@ -113,3 +113,85 @@ test('syncDeliveryTasksForOrderWithDeps prepares kuaishou cloud task with claim }, ]) }) + +test('syncDeliveryTasksForOrderWithDeps creates kuaishou cloud task from cloudtentacles name match snapshot', async () => { + const createdTasks = [] + const updates = [] + + const dynamicOrderItems = [ + { + id: 21, + order_id: 10, + sku_code: '套装-浪漫天命', + sku_name: '套装-浪漫天命', + quantity: 1, + spec_json: '{}', + item_snapshot_json: JSON.stringify({ + matchMode: 'cloudtentacles_name', + cloudtentacles: { + cloudSkuId: 28, + cloudSkuName: '套装-浪漫天命', + cloudSourceKeys: ['account-a', 'account-b'], + }, + }), + }, + ] + + const result = await syncDeliveryTasksForOrderWithDeps(paidOrder, dynamicOrderItems, { + listTasksByOrderId: async () => [], + resolveFulfillmentBinding: async () => null, + getFulfillmentProfileByKey: async () => ({ + id: 2, + profile_key: 'kuaishou_ct_assisted', + name: '快手 cloud 履约', + executor_key: 'kuaishou_ct_assisted', + requires_claim: false, + auto_dispatch: false, + config_json: '{}', + }), + createTask: async (input) => { + createdTasks.push(input) + return { + id: 31, + order_id: input.orderId, + order_item_id: input.orderItemId, + task_status: input.taskStatus, + executor_key: input.executorKey, + requires_claim: input.requiresClaim, + claim_token: '', + primary_claim_token_id: null, + last_error: '', + context_json: input.contextJson, + } + }, + createTaskClaimToken: async () => ({ + token: 'claim-token', + expired_at: '2026-04-15T12:00:00.000Z', + }), + updateTask: async (taskId, patch) => { + updates.push({ taskId, patch }) + return { id: taskId, ...patch } + }, + nowIso: () => '2026-04-14T12:03:00.000Z', + randomId: () => 'DT-DYNAMIC', + }) + + assert.equal(createdTasks.length, 1) + assert.equal(createdTasks[0].executorKey, 'kuaishou_ct_assisted') + assert.equal(createdTasks[0].profileId, 2) + assert.equal(createdTasks[0].taskStatus, 'pending_binding_prepare') + + const context = JSON.parse(createdTasks[0].contextJson) + assert.equal(context.kuaishouCloudFulfillment.binding.skuId, 28) + assert.equal(context.kuaishouCloudFulfillment.binding.skuName, '套装-浪漫天命') + assert.deepEqual(context.kuaishouCloudFulfillment.binding.cloudSourceKeys, ['account-a', 'account-b']) + assert.deepEqual(context.kuaishouCloudFulfillment.deliveryItems, [ + { + cloudSkuId: 28, + cloudSkuName: '套装-浪漫天命', + quantity: 1, + }, + ]) + assert.equal(result[0]?.claim_token, 'claim-token') + assert.equal(updates[0]?.patch.claim_token, 'claim-token') +}) diff --git a/apps/backend/src/services/order/delivery-task-service.ts b/apps/backend/src/services/order/delivery-task-service.ts index 45c177d3..bcd76edf 100644 --- a/apps/backend/src/services/order/delivery-task-service.ts +++ b/apps/backend/src/services/order/delivery-task-service.ts @@ -1,5 +1,6 @@ import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js' import { + getFulfillmentProfileByKey, resolveFulfillmentBinding, } from '../../repositories/fulfillment-profile-repo.js' import { createTaskClaimToken } from '../claim/claim-service.js' @@ -44,6 +45,7 @@ type DeliveryTaskDeps = { platform?: string shopId?: string }) => Promise + getFulfillmentProfileByKey?: (profileKey: string) => Promise createTaskClaimToken?: (taskId: number | string) => Promise notifyTaskAutoManualReview?: (payload: { task: unknown @@ -83,6 +85,7 @@ export async function syncDeliveryTasksForOrderWithDeps( listTasksByOrderId: listTasks = listTasksByOrderId, updateTask: updateDeliveryTask = updateTask, resolveFulfillmentBinding: resolveBinding = resolveFulfillmentBinding, + getFulfillmentProfileByKey: getProfileByKey = getFulfillmentProfileByKey, createTaskClaimToken: createClaimToken = createTaskClaimToken, notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview, nowIso: getNowIso = nowIso, @@ -122,10 +125,11 @@ export async function syncDeliveryTasksForOrderWithDeps( shopId: order.shop_id, }) - if (!binding) { + const profile = binding || await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) + + if (!profile) { continue } - const profile = binding const quantity = Math.max(1, Number(item.quantity || 1)) const fulfillmentConfig = parseJsonObject(profile.config_json) const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles) @@ -391,6 +395,62 @@ function parseJsonObject(value: unknown): JsonObject { } } +async function resolveDynamicCloudtentaclesProfile( + item: OrderItemRow, + getProfileByKey: (profileKey: string) => Promise, +): Promise { + const snapshot = parseJsonObject(item.item_snapshot_json) + const cloudtentacles = parseJsonObject(snapshot.cloudtentacles) + const cloudSkuId = Number(cloudtentacles.cloudSkuId || 0) || 0 + const cloudSkuName = String(cloudtentacles.cloudSkuName || item.sku_name || '').trim() + const cloudSourceKeys = normalizeStringArray(cloudtentacles.cloudSourceKeys) + + if (!cloudSkuId || !cloudSkuName || cloudSourceKeys.length === 0) { + return null + } + + const profile = await getProfileByKey('kuaishou_ct_assisted') + if (!profile) { + return null + } + + return { + ...profile, + profile_id: Number(profile.id || profile.profile_id || 0), + profile_key: 'kuaishou_ct_assisted', + profile_name: String(profile.profile_name || profile.name || '快手 cloud 履约').trim(), + executor_key: 'kuaishou_ct_assisted', + requires_claim: false, + auto_dispatch: false, + config_json: { + flowType: 'kuaishou_cloud_fulfillment', + configId: `cloudtentacles_name:${cloudSkuId}`, + cloudtentacles: { + cloudSourceKeys, + skuId: cloudSkuId, + skuName: cloudSkuName, + deliveryItems: [ + { + cloudSkuId, + cloudSkuName, + quantity: 1, + }, + ], + vnKey: '1', + autoBuyEnabled: true, + minAssetReserve: 0, + autoReturnNumberAfterDispatch: true, + }, + kuaishouConsume: { + shopId: '', + shopName: '', + autoConsumeAfterDispatch: false, + }, + notes: '91卡券商品名自动匹配 cloudtentacles 商品', + }, + } +} + function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return [] diff --git a/apps/backend/src/services/order/product-match-service.ts b/apps/backend/src/services/order/product-match-service.ts index b4ab6a93..7a453db9 100644 --- a/apps/backend/src/services/order/product-match-service.ts +++ b/apps/backend/src/services/order/product-match-service.ts @@ -1,5 +1,9 @@ import { resolveFulfillmentBinding } from '../../repositories/fulfillment-profile-repo.js' import { resolveProductMatchRule } from '../../repositories/product-match-rule-repo.js' +import { + resolveCloudtentaclesSkuByProductName, + type CloudtentaclesNameMatchResult, +} from './cloudtentacles-name-match-service.js' export type FulfillmentItem = { itemId?: string @@ -34,6 +38,7 @@ type FulfillmentItemCandidate = { matchedRule: Awaited> resolvedSkuCode: string binding: Awaited> + cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null isConfigured: boolean } @@ -74,14 +79,17 @@ export async function resolveOrderItemForFulfillment({ externalSkuNameNormalized, matchedRule, binding, + cloudtentaclesNameMatch, } = candidate const resolvedSkuCode = pickFirstNonEmpty([ + cloudtentaclesNameMatch?.cloudSkuName, matchedRule?.resolved_sku_code, externalSkuCode, externalItemId, ]) const resolvedSkuName = pickFirstNonEmpty([ + cloudtentaclesNameMatch?.cloudSkuName, readConfigValue(matchedRule?.config_json, 'resolvedSkuName'), readConfigValue(matchedRule?.config_json, 'internalProductName'), item.skuName, @@ -99,6 +107,21 @@ export async function resolveOrderItemForFulfillment({ matchedProductRuleBy: String(matchedRule?.matched_by || '').trim(), matchedFulfillmentBindingId: binding ? Number(binding.id) : null, matchedFulfillmentProfileKey: String(binding?.profile_key || '').trim(), + matchMode: cloudtentaclesNameMatch?.matchMode || String(matchedRule?.matched_by || '').trim(), + cloudtentacles: cloudtentaclesNameMatch + ? { + matchMode: cloudtentaclesNameMatch.matchMode, + productName: cloudtentaclesNameMatch.productName, + normalizedProductName: cloudtentaclesNameMatch.normalizedProductName, + cloudSkuId: cloudtentaclesNameMatch.cloudSkuId, + cloudSkuName: cloudtentaclesNameMatch.cloudSkuName, + cloudSkuPrice: cloudtentaclesNameMatch.cloudSkuPrice, + cloudSkuInventory: cloudtentaclesNameMatch.cloudSkuInventory, + cloudSourceKeys: cloudtentaclesNameMatch.cloudSourceKeys, + resolvedSourceKey: cloudtentaclesNameMatch.resolvedSourceKey, + skuSnapshot: cloudtentaclesNameMatch.skuSnapshot, + } + : null, isConfigured: candidate.isConfigured, } @@ -182,6 +205,27 @@ async function resolveConfiguredItemCandidate({ let matchedRule = null let binding = null + if (isOpen91KuaishouOrder(provider, platform)) { + const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName) + const resolvedSkuCode = pickFirstNonEmpty([ + cloudtentaclesNameMatch?.cloudSkuName, + externalSkuCode, + externalItemId, + ]) + + return { + externalItemId, + externalSkuCode, + externalSkuName, + externalSkuNameNormalized, + matchedRule: null, + resolvedSkuCode, + binding: null, + cloudtentaclesNameMatch, + isConfigured: Boolean(cloudtentaclesNameMatch), + } + } + for (const candidateShopId of shopIdCandidates) { const candidateRule = await resolveProductMatchRule({ provider, @@ -228,6 +272,7 @@ async function resolveConfiguredItemCandidate({ matchedRule, resolvedSkuCode, binding, + cloudtentaclesNameMatch: null, isConfigured: Boolean(binding), } } @@ -268,3 +313,8 @@ function safeParseJson(rawValue: unknown): unknown { function isPlainObject(value: unknown): value is Record { return Object.prototype.toString.call(value) === '[object Object]' } + +function isOpen91KuaishouOrder(provider: unknown, platform: unknown) { + return String(provider || '').trim() === '91kaquan' + && String(platform || '').trim() === 'kuaishou' +} diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.ts b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.ts index 555b154d..8fb34abe 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.ts @@ -2,8 +2,10 @@ import test from 'node:test' import assert from 'node:assert/strict' import { + consumeKuaishouEticket, mapKuaishouEticketDetailResult, normalizeKuaishouEticketApiResult, + queryKuaishouEticketConsumeDetail, resolveKuaishouEticketConsumePayload, } from './consume-service.js' @@ -70,3 +72,50 @@ test('resolveKuaishouEticketConsumePayload falls back to detail result', () => { formToken: '1777716306154', }) }) + +test('queryKuaishouEticketConsumeDetail accepts mock ticket code outside production', async () => { + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + try { + const result = await queryKuaishouEticketConsumeDetail({ + eTicketId: 'MOCK-CLAIM-001', + goodsTitle: '套装-浪漫天命', + }) + + assert.equal(result.ok, true) + assert.equal(result.eTicketId, 'MOCK-CLAIM-001') + assert.equal(result.detail?.leftCount, 1) + assert.equal(result.goods?.itemTitle, '套装-浪漫天命') + } finally { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = originalNodeEnv + } + } +}) + +test('consumeKuaishouEticket consumes mock ticket code outside production', async () => { + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + try { + const result = await consumeKuaishouEticket({ + eTicketId: 'MOCK-CLAIM-002', + oid: 'MOCK-OID-002', + formToken: 'MOCK-FORM-002', + }) + + assert.equal(result.consumed, true) + assert.equal(result.eTicketId, 'MOCK-CLAIM-002') + assert.equal(result.oid, 'MOCK-OID-002') + assert.equal(result.formToken, 'MOCK-FORM-002') + } finally { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = originalNodeEnv + } + } +}) diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts index 328886e2..6102f9ad 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts @@ -1,6 +1,11 @@ import { createHttpError } from '../../../utils/http.js' import { kuaishouEticketRequest } from './http-client.js' import { resolveKuaishouEticketConfig } from './helpers.js' +import { + buildMockKuaishouEticketConsumeResult, + buildMockKuaishouEticketDetailResult, + isKuaishouEticketMockTicketCode, +} from './mock-ticket-service.js' type JsonObject = Record @@ -18,6 +23,14 @@ type KuaishouEticketDetailResult = { } export async function queryKuaishouEticketConsumeDetail(payload: JsonObject = {}) { + if (isKuaishouEticketMockTicketCode(payload.eTicketId)) { + return buildMockKuaishouEticketDetailResult({ + baseUrl: String(payload.baseUrl || '').trim(), + eTicketId: String(payload.eTicketId || '').trim(), + goodsTitle: String(payload.goodsTitle || '').trim(), + }) + } + const context = resolveKuaishouEticketActionContext(payload) const response = await kuaishouEticketRequest(context.detailPath, { baseUrl: context.baseUrl, @@ -35,6 +48,17 @@ export async function queryKuaishouEticketConsumeDetail(payload: JsonObject = {} } export async function consumeKuaishouEticket(payload: JsonObject = {}) { + if (isKuaishouEticketMockTicketCode(payload.eTicketId)) { + return buildMockKuaishouEticketConsumeResult({ + baseUrl: String(payload.baseUrl || '').trim(), + eTicketId: String(payload.eTicketId || '').trim(), + oid: String(payload.oid || '').trim(), + formToken: String(payload.formToken || '').trim(), + num: payload.num, + storeId: String(payload.storeId || '').trim(), + }) + } + const context = resolveKuaishouEticketActionContext(payload) const detailResult = shouldResolveConsumeDetail(payload) ? await queryKuaishouEticketConsumeDetail({ diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/mock-ticket-service.ts b/apps/backend/src/services/platforms/kuaishou-eticket/mock-ticket-service.ts new file mode 100644 index 00000000..4db1dc6c --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-eticket/mock-ticket-service.ts @@ -0,0 +1,123 @@ +import process from 'node:process' + +import { isProductionLike } from '../../../config/runtime-validation.js' + +type RuntimeEnvironment = { + NODE_ENV?: string +} + +type MockTicketDetailOptions = { + eTicketId: string + baseUrl?: string + oid?: string + formToken?: string + leftCount?: number + goodsTitle?: string +} + +type MockTicketConsumeOptions = { + eTicketId: string + baseUrl?: string + oid?: string + formToken?: string + num?: number + storeId?: string +} + +export function isKuaishouEticketMockTicketCode( + ticketCode: unknown, + env: RuntimeEnvironment = process.env, +): boolean { + const normalized = String(ticketCode || '').trim().toUpperCase() + + if (!normalized || isProductionLike(env)) { + return false + } + + return normalized === 'MOCK' || normalized.startsWith('MOCK-') || normalized.startsWith('MOCK_') +} + +export function buildMockKuaishouEticketDetailResult(options: MockTicketDetailOptions) { + const eTicketId = String(options.eTicketId || '').trim() + const oid = String(options.oid || `MOCK-OID-${eTicketId}`).trim() + const formToken = String(options.formToken || `MOCK-FORM-${eTicketId}`).trim() + const leftCount = normalizePositiveInteger(options.leftCount, 1) + const goodsTitle = String(options.goodsTitle || 'MOCK 快手商品').trim() + + return { + baseUrl: String(options.baseUrl || 'mock://kuaishou-eticket').trim(), + eTicketId, + ok: true, + alreadyConsumed: false, + result: 1, + errorMessage: '', + requestId: `mock-${Date.now()}`, + serverTimestamp: new Date().toISOString(), + detail: { + uid: 'mock', + fulfillDetailId: `MOCK-FULFILL-${eTicketId}`, + sellerId: 'mock', + formToken, + validEndTime: '', + validStartTime: '', + leftReverseCount: 0, + eTicketId, + oid, + totalCount: leftCount, + leftCount, + status: 'MOCK', + }, + goods: { + itemId: 'mock', + itemPicUrl: '', + itemTitle: goodsTitle, + price: '', + skuDesc: goodsTitle, + skuId: 'mock', + }, + raw: { + mock: true, + }, + } +} + +export function buildMockKuaishouEticketConsumeResult(options: MockTicketConsumeOptions) { + const eTicketId = String(options.eTicketId || '').trim() + const oid = String(options.oid || `MOCK-OID-${eTicketId}`).trim() + const formToken = String(options.formToken || `MOCK-FORM-${eTicketId}`).trim() + const num = normalizePositiveInteger(options.num, 1) + + return { + baseUrl: String(options.baseUrl || 'mock://kuaishou-eticket').trim(), + eTicketId, + oid, + formToken, + num, + storeId: String(options.storeId || '0').trim() || '0', + ok: true, + consumed: true, + alreadyConsumed: false, + result: 1, + errorMessage: '', + requestId: `mock-${Date.now()}`, + serverTimestamp: new Date().toISOString(), + detail: { + eTicketId, + oid, + formToken, + leftCount: num, + }, + raw: { + mock: true, + }, + } +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const normalized = Number(value) + if (!Number.isFinite(normalized) || normalized <= 0) { + return fallback + } + + return Math.floor(normalized) +} diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index f46a801c..0a66dae9 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -10,6 +10,7 @@ import { import { normalizeOpen91CreatePayload } from '../../open-91/payload.js' import { buildOpen91OutTradeNo } from '../../open-91/response.js' import { createHttpError } from '../../../utils/http.js' +import { parseAmountToFen } from '../../../utils/money.js' import { nowIso } from '../../../utils/time.js' import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js' @@ -21,6 +22,7 @@ type JsonObject = Record export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObject = {}) { const normalized = normalizeOpen91CreatePayload(payload) const productNo = normalized.productNo + const maxAmountFen = parseAmountToFen(normalized.maxAmount) return { provider: OPEN_91_PROVIDER, @@ -33,7 +35,7 @@ export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObj buyerId: '', buyerName: '', receiverContact: '', - totalAmount: 0, + totalAmount: maxAmountFen, currency: 'CNY', paidAt: nowIso(), rawPayload: { @@ -54,11 +56,14 @@ export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObj source: OPEN_91_PROVIDER, orderNo: normalized.orderNo, productNo, + maxAmount: normalized.maxAmount, }, snapshot: { source: OPEN_91_PROVIDER, orderNo: normalized.orderNo, productNo, + maxAmount: normalized.maxAmount, + maxAmountFen, externalItemId: productNo, externalSkuCode: productNo, externalSkuName: productNo, @@ -95,7 +100,7 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config: buyerId: '', buyerName: '', receiverContact: '', - totalAmount: 0, + totalAmount: event.totalAmount, currency: 'CNY', rawPayloadJson, paidAt: existing?.paid_at || now, diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css index 820b48e5..88a7ccb4 100644 --- a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css @@ -3,561 +3,198 @@ gap: var(--space-4); } -.section-card { - border-radius: var(--radius-lg); - border: 1px solid var(--border-default); - background: var(--bg-surface); -} - -.section-card :deep(.el-card__header) { - background: var(--bg-surface); - border-bottom: 1px solid var(--border-default); - padding: var(--space-4) var(--space-5); -} - -.section-card :deep(.el-card__body) { - padding: var(--space-5); -} - -.card-header { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.card-header--split { - flex-direction: row; - justify-content: space-between; - align-items: flex-start; -} - -.card-title { - display: block; - font-size: var(--text-lg); - font-weight: 600; - color: var(--text-primary); - margin-bottom: var(--space-1); -} - -.card-desc { - display: block; - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: 1.5; -} - -.overview-card { - display: grid; - gap: var(--space-4); -} - -.overview-file code { - color: var(--text-primary); - word-break: break-all; -} - -.overview-stats { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: var(--space-3); -} - -.overview-stat { +.fulfillment-simple-page { min-width: 0; - padding: var(--space-3); +} + +.fulfillment-overview { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(120px, auto); + gap: var(--space-3); + align-items: stretch; +} + +.metric-card { + display: grid; + gap: var(--space-2); + min-width: 0; + padding: var(--space-4); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-surface); } -.overview-stat :deep(.el-statistic__head) { - margin-bottom: 6px; +.metric-card span { color: var(--text-secondary); - font-size: var(--text-xs); - font-weight: 700; + font-size: var(--text-sm); } -.overview-stat :deep(.el-statistic__content) { +.metric-card strong { color: var(--text-primary); font-size: var(--text-2xl); - font-weight: 700; + line-height: 1.1; } -.overview-notes { +.overview-actions { display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.lookup-panel { - display: grid; - gap: var(--space-3); - padding: var(--space-4); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - background: var(--bg-subtle); -} - -.lookup-toolbar { - display: grid; - grid-template-columns: - minmax(260px, 1.4fr) - minmax(132px, 0.36fr) - minmax(150px, 0.44fr) - minmax(210px, 0.5fr); - gap: var(--space-3) var(--space-4); - align-items: end; -} - -.lookup-field { - min-width: 0; -} - -.lookup-field--number :deep(.el-input-number) { - width: 100%; -} - -.lookup-action { - width: 100%; - min-height: 40px; -} - -.lookup-note { - margin-top: 0; -} - -.lookup-note :deep(.el-alert__content) { - min-width: 0; -} - -.lookup-note :deep(.el-alert__title) { - line-height: 1.6; -} - -.rule-action-row { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: var(--space-3); + justify-content: flex-end; align-items: center; } -.rule-enabled-control { - display: flex; - justify-content: flex-end; +.fulfillment-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: var(--space-4); + align-items: start; } -.binding-card { - margin-top: var(--space-3); - background: var(--bg-surface); +.source-panel, +.catalog-panel { + min-width: 0; border: 1px solid var(--border-default); border-radius: var(--radius-md); -} - -.binding-card :deep(.el-card__header) { - padding: var(--space-3) var(--space-4); background: var(--bg-surface); - border-bottom: 1px solid var(--border-default); } -.binding-card :deep(.el-card__body) { +.source-panel { + display: grid; + gap: var(--space-3); + padding: var(--space-4); +} + +.catalog-panel { display: grid; gap: var(--space-4); padding: var(--space-4); } -.binding-card--invalid { - border-color: var(--color-danger); - box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.08); -} - -.binding-header { - display: flex; - justify-content: space-between; - gap: var(--space-3); - align-items: flex-start; -} - -.binding-summary { +.section-head { display: grid; - gap: var(--space-2); -} - -.binding-title-row { - display: flex; - gap: var(--space-2); - align-items: center; - flex-wrap: wrap; - color: var(--text-primary); -} - -.binding-subtle, -.cell-subtle { - color: var(--text-secondary); -} - -.cell-subtle { - display: block; - font-size: var(--text-xs); - line-height: 1.6; -} - -.cell-stack { - display: flex; - flex-direction: column; gap: var(--space-1); } -.binding-collapsed-preview { +.section-head h3 { + margin: 0; + color: var(--text-primary); + font-size: var(--text-lg); + font-weight: 700; +} + +.section-head p { + margin: 0; color: var(--text-secondary); font-size: var(--text-sm); } -.mapping-editor { +.source-list { display: grid; - grid-template-columns: minmax(0, 1fr) 74px minmax(0, 1fr); - gap: var(--space-3); - align-items: stretch; + gap: var(--space-2); } -.mapping-panel { - min-width: 0; - background: var(--bg-surface); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); -} - -.mapping-panel :deep(.el-card__header) { - padding: var(--space-3) var(--space-4); - background: var(--bg-surface); - border-bottom: 1px solid var(--border-default); -} - -.mapping-panel :deep(.el-card__body) { - padding: var(--space-4); -} - -.mapping-panel-head { +.source-item { display: flex; + width: 100%; + min-width: 0; justify-content: space-between; gap: var(--space-3); - align-items: baseline; -} - -.mapping-panel-head strong { - color: var(--text-primary); -} - -.mapping-panel-head span { - color: var(--text-secondary); - font-size: var(--text-xs); -} - -.mapping-grid, -.mapping-extra { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-4); -} - -.mapping-extra { - padding-top: var(--space-3); - border-top: 1px dashed var(--border-default); -} - -.mapping-arrow { - display: grid; - place-items: center; - color: var(--color-primary); - font-size: var(--text-xs); - font-weight: 700; -} - -.mapping-arrow span { - display: inline-flex; align-items: center; - justify-content: center; - min-height: 30px; - min-width: 54px; - padding: 0 10px; - border-radius: var(--radius-full); - background: var(--bg-surface); + padding: var(--space-3); border: 1px solid var(--border-default); -} - -.rule-editor { - display: grid; - gap: var(--space-3); -} - -.rule-editor-main { - display: grid; - grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr); - gap: var(--space-3); - align-items: stretch; -} - -.rule-panel { - display: grid; - align-content: start; - gap: var(--space-4); - min-width: 0; - padding: var(--space-4); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); + border-radius: var(--radius-sm); background: var(--bg-surface); + color: inherit; + text-align: left; + cursor: pointer; } -.rule-panel--strategy { +.source-item:hover, +.source-item--active { + border-color: var(--color-primary); background: var(--bg-subtle); } -.rule-panel-head { - display: flex; - justify-content: space-between; - gap: var(--space-3); - align-items: center; +.source-item span { + display: grid; + gap: 4px; min-width: 0; } -.rule-panel-head > span { - color: var(--text-secondary); - font-size: var(--text-xs); - line-height: 1.5; - text-align: right; +.source-item strong, +.source-item small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.rule-panel-title { - display: flex; - align-items: center; - gap: var(--space-2); - min-width: 0; +.source-item strong { color: var(--text-primary); } -.rule-panel-title strong { - font-size: var(--text-base); - font-weight: 700; +.source-item small { + color: var(--text-secondary); } -.rule-step { - display: inline-grid; - place-items: center; - width: 30px; - height: 24px; +.catalog-toolbar { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: flex-start; +} + +.match-checker { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto; + gap: var(--space-3); + align-items: center; + padding: var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--bg-subtle); +} + +.sku-table { + width: 100%; +} + +.sku-name-cell { + display: grid; + gap: 4px; + min-width: 0; +} + +.sku-name-cell strong { + color: var(--text-primary); +} + +.sku-name-cell small { + color: var(--text-secondary); + line-height: 1.5; +} + +.match-name { + display: inline-block; + max-width: 100%; + padding: 2px 6px; border-radius: var(--radius-sm); background: var(--bg-subtle); - color: var(--text-secondary); - font-size: var(--text-xs); - font-weight: 700; -} - -.rule-panel--strategy .rule-step { - background: var(--bg-surface); -} - -.mapping-grid--core { - gap: var(--space-3) var(--space-4); -} - -.strategy-grid { - display: grid; - grid-template-columns: minmax(150px, 0.24fr) minmax(150px, 0.24fr) minmax(0, 1fr); - gap: var(--space-4); - align-items: end; -} - -.strategy-checks { - display: flex; - flex-wrap: wrap; - gap: var(--space-2) var(--space-4); - min-height: 32px; - align-items: center; -} - -.advanced-fields { - border: 1px dashed var(--border-default); - border-radius: var(--radius-md); - background: var(--bg-surface); -} - -.advanced-fields summary { - display: flex; - justify-content: space-between; - gap: var(--space-3); - align-items: center; - padding: var(--space-3) var(--space-4); color: var(--text-primary); - cursor: pointer; - list-style: none; + white-space: normal; + word-break: break-all; } -.advanced-fields summary::-webkit-details-marker { - display: none; -} - -.advanced-fields summary span { - font-weight: 700; -} - -.advanced-fields summary small { - color: var(--text-secondary); - font-size: var(--text-xs); - line-height: 1.5; - text-align: right; -} - -.advanced-fields summary::after { - content: '展开'; - flex: 0 0 auto; - min-width: 40px; - color: var(--color-primary); - font-size: var(--text-xs); - font-weight: 700; - text-align: right; -} - -.advanced-fields[open] summary { - border-bottom: 1px dashed var(--border-default); -} - -.advanced-fields[open] summary::after { - content: '收起'; -} - -.advanced-grid { - padding: var(--space-4); -} - -.delivery-items-editor { - display: grid; - gap: var(--space-3); - padding: var(--space-4); -} - -.delivery-items-head { - display: flex; - justify-content: space-between; - gap: var(--space-3); - align-items: center; - color: var(--text-primary); - font-weight: 700; -} - -.delivery-item-row { - display: grid; - grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.7fr) 132px 48px; - gap: var(--space-3); - align-items: center; -} - -.delivery-item-sku, -.delivery-item-name, -.delivery-item-count { - width: 100%; -} - -.field-wide { - grid-column: 1 / -1; -} - -.field-help { - display: block; - margin-top: var(--space-2); - color: var(--text-secondary); - font-size: var(--text-xs); - line-height: 1.6; -} - -.text-input { - width: 100%; -} - -.textarea-input { - min-height: 72px; -} - -.admin-panel :deep(.el-form-item) { - margin-bottom: 0; -} - -.admin-panel :deep(.el-form-item__label) { - color: var(--text-secondary); - font-size: var(--text-sm); - font-weight: 600; - line-height: 1.5; - margin-bottom: var(--space-2); -} - -.binding-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: var(--space-3); - align-items: center; - padding-top: var(--space-3); - border-top: 1px solid var(--border-default); -} - -.data-table { - margin-top: var(--space-4); -} - -.data-table :deep(.el-table__header th) { - background: var(--bg-surface); - color: var(--text-secondary); - font-weight: 600; - font-size: var(--text-sm); -} - -.data-table :deep(.el-table__body td) { - padding: var(--space-3) var(--space-2); -} - -@media (max-width: 900px) { - .card-header--split, - .rule-action-row, - .binding-header, - .mapping-panel-head { - display: grid; - grid-template-columns: 1fr; - align-items: stretch; - } - - .overview-stats, - .lookup-toolbar, - .rule-editor-main, - .strategy-grid, - .mapping-editor, - .delivery-item-row, - .mapping-grid, - .mapping-extra { +@media (max-width: 960px) { + .fulfillment-overview, + .fulfillment-layout, + .match-checker { grid-template-columns: 1fr; } - .rule-panel-head, - .advanced-fields summary { - display: grid; - grid-template-columns: 1fr; - align-items: start; + .overview-actions, + .catalog-toolbar { + justify-content: stretch; } - .rule-panel-head > span, - .advanced-fields summary small { - text-align: left; - } - - .mapping-arrow { - display: none; - } - - .section-card :deep(.el-card__header), - .section-card :deep(.el-card__body) { - padding: var(--space-4); - } - - .binding-card :deep(.el-card__header), - .binding-card :deep(.el-card__body) { - padding: var(--space-3); + .catalog-toolbar { + flex-direction: column; } } diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue index 7f569b8d..bf0cab04 100644 --- a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue @@ -1,110 +1,312 @@