简化履约匹配并支持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
@@ -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'
}