简化履约匹配并支持mock订单测试

This commit is contained in:
yml
2026-05-27 12:55:44 +08:00
parent e8010728a4
commit dd6776c4e4
21 changed files with 1892 additions and 683 deletions
+1
View File
@@ -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",
+182
View File
@@ -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<string, unknown>
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 回调地址,默认空
`)
}
@@ -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<string, any>
@@ -26,8 +25,6 @@ export async function updateAdminKuaishouCloudFulfillmentConfig(
items: Array.isArray(payload.items) ? payload.items : [],
})
await syncConfiguredFulfillmentBindings()
return {
filePath: getKuaishouCloudFulfillmentFilePath(),
source: mapAdminKuaishouCloudFulfillmentSource(saved),
@@ -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,无法执行快手核销'
@@ -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<string, any>
@@ -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]'
}
@@ -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<TaskRow> | 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()
@@ -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 ||
@@ -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')
})
@@ -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<string, any>
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<CloudtentaclesNameMatchResult | null> {
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<ReturnType<typeof buildCloudtentaclesSourceContext>> => 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
)
}
@@ -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')
})
@@ -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<FulfillmentBindingLike | null>
getFulfillmentProfileByKey?: (profileKey: string) => Promise<FulfillmentBindingLike | null>
createTaskClaimToken?: (taskId: number | string) => Promise<ClaimTokenLike>
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<FulfillmentBindingLike | null>,
): Promise<FulfillmentBindingLike | null> {
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 []
@@ -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<ReturnType<typeof resolveProductMatchRule>>
resolvedSkuCode: string
binding: Awaited<ReturnType<typeof resolveFulfillmentBinding>>
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<string, unknown> {
return Object.prototype.toString.call(value) === '[object Object]'
}
function isOpen91KuaishouOrder(provider: unknown, platform: unknown) {
return String(provider || '').trim() === '91kaquan'
&& String(platform || '').trim() === 'kuaishou'
}
@@ -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
}
}
})
@@ -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<string, any>
@@ -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({
@@ -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)
}
@@ -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<string, any>
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,