整理后台平台配置服务目录结构
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
// @ts-check
|
||||
|
||||
import { getCloudtentaclesSourceConfig } from '../../platforms/cloudtentacles/source-config-service.js'
|
||||
import { getCloudtentaclesSessionState } from '../../platforms/cloudtentacles/session-state-service.js'
|
||||
import { pickFirstNonEmpty, resolveCloudtentaclesAdminContext } from './context.js'
|
||||
|
||||
const DEFAULT_CLOUDTENTACLES_BASE_URL = 'https://123.207.217.176'
|
||||
|
||||
export function resolveAdminCloudtentaclesCredentialPayload(
|
||||
payload = {},
|
||||
options = {},
|
||||
) {
|
||||
const savedSource = /** @type {Record<string, unknown>} */ (
|
||||
options.savedSource || getCloudtentaclesSourceConfig()
|
||||
)
|
||||
const defaultBaseUrl = String(options.defaultBaseUrl || DEFAULT_CLOUDTENTACLES_BASE_URL).trim()
|
||||
|
||||
return {
|
||||
baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, defaultBaseUrl]),
|
||||
username: pickFirstNonEmpty([payload.username, savedSource.username]),
|
||||
password: pickFirstNonEmpty([payload.password, savedSource.password]),
|
||||
phone: pickFirstNonEmpty([payload.phone, savedSource.phone]),
|
||||
deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, '-']),
|
||||
deviceType: payload.deviceType ?? savedSource.deviceType,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAdminCloudtentaclesSessionPayload(
|
||||
payload = {},
|
||||
options = {},
|
||||
) {
|
||||
const savedSource = /** @type {Record<string, unknown>} */ (
|
||||
options.savedSource || getCloudtentaclesSourceConfig()
|
||||
)
|
||||
const persistedSession = /** @type {Record<string, unknown>} */ (
|
||||
options.persistedSession || getCloudtentaclesSessionState()
|
||||
)
|
||||
|
||||
return resolveCloudtentaclesAdminContext(payload, {
|
||||
savedSource,
|
||||
persistedSession,
|
||||
defaultBaseUrl: options.defaultBaseUrl || DEFAULT_CLOUDTENTACLES_BASE_URL,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
resolveAdminCloudtentaclesCredentialPayload,
|
||||
resolveAdminCloudtentaclesSessionPayload,
|
||||
} from './cloudtentacles.js'
|
||||
|
||||
test('resolveAdminCloudtentaclesCredentialPayload merges payload and saved source defaults', () => {
|
||||
assert.deepEqual(
|
||||
resolveAdminCloudtentaclesCredentialPayload(
|
||||
{
|
||||
baseUrl: ' https://override.example.com ',
|
||||
phone: ' 13800138000 ',
|
||||
deviceId: ' custom-device ',
|
||||
},
|
||||
{
|
||||
savedSource: {
|
||||
baseUrl: 'https://saved.example.com',
|
||||
username: 'saved-user',
|
||||
password: 'saved-pass',
|
||||
phone: '13900000000',
|
||||
deviceId: 'saved-device',
|
||||
deviceType: 2,
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
baseUrl: 'https://override.example.com',
|
||||
username: 'saved-user',
|
||||
password: 'saved-pass',
|
||||
phone: '13800138000',
|
||||
deviceId: 'custom-device',
|
||||
deviceType: 2,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveAdminCloudtentaclesSessionPayload merges payload source and persisted session defaults', () => {
|
||||
assert.deepEqual(
|
||||
resolveAdminCloudtentaclesSessionPayload(
|
||||
{
|
||||
token: ' payload-token ',
|
||||
},
|
||||
{
|
||||
savedSource: {
|
||||
baseUrl: 'https://saved.example.com',
|
||||
deviceId: 'saved-device',
|
||||
deviceType: 3,
|
||||
},
|
||||
persistedSession: {
|
||||
token: 'session-token',
|
||||
deviceId: 'session-device',
|
||||
deviceType: 5,
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
baseUrl: 'https://saved.example.com',
|
||||
token: 'payload-token',
|
||||
deviceId: 'saved-device',
|
||||
deviceType: 3,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
// @ts-check
|
||||
|
||||
export function pickFirstNonEmpty(values) {
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function resolveKuaishouEticketAdminContext(
|
||||
payload = {},
|
||||
options = {},
|
||||
) {
|
||||
const savedSource = /** @type {Record<string, unknown>} */ (options.savedSource || {})
|
||||
const findShopConfig = /** @type {(shopId: string, source: Record<string, unknown>) => unknown} */ (
|
||||
options.findShopConfig || (() => null)
|
||||
)
|
||||
const getFirstAvailableShop = /** @type {(source: Record<string, unknown>) => unknown} */ (
|
||||
options.getFirstAvailableShop || (() => null)
|
||||
)
|
||||
const defaultBaseUrl = String(options.defaultBaseUrl || 'https://s.kwaixiaodian.com').trim()
|
||||
const requestedShopId = String(payload.shopId || '').trim()
|
||||
const configuredShop = /** @type {Record<string, unknown> | null} */ (
|
||||
requestedShopId
|
||||
? findShopConfig(requestedShopId, savedSource)
|
||||
: getFirstAvailableShop(savedSource)
|
||||
)
|
||||
|
||||
return {
|
||||
baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, defaultBaseUrl]),
|
||||
cookie: pickFirstNonEmpty([payload.cookie, configuredShop?.cookie]),
|
||||
shopId: pickFirstNonEmpty([requestedShopId, configuredShop?.shopId]),
|
||||
shop: configuredShop,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCloudtentaclesAdminContext(
|
||||
payload = {},
|
||||
options = {},
|
||||
) {
|
||||
const savedSource = /** @type {Record<string, unknown>} */ (options.savedSource || {})
|
||||
const persistedSession = /** @type {Record<string, unknown>} */ (options.persistedSession || {})
|
||||
const defaultBaseUrl = String(options.defaultBaseUrl || 'https://123.207.217.176').trim()
|
||||
return {
|
||||
baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, defaultBaseUrl]),
|
||||
token: pickFirstNonEmpty([payload.token, persistedSession.token]),
|
||||
deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, persistedSession.deviceId, '-']),
|
||||
deviceType: payload.deviceType ?? savedSource.deviceType ?? persistedSession.deviceType,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCloudtentaclesCredentialContextChanged(current = {}, next = {}) {
|
||||
return (
|
||||
String(current.baseUrl || '').trim() !== String(next.baseUrl || '').trim()
|
||||
|| String(current.username || '').trim() !== String(next.username || '').trim()
|
||||
|| String(current.phone || '').trim() !== String(next.phone || '').trim()
|
||||
|| String(current.deviceId || '').trim() !== String(next.deviceId || '').trim()
|
||||
|| Number(current.deviceType || 0) !== Number(next.deviceType || 0)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
hasCloudtentaclesCredentialContextChanged,
|
||||
isPlainObject,
|
||||
pickFirstNonEmpty,
|
||||
resolveCloudtentaclesAdminContext,
|
||||
resolveKuaishouEticketAdminContext,
|
||||
} from './context.js'
|
||||
|
||||
test('pickFirstNonEmpty returns first trimmed non-empty string value', () => {
|
||||
assert.equal(pickFirstNonEmpty(['', ' ', ' value ', 'fallback']), 'value')
|
||||
assert.equal(pickFirstNonEmpty([null, undefined, 0, '']), '')
|
||||
})
|
||||
|
||||
test('isPlainObject only accepts non-array objects', () => {
|
||||
assert.equal(isPlainObject({ a: 1 }), true)
|
||||
assert.equal(isPlainObject([]), false)
|
||||
assert.equal(isPlainObject(null), false)
|
||||
assert.equal(isPlainObject('x'), false)
|
||||
})
|
||||
|
||||
test('resolveKuaishouEticketAdminContext prefers explicit payload values', () => {
|
||||
const context = resolveKuaishouEticketAdminContext(
|
||||
{
|
||||
baseUrl: ' https://override.example.com ',
|
||||
shopId: ' shop-2 ',
|
||||
cookie: ' cookie-2 ',
|
||||
},
|
||||
{
|
||||
savedSource: { baseUrl: 'https://saved.example.com' },
|
||||
findShopConfig(requestedShopId) {
|
||||
assert.equal(requestedShopId, 'shop-2')
|
||||
return { shopId: 'shop-2', cookie: 'saved-cookie-2' }
|
||||
},
|
||||
getFirstAvailableShop() {
|
||||
throw new Error('should not use first available shop')
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.deepEqual(context, {
|
||||
baseUrl: 'https://override.example.com',
|
||||
cookie: 'cookie-2',
|
||||
shopId: 'shop-2',
|
||||
shop: { shopId: 'shop-2', cookie: 'saved-cookie-2' },
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveKuaishouEticketAdminContext falls back to first available shop', () => {
|
||||
const context = resolveKuaishouEticketAdminContext(
|
||||
{},
|
||||
{
|
||||
savedSource: { baseUrl: ' https://saved.example.com ' },
|
||||
getFirstAvailableShop() {
|
||||
return { shopId: 'shop-1', cookie: 'cookie-1' }
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.deepEqual(context, {
|
||||
baseUrl: 'https://saved.example.com',
|
||||
cookie: 'cookie-1',
|
||||
shopId: 'shop-1',
|
||||
shop: { shopId: 'shop-1', cookie: 'cookie-1' },
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveCloudtentaclesAdminContext merges payload source and persisted session', () => {
|
||||
const context = resolveCloudtentaclesAdminContext(
|
||||
{
|
||||
token: ' token-1 ',
|
||||
deviceType: 3,
|
||||
},
|
||||
{
|
||||
savedSource: {
|
||||
baseUrl: ' https://saved.example.com ',
|
||||
deviceId: ' device-saved ',
|
||||
deviceType: 1,
|
||||
},
|
||||
persistedSession: {
|
||||
token: 'token-persisted',
|
||||
deviceId: 'device-persisted',
|
||||
deviceType: 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.deepEqual(context, {
|
||||
baseUrl: 'https://saved.example.com',
|
||||
token: 'token-1',
|
||||
deviceId: 'device-saved',
|
||||
deviceType: 3,
|
||||
})
|
||||
})
|
||||
|
||||
test('hasCloudtentaclesCredentialContextChanged detects normalized credential changes', () => {
|
||||
assert.equal(
|
||||
hasCloudtentaclesCredentialContextChanged(
|
||||
{ baseUrl: ' https://a ', username: 'u1', phone: '13812345678', deviceId: 'd1', deviceType: 1 },
|
||||
{ baseUrl: 'https://a', username: 'u1', phone: '13812345678', deviceId: 'd1', deviceType: 1 },
|
||||
),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
hasCloudtentaclesCredentialContextChanged(
|
||||
{ baseUrl: 'https://a', username: 'u1', phone: '13812345678', deviceId: 'd1', deviceType: 1 },
|
||||
{ baseUrl: 'https://b', username: 'u1', phone: '13812345678', deviceId: 'd1', deviceType: 1 },
|
||||
),
|
||||
true,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
// @ts-check
|
||||
|
||||
import { normalizeAgisoMessageTemplate } from '../../platforms/agiso/xianyu/message-service.js'
|
||||
import { maskSecret } from './mappers.js'
|
||||
|
||||
export function mapAdminAgisoMessagingDefaults(defaults = {}) {
|
||||
return {
|
||||
messageTemplate: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()),
|
||||
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
|
||||
String(defaults.autoDeliveryMessageTemplate || '').trim(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminAgisoShopConfigItem(shopId, config = {}) {
|
||||
return {
|
||||
shopId,
|
||||
shopName: String(config.shopName || '').trim(),
|
||||
accessToken: String(config.accessToken || '').trim(),
|
||||
accessTokenMasked: maskSecret(config.accessToken),
|
||||
enabled: typeof config.enabled === 'boolean' ? config.enabled : null,
|
||||
messageTemplate: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()),
|
||||
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
|
||||
String(config.autoDeliveryMessageTemplate || '').trim(),
|
||||
),
|
||||
appSecretConfigured: Boolean(String(config.appSecret || '').trim()),
|
||||
apiVersion: String(config.apiVersion || '').trim(),
|
||||
sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOptionalStringField(target, key, source) {
|
||||
if (!source || typeof source[key] !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const value = String(source[key] || '').trim()
|
||||
if (value) {
|
||||
target[key] = normalizeAgisoMessageTemplate(value)
|
||||
return
|
||||
}
|
||||
|
||||
delete target[key]
|
||||
}
|
||||
|
||||
export function mapAdminFulfillmentBindingConfigItem(item) {
|
||||
const match = item?.match || {}
|
||||
return {
|
||||
provider: String(item?.provider || '').trim(),
|
||||
platform: String(item?.platform || '').trim(),
|
||||
shopId: String(item?.shopId || '').trim(),
|
||||
shopName: String(item?.shopName || '').trim(),
|
||||
khhaoShopId: String(item?.khhaoShopId || '').trim(),
|
||||
skuCode: String(item?.skuCode || '').trim(),
|
||||
skuName: String(item?.skuName || '').trim(),
|
||||
profileKey: String(item?.profileKey || '').trim(),
|
||||
enabled: item?.enabled !== false,
|
||||
priority: Number(item?.priority || 100),
|
||||
config: item?.config || {},
|
||||
match: {
|
||||
externalSkuCode: String(match.externalSkuCode || '').trim(),
|
||||
externalItemId: String(match.externalItemId || '').trim(),
|
||||
externalSkuName: String(match.externalSkuName || '').trim(),
|
||||
config: match.config || {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFulfillmentBindingMatchShopId({
|
||||
provider = '',
|
||||
platform = '',
|
||||
shopId = '',
|
||||
khhaoShopId = '',
|
||||
} = {}) {
|
||||
if (String(provider || '').trim() === 'khhao' && String(platform || '').trim() === 'kuaishou') {
|
||||
return String(khhaoShopId || shopId || '').trim()
|
||||
}
|
||||
|
||||
return String(shopId || '').trim()
|
||||
}
|
||||
|
||||
export function matchesObservedProduct(binding, observed) {
|
||||
const provider = String(binding?.provider || '').trim()
|
||||
const platform = String(binding?.platform || '').trim()
|
||||
const shopId = String(binding?.shopId || '').trim()
|
||||
const match = binding?.match || {}
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
|
||||
if (provider && provider !== String(observed?.provider || '').trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (platform && platform !== String(observed?.platform || '').trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (shopId && shopId !== String(observed?.shopId || '').trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
(externalSkuCode && externalSkuCode === String(observed?.externalSkuCode || '').trim())
|
||||
|| (externalItemId && externalItemId === String(observed?.externalItemId || '').trim())
|
||||
|| (externalSkuName && externalSkuName === String(observed?.externalSkuName || '').trim())
|
||||
)
|
||||
}
|
||||
|
||||
export function findMatchingObservedBinding(bindings, observed) {
|
||||
return (Array.isArray(bindings) ? bindings : []).find((binding) => matchesObservedProduct(binding, observed)) || null
|
||||
}
|
||||
|
||||
export function mapAdminObservedProductItem(item, bindings = []) {
|
||||
const matchedBinding = findMatchingObservedBinding(bindings, item)
|
||||
|
||||
return {
|
||||
provider: String(item?.provider || '').trim(),
|
||||
platform: String(item?.platform || '').trim(),
|
||||
shopId: String(item?.shopId || '').trim(),
|
||||
shopName: String(item?.shopName || '').trim(),
|
||||
externalItemId: String(item?.externalItemId || '').trim(),
|
||||
externalSkuCode: String(item?.externalSkuCode || '').trim(),
|
||||
externalSkuName: String(item?.externalSkuName || '').trim(),
|
||||
latestSeenAt: item?.latestSeenAt || null,
|
||||
orderItemCount: Number(item?.orderItemCount || 0),
|
||||
configured: Boolean(matchedBinding),
|
||||
matchedBinding: matchedBinding
|
||||
? {
|
||||
skuCode: String(matchedBinding.skuCode || '').trim(),
|
||||
skuName: String(matchedBinding.skuName || '').trim(),
|
||||
profileKey: String(matchedBinding.profileKey || '').trim(),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
applyOptionalStringField,
|
||||
findMatchingObservedBinding,
|
||||
mapAdminAgisoMessagingDefaults,
|
||||
mapAdminAgisoShopConfigItem,
|
||||
mapAdminFulfillmentBindingConfigItem,
|
||||
mapAdminObservedProductItem,
|
||||
matchesObservedProduct,
|
||||
resolveFulfillmentBindingMatchShopId,
|
||||
} from './domain.js'
|
||||
|
||||
test('mapAdminAgisoMessagingDefaults normalizes escaped newlines', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminAgisoMessagingDefaults({
|
||||
messageTemplate: ' hello\\nworld ',
|
||||
autoDeliveryMessageTemplate: ' done\\nnow ',
|
||||
}),
|
||||
{
|
||||
messageTemplate: 'hello\nworld',
|
||||
autoDeliveryMessageTemplate: 'done\nnow',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('mapAdminAgisoShopConfigItem masks secrets and reports configured flags', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminAgisoShopConfigItem('1001', {
|
||||
shopName: ' 店铺A ',
|
||||
accessToken: 'abcdef1234567890',
|
||||
enabled: true,
|
||||
messageTemplate: ' hi\\nall ',
|
||||
autoDeliveryMessageTemplate: ' ok ',
|
||||
appSecret: 'secret-1',
|
||||
apiVersion: ' v2 ',
|
||||
sendMessageEndpoint: ' /send ',
|
||||
}),
|
||||
{
|
||||
shopId: '1001',
|
||||
shopName: '店铺A',
|
||||
accessToken: 'abcdef1234567890',
|
||||
accessTokenMasked: 'abcdef****567890',
|
||||
enabled: true,
|
||||
messageTemplate: 'hi\nall',
|
||||
autoDeliveryMessageTemplate: 'ok',
|
||||
appSecretConfigured: true,
|
||||
apiVersion: 'v2',
|
||||
sendMessageEndpoint: '/send',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('applyOptionalStringField updates and clears normalized template fields', () => {
|
||||
const target = { messageTemplate: 'old' }
|
||||
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' next\\nline ' })
|
||||
assert.deepEqual(target, { messageTemplate: 'next\nline' })
|
||||
|
||||
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' ' })
|
||||
assert.deepEqual(target, {})
|
||||
})
|
||||
|
||||
test('mapAdminFulfillmentBindingConfigItem normalizes binding shape', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminFulfillmentBindingConfigItem({
|
||||
provider: ' agiso ',
|
||||
platform: ' xianyu ',
|
||||
shopId: ' shop-1 ',
|
||||
skuCode: ' sku-1 ',
|
||||
priority: '80',
|
||||
match: {
|
||||
externalSkuCode: ' ext-1 ',
|
||||
},
|
||||
}),
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '',
|
||||
khhaoShopId: '',
|
||||
skuCode: 'sku-1',
|
||||
skuName: '',
|
||||
profileKey: '',
|
||||
enabled: true,
|
||||
priority: 80,
|
||||
config: {},
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveFulfillmentBindingMatchShopId prefers khhaoShopId for khhao kuaishou bindings', () => {
|
||||
assert.equal(
|
||||
resolveFulfillmentBindingMatchShopId({
|
||||
provider: 'khhao',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-a',
|
||||
khhaoShopId: 'khhao-1',
|
||||
}),
|
||||
'khhao-1',
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
resolveFulfillmentBindingMatchShopId({
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-a',
|
||||
khhaoShopId: 'khhao-1',
|
||||
}),
|
||||
'shop-a',
|
||||
)
|
||||
})
|
||||
|
||||
test('matchesObservedProduct honors provider platform shop and external fields', () => {
|
||||
const binding = {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
match: {
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
},
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
matchesObservedProduct(binding, {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
matchesObservedProduct(binding, {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-2',
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||
const bindings = [
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'sku-1',
|
||||
skuName: 'SKU 1',
|
||||
profileKey: 'profile-1',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
},
|
||||
]
|
||||
const observed = {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺1',
|
||||
externalItemId: '',
|
||||
externalSkuCode: 'ext-1',
|
||||
externalSkuName: '礼包1',
|
||||
latestSeenAt: '2026-05-04T10:00:00.000Z',
|
||||
orderItemCount: 2,
|
||||
}
|
||||
|
||||
assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0])
|
||||
assert.deepEqual(mapAdminObservedProductItem(observed, bindings), {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺1',
|
||||
externalItemId: '',
|
||||
externalSkuCode: 'ext-1',
|
||||
externalSkuName: '礼包1',
|
||||
latestSeenAt: '2026-05-04T10:00:00.000Z',
|
||||
orderItemCount: 2,
|
||||
configured: true,
|
||||
matchedBinding: {
|
||||
skuCode: 'sku-1',
|
||||
skuName: 'SKU 1',
|
||||
profileKey: 'profile-1',
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
// @ts-check
|
||||
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { formatFenToAmount } from '../../../utils/money.js'
|
||||
import { isPlainObject, pickFirstNonEmpty } from './context.js'
|
||||
import { mapAdminObservedProductItem } from './domain.js'
|
||||
|
||||
export function normalizeFulfillmentLookupPayload(payload = {}) {
|
||||
const provider = String(payload.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(payload.platform || 'xianyu').trim() || 'xianyu'
|
||||
const shopId = String(payload.shopId || '').trim()
|
||||
const platformOrderId = String(payload.platformOrderId || '').trim()
|
||||
|
||||
if (!shopId) {
|
||||
throw createHttpError('请先填写店铺 ID', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_missing_shop_id',
|
||||
})
|
||||
}
|
||||
|
||||
if (!platformOrderId) {
|
||||
throw createHttpError('请先填写平台订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_missing_platform_order_id',
|
||||
})
|
||||
}
|
||||
|
||||
if (provider !== 'agiso' || platform !== 'xianyu') {
|
||||
throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_platform_not_supported',
|
||||
})
|
||||
}
|
||||
|
||||
return { provider, platform, shopId, platformOrderId }
|
||||
}
|
||||
|
||||
export function resolveFulfillmentLookupDetail(detailResult, platformOrderId) {
|
||||
const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {}
|
||||
const items = Array.isArray(detail.items) ? detail.items : []
|
||||
|
||||
if (items.length > 0) {
|
||||
return { detail, items }
|
||||
}
|
||||
|
||||
const detailReason = String(detailResult?.reason || '').trim()
|
||||
const detailMessage = String(detailResult?.errorMessage || '').trim()
|
||||
let message = detailMessage
|
||||
|
||||
if (!message && detailReason === 'missing_config') {
|
||||
message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址'
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
message = `未查询到订单 ${platformOrderId} 的商品明细`
|
||||
}
|
||||
|
||||
throw createHttpError(message, {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_fulfillment_lookup_order_items_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
export function buildFulfillmentLookupResult({
|
||||
provider = '',
|
||||
platform = '',
|
||||
shopId = '',
|
||||
platformOrderId = '',
|
||||
detail = /** @type {Record<string, unknown>} */ ({}),
|
||||
detailResult = /** @type {Record<string, unknown>} */ ({}),
|
||||
bindings = [],
|
||||
fallbackShopName = '',
|
||||
} = {}) {
|
||||
const resolvedShopName = pickFirstNonEmpty([detail.shopName, fallbackShopName])
|
||||
const items = Array.isArray(detail.items) ? detail.items : []
|
||||
|
||||
return {
|
||||
order: {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: resolvedShopName,
|
||||
platformOrderId,
|
||||
buyerName: String(detail.buyerName || '').trim(),
|
||||
totalAmountFen: Number(detail.totalAmount || 0),
|
||||
totalAmount: formatFenToAmount(detail.totalAmount),
|
||||
paidAt: detail.paidAt || null,
|
||||
enriched: Boolean(detailResult?.enriched),
|
||||
enrichReason: String(detailResult?.reason || '').trim(),
|
||||
errorMessage: String(detailResult?.errorMessage || '').trim(),
|
||||
},
|
||||
items: items.map((item, index) => {
|
||||
const observed = {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: resolvedShopName,
|
||||
externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]),
|
||||
externalSkuCode: pickFirstNonEmpty([
|
||||
item?.externalSkuCode,
|
||||
item?.skuCode,
|
||||
item?.externalItemId,
|
||||
item?.itemId,
|
||||
]),
|
||||
externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]),
|
||||
latestSeenAt: null,
|
||||
orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1),
|
||||
}
|
||||
|
||||
return {
|
||||
lineId: [
|
||||
platformOrderId,
|
||||
index + 1,
|
||||
observed.externalSkuCode || 'na',
|
||||
observed.externalItemId || 'na',
|
||||
].join(':'),
|
||||
itemTitle: pickFirstNonEmpty([
|
||||
item?.skuName,
|
||||
item?.externalSkuName,
|
||||
item?.externalSkuCode,
|
||||
item?.externalItemId,
|
||||
]),
|
||||
quantity: observed.orderItemCount,
|
||||
...mapAdminObservedProductItem(observed, bindings),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
buildFulfillmentLookupResult,
|
||||
normalizeFulfillmentLookupPayload,
|
||||
resolveFulfillmentLookupDetail,
|
||||
} from './fulfillment.js'
|
||||
|
||||
test('normalizeFulfillmentLookupPayload validates required fields and defaults provider/platform', () => {
|
||||
assert.deepEqual(
|
||||
normalizeFulfillmentLookupPayload({
|
||||
shopId: ' shop-1 ',
|
||||
platformOrderId: ' order-1 ',
|
||||
}),
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
platformOrderId: 'order-1',
|
||||
},
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => normalizeFulfillmentLookupPayload({ platformOrderId: 'order-1' }),
|
||||
/请先填写店铺 ID/,
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveFulfillmentLookupDetail maps missing config into readable error', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveFulfillmentLookupDetail(
|
||||
{
|
||||
reason: 'missing_config',
|
||||
parsed: { items: [] },
|
||||
},
|
||||
'order-1',
|
||||
),
|
||||
/当前店铺缺少订单详情查询配置/,
|
||||
)
|
||||
})
|
||||
|
||||
test('buildFulfillmentLookupResult assembles order and item matching payload', () => {
|
||||
const result = buildFulfillmentLookupResult({
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
platformOrderId: 'order-1',
|
||||
detail: {
|
||||
shopName: '店铺A',
|
||||
buyerName: '买家A',
|
||||
totalAmount: 12345,
|
||||
paidAt: '2026-05-04T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
externalItemId: 'item-1',
|
||||
externalSkuCode: 'sku-1',
|
||||
externalSkuName: '礼包A',
|
||||
skuName: '礼包A',
|
||||
quantity: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
detailResult: {
|
||||
enriched: true,
|
||||
reason: 'ok',
|
||||
errorMessage: '',
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'internal-1',
|
||||
skuName: '内部商品',
|
||||
profileKey: 'profile-1',
|
||||
match: {
|
||||
externalSkuCode: 'sku-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
fallbackShopName: '备用店铺',
|
||||
})
|
||||
|
||||
assert.deepEqual(result, {
|
||||
order: {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺A',
|
||||
platformOrderId: 'order-1',
|
||||
buyerName: '买家A',
|
||||
totalAmountFen: 12345,
|
||||
totalAmount: '123.45',
|
||||
paidAt: '2026-05-04T10:00:00.000Z',
|
||||
enriched: true,
|
||||
enrichReason: 'ok',
|
||||
errorMessage: '',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
lineId: 'order-1:1:sku-1:item-1',
|
||||
itemTitle: '礼包A',
|
||||
quantity: 2,
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺A',
|
||||
externalItemId: 'item-1',
|
||||
externalSkuCode: 'sku-1',
|
||||
externalSkuName: '礼包A',
|
||||
latestSeenAt: null,
|
||||
orderItemCount: 2,
|
||||
configured: true,
|
||||
matchedBinding: {
|
||||
skuCode: 'internal-1',
|
||||
skuName: '内部商品',
|
||||
profileKey: 'profile-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
// @ts-check
|
||||
|
||||
export function maskSecret(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (normalized.length <= 10) {
|
||||
return `${normalized.slice(0, 2)}****${normalized.slice(-2)}`
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, 6)}****${normalized.slice(-6)}`
|
||||
}
|
||||
|
||||
export function maskPhone(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (normalized.length < 7) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, 3)}****${normalized.slice(-4)}`
|
||||
}
|
||||
|
||||
export function mapAdminKhhaoSourceConfig(config = {}) {
|
||||
return {
|
||||
enabled: config.enabled !== false,
|
||||
baseUrl: String(config.baseUrl || '').trim(),
|
||||
username: String(config.username || '').trim(),
|
||||
password: String(config.password || '').trim(),
|
||||
maxCaptchaAttempts: Number(config.maxCaptchaAttempts || 3) || 3,
|
||||
autoSync: {
|
||||
enabled: config.autoSync?.enabled === true,
|
||||
intervalMinutes: Number(config.autoSync?.intervalMinutes || 3) || 3,
|
||||
pages: Number(config.autoSync?.pages || 2) || 2,
|
||||
pageSize: Number(config.autoSync?.pageSize || 20) || 20,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminKhhaoSyncState(syncState = {}) {
|
||||
return {
|
||||
syncFromCreatedAt: String(syncState.syncFromCreatedAt || '').trim(),
|
||||
lastRunStartedAt: String(syncState.lastRunStartedAt || '').trim(),
|
||||
lastRunFinishedAt: String(syncState.lastRunFinishedAt || '').trim(),
|
||||
lastRunStatus: String(syncState.lastRunStatus || '').trim(),
|
||||
lastErrorMessage: String(syncState.lastErrorMessage || '').trim(),
|
||||
fetchedCount: Number(syncState.fetchedCount || 0),
|
||||
syncedCount: Number(syncState.syncedCount || 0),
|
||||
ignoredCount: Number(syncState.ignoredCount || 0),
|
||||
watchMode: String(syncState.watchMode || 'normal').trim() || 'normal',
|
||||
watchOrders: Array.isArray(syncState.watchOrders)
|
||||
? syncState.watchOrders.map((item) => ({
|
||||
platformOrderId: String(item?.platformOrderId || '').trim(),
|
||||
shopId: String(item?.shopId || '').trim(),
|
||||
shopName: String(item?.shopName || '').trim(),
|
||||
itemTitle: String(item?.itemTitle || '').trim(),
|
||||
payStatus: String(item?.payStatus || '').trim(),
|
||||
detectedAt: String(item?.detectedAt || '').trim(),
|
||||
lastSeenAt: String(item?.lastSeenAt || '').trim(),
|
||||
}))
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminKuaishouEticketShopItem(item = {}) {
|
||||
return {
|
||||
shopId: String(item.shopId || '').trim(),
|
||||
kshopName: String(item.kshopName || '').trim(),
|
||||
cookie: String(item.cookie || '').trim(),
|
||||
cookieMasked: maskSecret(item.cookie),
|
||||
hasCookie: Boolean(String(item.cookie || '').trim()),
|
||||
userAvatar: String(item.userAvatar || '').trim(),
|
||||
enabled: item.enabled !== false,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminKuaishouEticketSourceConfig(config = {}, shops = []) {
|
||||
return {
|
||||
enabled: config.enabled !== false,
|
||||
baseUrl: String(config.baseUrl || '').trim(),
|
||||
shops: shops.map((item) => mapAdminKuaishouEticketShopItem(item)),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminCloudtentaclesSourceConfig(config = {}) {
|
||||
return {
|
||||
enabled: config.enabled !== false,
|
||||
baseUrl: String(config.baseUrl || '').trim(),
|
||||
username: String(config.username || '').trim(),
|
||||
password: String(config.password || '').trim(),
|
||||
phone: String(config.phone || '').trim(),
|
||||
deviceId: String(config.deviceId || '-').trim() || '-',
|
||||
deviceType: Number(config.deviceType || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminCloudtentaclesSession(session = {}) {
|
||||
return {
|
||||
token: String(session.token || '').trim(),
|
||||
tokenMasked: maskSecret(session.token),
|
||||
baseUrl: String(session.baseUrl || '').trim(),
|
||||
username: String(session.username || '').trim(),
|
||||
phoneMasked: maskPhone(session.phone),
|
||||
loggedInAt: String(session.loggedInAt || '').trim(),
|
||||
deviceId: String(session.deviceId || '-').trim() || '-',
|
||||
deviceType: Number(session.deviceType || 0),
|
||||
hasToken: Boolean(String(session.token || '').trim()),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminKuaishouCloudFulfillmentItem(item = {}) {
|
||||
return {
|
||||
id: String(item.id || '').trim(),
|
||||
enabled: item.enabled !== false,
|
||||
priority: Number(item.priority || 100) || 100,
|
||||
provider: String(item.provider || 'khhao').trim() || 'khhao',
|
||||
platform: String(item.platform || 'kuaishou').trim() || 'kuaishou',
|
||||
shopId: String(item.shopId || '').trim(),
|
||||
internalSkuCode: String(item.internalSkuCode || '').trim(),
|
||||
internalSkuName: String(item.internalSkuName || '').trim(),
|
||||
externalSkuCode: String(item.externalSkuCode || '').trim(),
|
||||
externalItemId: String(item.externalItemId || '').trim(),
|
||||
externalSkuName: String(item.externalSkuName || '').trim(),
|
||||
resolvedSkuName: String(item.resolvedSkuName || '').trim(),
|
||||
cloudSourceKey: String(item.cloudSourceKey || 'default').trim() || 'default',
|
||||
cloudSkuId: Number(item.cloudSkuId || 0) || 0,
|
||||
cloudSkuName: String(item.cloudSkuName || '').trim(),
|
||||
vnKey: String(item.vnKey || '').trim(),
|
||||
autoBuyEnabled: item.autoBuyEnabled !== false,
|
||||
minAssetReserve: Number(item.minAssetReserve || 0) || 0,
|
||||
autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch === true,
|
||||
autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true,
|
||||
kuaishouConsumeShopId: String(item.kuaishouConsumeShopId || '').trim(),
|
||||
kuaishouConsumeShopName: String(item.kuaishouConsumeShopName || '').trim(),
|
||||
notes: String(item.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminKuaishouCloudFulfillmentSource(config = {}) {
|
||||
return {
|
||||
enabled: config.enabled !== false,
|
||||
items: (Array.isArray(config.items) ? config.items : []).map((item) =>
|
||||
mapAdminKuaishouCloudFulfillmentItem(item),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
mapAdminCloudtentaclesSession,
|
||||
mapAdminKhhaoSourceConfig,
|
||||
mapAdminKhhaoSyncState,
|
||||
mapAdminKuaishouCloudFulfillmentSource,
|
||||
mapAdminKuaishouEticketSourceConfig,
|
||||
maskPhone,
|
||||
maskSecret,
|
||||
} from './mappers.js'
|
||||
|
||||
test('maskSecret preserves edges while hiding middle characters', () => {
|
||||
assert.equal(maskSecret('abcdef1234567890'), 'abcdef****567890')
|
||||
assert.equal(maskSecret('12345678'), '12****78')
|
||||
assert.equal(maskSecret(''), '')
|
||||
})
|
||||
|
||||
test('maskPhone hides middle digits for mobile numbers', () => {
|
||||
assert.equal(maskPhone('13812345678'), '138****5678')
|
||||
assert.equal(maskPhone('123456'), '123456')
|
||||
assert.equal(maskPhone(''), '')
|
||||
})
|
||||
|
||||
test('mapAdminKhhaoSourceConfig normalizes defaults and nested auto sync values', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminKhhaoSourceConfig({
|
||||
enabled: true,
|
||||
baseUrl: ' https://admin.khhao.com ',
|
||||
username: ' user ',
|
||||
password: ' pass ',
|
||||
maxCaptchaAttempts: '0',
|
||||
autoSync: {
|
||||
enabled: true,
|
||||
intervalMinutes: '',
|
||||
pages: '0',
|
||||
pageSize: '50',
|
||||
},
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
baseUrl: 'https://admin.khhao.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
maxCaptchaAttempts: 3,
|
||||
autoSync: {
|
||||
enabled: true,
|
||||
intervalMinutes: 3,
|
||||
pages: 2,
|
||||
pageSize: 50,
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('mapAdminKhhaoSyncState normalizes watch orders and fallback values', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminKhhaoSyncState({
|
||||
watchMode: '',
|
||||
fetchedCount: '8',
|
||||
watchOrders: [
|
||||
{
|
||||
platformOrderId: ' A1 ',
|
||||
shopId: ' S1 ',
|
||||
shopName: ' 店铺 ',
|
||||
itemTitle: ' 商品 ',
|
||||
payStatus: ' unpaid ',
|
||||
detectedAt: ' t1 ',
|
||||
lastSeenAt: ' t2 ',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
syncFromCreatedAt: '',
|
||||
lastRunStartedAt: '',
|
||||
lastRunFinishedAt: '',
|
||||
lastRunStatus: '',
|
||||
lastErrorMessage: '',
|
||||
fetchedCount: 8,
|
||||
syncedCount: 0,
|
||||
ignoredCount: 0,
|
||||
watchMode: 'normal',
|
||||
watchOrders: [
|
||||
{
|
||||
platformOrderId: 'A1',
|
||||
shopId: 'S1',
|
||||
shopName: '店铺',
|
||||
itemTitle: '商品',
|
||||
payStatus: 'unpaid',
|
||||
detectedAt: 't1',
|
||||
lastSeenAt: 't2',
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('mapAdminKuaishouEticketSourceConfig maps shops and masks cookies', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminKuaishouEticketSourceConfig(
|
||||
{ enabled: true, baseUrl: ' https://s.kwaixiaodian.com ' },
|
||||
[{ shopId: ' 1 ', kshopName: ' A店 ', cookie: 'cookie-abcdef123456', enabled: true }],
|
||||
),
|
||||
{
|
||||
enabled: true,
|
||||
baseUrl: 'https://s.kwaixiaodian.com',
|
||||
shops: [
|
||||
{
|
||||
shopId: '1',
|
||||
kshopName: 'A店',
|
||||
cookie: 'cookie-abcdef123456',
|
||||
cookieMasked: 'cookie****123456',
|
||||
hasCookie: true,
|
||||
userAvatar: '',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('mapAdminCloudtentaclesSession derives masked fields and token presence', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminCloudtentaclesSession({
|
||||
token: 'abcdef1234567890',
|
||||
baseUrl: ' https://123.207.217.176 ',
|
||||
username: ' admin ',
|
||||
phone: '13812345678',
|
||||
loggedInAt: ' 2026-05-04T10:00:00.000Z ',
|
||||
deviceId: ' dev-1 ',
|
||||
deviceType: '2',
|
||||
}),
|
||||
{
|
||||
token: 'abcdef1234567890',
|
||||
tokenMasked: 'abcdef****567890',
|
||||
baseUrl: 'https://123.207.217.176',
|
||||
username: 'admin',
|
||||
phoneMasked: '138****5678',
|
||||
loggedInAt: '2026-05-04T10:00:00.000Z',
|
||||
deviceId: 'dev-1',
|
||||
deviceType: 2,
|
||||
hasToken: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('mapAdminKuaishouCloudFulfillmentSource normalizes items and defaults', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminKuaishouCloudFulfillmentSource({
|
||||
enabled: true,
|
||||
items: [
|
||||
{
|
||||
id: ' item-1 ',
|
||||
provider: '',
|
||||
platform: '',
|
||||
cloudSourceKey: '',
|
||||
cloudSkuId: '0',
|
||||
autoConsumeAfterDispatch: true,
|
||||
kuaishouConsumeShopId: ' ks1 ',
|
||||
notes: ' note ',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
provider: 'khhao',
|
||||
platform: 'kuaishou',
|
||||
shopId: '',
|
||||
internalSkuCode: '',
|
||||
internalSkuName: '',
|
||||
externalSkuCode: '',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
resolvedSkuName: '',
|
||||
cloudSourceKey: 'default',
|
||||
cloudSkuId: 0,
|
||||
cloudSkuName: '',
|
||||
vnKey: '',
|
||||
autoBuyEnabled: true,
|
||||
minAssetReserve: 0,
|
||||
autoReturnNumberAfterDispatch: false,
|
||||
autoConsumeAfterDispatch: true,
|
||||
kuaishouConsumeShopId: 'ks1',
|
||||
kuaishouConsumeShopName: '',
|
||||
notes: 'note',
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
// @ts-check
|
||||
|
||||
import { query } from '../../../db/client.js'
|
||||
import { mapAdminObservedProductItem } from './domain.js'
|
||||
|
||||
export const ADMIN_OBSERVED_PRODUCTS_QUERY = `
|
||||
SELECT
|
||||
o.provider,
|
||||
o.platform,
|
||||
o.shop_id,
|
||||
MAX(CASE WHEN trim(o.shop_name) != '' THEN o.shop_name ELSE '' END) AS shop_name,
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') AS external_item_id,
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') AS external_sku_code,
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') AS external_sku_name,
|
||||
MAX(oi.created_at) AS latest_seen_at,
|
||||
COUNT(*)::int AS order_item_count
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE
|
||||
COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') != ''
|
||||
OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') != ''
|
||||
OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') != ''
|
||||
GROUP BY
|
||||
o.provider,
|
||||
o.platform,
|
||||
o.shop_id,
|
||||
external_item_id,
|
||||
external_sku_code,
|
||||
external_sku_name
|
||||
ORDER BY latest_seen_at DESC, o.platform ASC, o.shop_id ASC, external_sku_code ASC
|
||||
LIMIT 200
|
||||
`
|
||||
|
||||
export function mapAdminObservedProductRow(row = {}) {
|
||||
return {
|
||||
provider: String(row.provider || '').trim(),
|
||||
platform: String(row.platform || '').trim(),
|
||||
shopId: String(row.shop_id || '').trim(),
|
||||
shopName: String(row.shop_name || '').trim(),
|
||||
externalItemId: String(row.external_item_id || '').trim(),
|
||||
externalSkuCode: String(row.external_sku_code || '').trim(),
|
||||
externalSkuName: String(row.external_sku_name || '').trim(),
|
||||
latestSeenAt: row.latest_seen_at || null,
|
||||
orderItemCount: Number(row.order_item_count || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAdminObservedProducts(bindings = [], options = {}) {
|
||||
const queryImpl = /** @type {(sql: string) => Promise<{ rows: Array<Record<string, unknown>> }>} */ (
|
||||
options.query || query
|
||||
)
|
||||
const rowsResult = await queryImpl(ADMIN_OBSERVED_PRODUCTS_QUERY)
|
||||
return rowsResult.rows.map((row) => mapAdminObservedProductItem(mapAdminObservedProductRow(row), bindings))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
ADMIN_OBSERVED_PRODUCTS_QUERY,
|
||||
listAdminObservedProducts,
|
||||
mapAdminObservedProductRow,
|
||||
} from './observed-products.js'
|
||||
|
||||
test('mapAdminObservedProductRow normalizes query row fields', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminObservedProductRow({
|
||||
provider: ' agiso ',
|
||||
platform: ' xianyu ',
|
||||
shop_id: ' shop-1 ',
|
||||
shop_name: ' 店铺A ',
|
||||
external_item_id: ' item-1 ',
|
||||
external_sku_code: ' sku-1 ',
|
||||
external_sku_name: ' 商品A ',
|
||||
latest_seen_at: '2026-05-04T12:00:00.000Z',
|
||||
order_item_count: '3',
|
||||
}),
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺A',
|
||||
externalItemId: 'item-1',
|
||||
externalSkuCode: 'sku-1',
|
||||
externalSkuName: '商品A',
|
||||
latestSeenAt: '2026-05-04T12:00:00.000Z',
|
||||
orderItemCount: 3,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('listAdminObservedProducts queries rows and maps matched binding summary', async () => {
|
||||
/** @type {string[]} */
|
||||
const sqlCalls = []
|
||||
const result = await listAdminObservedProducts(
|
||||
[
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'inner-1',
|
||||
skuName: '内部商品A',
|
||||
profileKey: 'manual_review',
|
||||
match: {
|
||||
externalSkuCode: 'sku-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
async query(sql) {
|
||||
sqlCalls.push(sql)
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shop_id: 'shop-1',
|
||||
shop_name: '店铺A',
|
||||
external_item_id: '',
|
||||
external_sku_code: 'sku-1',
|
||||
external_sku_name: '商品A',
|
||||
latest_seen_at: '2026-05-04T12:00:00.000Z',
|
||||
order_item_count: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(sqlCalls.length, 1)
|
||||
assert.equal(sqlCalls[0], ADMIN_OBSERVED_PRODUCTS_QUERY)
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺A',
|
||||
externalItemId: '',
|
||||
externalSkuCode: 'sku-1',
|
||||
externalSkuName: '商品A',
|
||||
latestSeenAt: '2026-05-04T12:00:00.000Z',
|
||||
orderItemCount: 2,
|
||||
configured: true,
|
||||
matchedBinding: {
|
||||
skuCode: 'inner-1',
|
||||
skuName: '内部商品A',
|
||||
profileKey: 'manual_review',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,789 @@
|
||||
// @ts-check
|
||||
|
||||
import { query } from '../../../db/client.js'
|
||||
import {
|
||||
getAgisoMessagingDefaults,
|
||||
getAgisoShopConfig,
|
||||
getAgisoShopConfigMap,
|
||||
getAgisoShopsFilePath,
|
||||
saveAgisoMessagingConfig,
|
||||
} from '../../platforms/agiso/shop-config-service.js'
|
||||
import { enrichAgisoXianyuTradeOrder } from '../../platforms/agiso/xianyu/order-detail-service.js'
|
||||
import { mapKhhaoOrderPreviewList } from '../../platforms/khhao/order-mapper-service.js'
|
||||
import { queryKhhaoOrderList } from '../../platforms/khhao/order-query-service.js'
|
||||
import { syncKhhaoOrders } from '../../platforms/khhao/order-sync-service.js'
|
||||
import { getKhhaoSourceConfig, getKhhaoSourcesFilePath, saveKhhaoSourceConfig } from '../../platforms/khhao/source-config-service.js'
|
||||
import { ensureKhhaoSyncBaseline, getKhhaoSyncState, getKhhaoSyncStateFilePath } from '../../platforms/khhao/sync-state-service.js'
|
||||
import { loginKhhaoSession } from '../../platforms/khhao/session-service.js'
|
||||
import {
|
||||
consumeKuaishouEticket,
|
||||
queryKuaishouEticketConsumeDetail,
|
||||
} from '../../platforms/kuaishou-eticket/consume-service.js'
|
||||
import {
|
||||
findKuaishouEticketShopConfig,
|
||||
getFirstAvailableKuaishouEticketShop,
|
||||
getKuaishouEticketSourceConfig,
|
||||
getKuaishouEticketSourceFilePath,
|
||||
listKuaishouEticketShopConfigs,
|
||||
resolveKuaishouEticketShopConfig,
|
||||
saveKuaishouEticketSourceConfig,
|
||||
} from '../../platforms/kuaishou-eticket/source-config-service.js'
|
||||
import { queryKuaishouEticketStableInfo } from '../../platforms/kuaishou-eticket/info-service.js'
|
||||
import {
|
||||
getCloudtentaclesSourceConfig,
|
||||
getCloudtentaclesSourcesFilePath,
|
||||
saveCloudtentaclesSourceConfig,
|
||||
} from '../../platforms/cloudtentacles/source-config-service.js'
|
||||
import {
|
||||
clearCloudtentaclesSessionState,
|
||||
getCloudtentaclesSessionFilePath,
|
||||
getCloudtentaclesSessionState,
|
||||
saveCloudtentaclesSessionState,
|
||||
} from '../../platforms/cloudtentacles/session-state-service.js'
|
||||
import {
|
||||
loginCloudtentaclesSession,
|
||||
sendCloudtentaclesSmsCode,
|
||||
validateCloudtentaclesSession,
|
||||
} from '../../platforms/cloudtentacles/session-service.js'
|
||||
import {
|
||||
buyCloudtentaclesSku,
|
||||
getCloudtentaclesAsset,
|
||||
getCloudtentaclesCategories,
|
||||
listCloudtentaclesSku,
|
||||
useCloudtentaclesSku,
|
||||
} from '../../platforms/cloudtentacles/catalog-service.js'
|
||||
import { getCloudtentaclesKnapsack } from '../../platforms/cloudtentacles/knapsack-service.js'
|
||||
import {
|
||||
appointCloudtentaclesVirtualNumber,
|
||||
backCloudtentaclesVirtualNumber,
|
||||
fetchCloudtentaclesVirtualNumberCode,
|
||||
generateCloudtentaclesLoginCode,
|
||||
getCloudtentaclesBindUrl,
|
||||
listCloudtentaclesVirtualNumbers,
|
||||
verifyCloudtentaclesLoginCode,
|
||||
} from '../../platforms/cloudtentacles/virtual-number-service.js'
|
||||
import { runCloudtentaclesFullDebugFlow } from '../../platforms/cloudtentacles/debug-flow-service.js'
|
||||
import {
|
||||
getOrderFulfillmentBindingConfigs,
|
||||
getOrderFulfillmentBindingsFilePath,
|
||||
saveOrderFulfillmentBindingConfigs,
|
||||
} from '../../order/fulfillment-binding-config-service.js'
|
||||
import {
|
||||
getKuaishouCloudFulfillmentConfig,
|
||||
getKuaishouCloudFulfillmentFilePath,
|
||||
saveKuaishouCloudFulfillmentConfig,
|
||||
} from '../../order/kuaishou-cloud-fulfillment-config-service.js'
|
||||
import { getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { resolveDisplayShopName } from '../admin-read-shared-helpers.js'
|
||||
import {
|
||||
hasCloudtentaclesCredentialContextChanged,
|
||||
isPlainObject,
|
||||
pickFirstNonEmpty,
|
||||
resolveKuaishouEticketAdminContext,
|
||||
} from './context.js'
|
||||
import {
|
||||
buildFulfillmentLookupResult,
|
||||
normalizeFulfillmentLookupPayload,
|
||||
resolveFulfillmentLookupDetail,
|
||||
} from './fulfillment.js'
|
||||
import {
|
||||
resolveAdminCloudtentaclesCredentialPayload,
|
||||
resolveAdminCloudtentaclesSessionPayload,
|
||||
} from './cloudtentacles.js'
|
||||
import { listAdminObservedProducts } from './observed-products.js'
|
||||
import {
|
||||
assertAdminFulfillmentBindingsInput,
|
||||
buildAdminFulfillmentBindingUniqueKey,
|
||||
normalizeAdminFulfillmentBindingItem,
|
||||
} from './validation.js'
|
||||
import {
|
||||
applyOptionalStringField,
|
||||
mapAdminAgisoMessagingDefaults,
|
||||
mapAdminAgisoShopConfigItem,
|
||||
mapAdminFulfillmentBindingConfigItem,
|
||||
} from './domain.js'
|
||||
import {
|
||||
mapAdminCloudtentaclesSession,
|
||||
mapAdminCloudtentaclesSourceConfig,
|
||||
mapAdminKhhaoSourceConfig,
|
||||
mapAdminKhhaoSyncState,
|
||||
mapAdminKuaishouCloudFulfillmentSource,
|
||||
mapAdminKuaishouEticketSourceConfig,
|
||||
maskPhone,
|
||||
maskSecret,
|
||||
} from './mappers.js'
|
||||
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigSaveInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminKhhaoSourceConfigInput} AdminKhhaoSourceConfigInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminKuaishouEticketSourceConfigInput} AdminKuaishouEticketSourceConfigInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminKuaishouEticketShopInfoInput} AdminKuaishouEticketShopInfoInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminKuaishouEticketDetailQueryInput} AdminKuaishouEticketDetailQueryInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminKuaishouEticketConsumeInput} AdminKuaishouEticketConsumeInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminKhhaoOrderQueryInput} AdminKhhaoOrderQueryInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminKhhaoTestLoginInput} AdminKhhaoTestLoginInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminCloudtentaclesSourceConfigInput} AdminCloudtentaclesSourceConfigInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminKuaishouCloudFulfillmentConfigInput} AdminKuaishouCloudFulfillmentConfigInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminCloudtentaclesSendSmsCodeInput} AdminCloudtentaclesSendSmsCodeInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminCloudtentaclesTestLoginInput} AdminCloudtentaclesTestLoginInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminCloudtentaclesValidateSessionInput} AdminCloudtentaclesValidateSessionInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminCloudtentaclesCatalogQueryInput} AdminCloudtentaclesCatalogQueryInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminCloudtentaclesSkuBuyInput} AdminCloudtentaclesSkuBuyInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminCloudtentaclesSkuUseInput} AdminCloudtentaclesSkuUseInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminCloudtentaclesVirtualNumberInput} AdminCloudtentaclesVirtualNumberInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminCloudtentaclesFullFlowInput} AdminCloudtentaclesFullFlowInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminFulfillmentBindingConfigSaveInput} AdminFulfillmentBindingConfigSaveInput */
|
||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminFulfillmentBindingLookupInput} AdminFulfillmentBindingLookupInput */
|
||||
|
||||
export async function getAdminAgisoShopConfigs() {
|
||||
const configMap = getAgisoShopConfigMap()
|
||||
const defaults = getAgisoMessagingDefaults()
|
||||
const rowsResult = await query(
|
||||
`
|
||||
SELECT
|
||||
shop_id,
|
||||
MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name,
|
||||
MAX(created_at) AS latest_seen_at,
|
||||
COUNT(*)::int AS webhook_event_count
|
||||
FROM webhook_events
|
||||
WHERE provider = 'agiso' AND trim(shop_id) != ''
|
||||
GROUP BY shop_id
|
||||
ORDER BY latest_seen_at DESC, shop_id DESC
|
||||
`,
|
||||
)
|
||||
const rows = rowsResult.rows
|
||||
|
||||
return {
|
||||
filePath: getAgisoShopsFilePath(),
|
||||
defaults: mapAdminAgisoMessagingDefaults(defaults),
|
||||
shops: Object.entries(configMap)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
|
||||
observedShops: rows.map((row) => ({
|
||||
shopId: String(row.shop_id || '').trim(),
|
||||
detectedShopName: String(row.detected_shop_name || '').trim(),
|
||||
displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name),
|
||||
latestSeenAt: row.latest_seen_at || null,
|
||||
webhookEventCount: Number(row.webhook_event_count || 0),
|
||||
configured: Boolean(configMap[String(row.shop_id || '').trim()]),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminAgisoShopConfigSaveInput} [payload] */
|
||||
export function updateAdminAgisoShopConfigs(payload = /** @type {AdminAgisoShopConfigSaveInput} */ ({})) {
|
||||
const rawItems = Array.isArray(payload.shops) ? payload.shops : []
|
||||
const currentDefaults = getAgisoMessagingDefaults()
|
||||
const currentMap = getAgisoShopConfigMap()
|
||||
const nextDefaults = { ...currentDefaults }
|
||||
const nextMap = {}
|
||||
|
||||
applyOptionalStringField(nextDefaults, 'messageTemplate', payload.defaults)
|
||||
applyOptionalStringField(nextDefaults, 'autoDeliveryMessageTemplate', payload.defaults)
|
||||
|
||||
for (const item of rawItems) {
|
||||
const shopId = String(item?.shopId || '').trim()
|
||||
if (!shopId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const current = currentMap[shopId] || {}
|
||||
const next = { ...current }
|
||||
const shopName = String(item?.shopName || '').trim()
|
||||
const accessToken = String(item?.accessToken || '').trim()
|
||||
const messageTemplate = String(item?.messageTemplate || '').trim()
|
||||
const autoDeliveryMessageTemplate = String(item?.autoDeliveryMessageTemplate || '').trim()
|
||||
const appSecret = String(item?.appSecret || '').trim()
|
||||
const apiVersion = String(item?.apiVersion || '').trim()
|
||||
const sendMessageEndpoint = String(item?.sendMessageEndpoint || '').trim()
|
||||
|
||||
if (shopName) {
|
||||
next.shopName = shopName
|
||||
} else {
|
||||
delete next.shopName
|
||||
}
|
||||
if (accessToken) {
|
||||
next.accessToken = accessToken
|
||||
}
|
||||
if (messageTemplate) {
|
||||
next.messageTemplate = messageTemplate
|
||||
} else if (typeof item?.messageTemplate === 'string') {
|
||||
delete next.messageTemplate
|
||||
}
|
||||
if (autoDeliveryMessageTemplate) {
|
||||
next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate
|
||||
} else if (typeof item?.autoDeliveryMessageTemplate === 'string') {
|
||||
delete next.autoDeliveryMessageTemplate
|
||||
}
|
||||
if (appSecret) {
|
||||
next.appSecret = appSecret
|
||||
}
|
||||
if (apiVersion) {
|
||||
next.apiVersion = apiVersion
|
||||
}
|
||||
if (sendMessageEndpoint) {
|
||||
next.sendMessageEndpoint = sendMessageEndpoint
|
||||
}
|
||||
if (typeof item?.enabled === 'boolean') {
|
||||
next.enabled = item.enabled
|
||||
}
|
||||
|
||||
if (!next.accessToken) {
|
||||
continue
|
||||
}
|
||||
|
||||
nextMap[shopId] = next
|
||||
}
|
||||
|
||||
const saved = saveAgisoMessagingConfig({
|
||||
defaults: nextDefaults,
|
||||
shops: nextMap,
|
||||
})
|
||||
|
||||
return {
|
||||
filePath: getAgisoShopsFilePath(),
|
||||
defaults: mapAdminAgisoMessagingDefaults(saved.defaults),
|
||||
shops: Object.entries(saved.shops)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminKhhaoSourceConfig() {
|
||||
const config = getKhhaoSourceConfig()
|
||||
const syncState = getKhhaoSyncState()
|
||||
|
||||
return {
|
||||
filePath: getKhhaoSourcesFilePath(),
|
||||
stateFilePath: getKhhaoSyncStateFilePath(),
|
||||
source: mapAdminKhhaoSourceConfig(config),
|
||||
syncState: mapAdminKhhaoSyncState(syncState),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminKuaishouEticketSourceConfig() {
|
||||
const config = getKuaishouEticketSourceConfig()
|
||||
|
||||
return {
|
||||
filePath: getKuaishouEticketSourceFilePath(),
|
||||
source: mapAdminKuaishouEticketSourceConfig(config, listKuaishouEticketShopConfigs(config)),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminKuaishouEticketSourceConfigInput} [payload] */
|
||||
export function updateAdminKuaishouEticketSourceConfig(payload = /** @type {AdminKuaishouEticketSourceConfigInput} */ ({})) {
|
||||
const saved = saveKuaishouEticketSourceConfig({
|
||||
enabled: payload.enabled !== false,
|
||||
baseUrl: String(payload.baseUrl || '').trim() || 'https://s.kwaixiaodian.com',
|
||||
shops: Array.isArray(payload.shops) ? payload.shops : [],
|
||||
})
|
||||
|
||||
return {
|
||||
filePath: getKuaishouEticketSourceFilePath(),
|
||||
source: mapAdminKuaishouEticketSourceConfig(saved, listKuaishouEticketShopConfigs(saved)),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminKhhaoSourceConfigInput} [payload] */
|
||||
export function updateAdminKhhaoSourceConfig(payload = /** @type {AdminKhhaoSourceConfigInput} */ ({})) {
|
||||
const saved = saveKhhaoSourceConfig({
|
||||
enabled: payload.enabled !== false,
|
||||
baseUrl: String(payload.baseUrl || '').trim() || 'https://admin.khhao.com',
|
||||
username: String(payload.username || '').trim(),
|
||||
password: String(payload.password || '').trim(),
|
||||
maxCaptchaAttempts: Number(payload.maxCaptchaAttempts || 3) || 3,
|
||||
autoSync: {
|
||||
enabled: payload.autoSync?.enabled === true,
|
||||
intervalMinutes: Number(payload.autoSync?.intervalMinutes || 3) || 3,
|
||||
pages: Number(payload.autoSync?.pages || 2) || 2,
|
||||
pageSize: Number(payload.autoSync?.pageSize || 20) || 20,
|
||||
},
|
||||
})
|
||||
const syncState = saved.autoSync?.enabled ? ensureKhhaoSyncBaseline() : getKhhaoSyncState()
|
||||
|
||||
return {
|
||||
filePath: getKhhaoSourcesFilePath(),
|
||||
stateFilePath: getKhhaoSyncStateFilePath(),
|
||||
source: mapAdminKhhaoSourceConfig(saved),
|
||||
syncState: mapAdminKhhaoSyncState(syncState),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminKhhaoTestLoginInput} [payload] */
|
||||
export async function testAdminKhhaoLogin(payload = /** @type {AdminKhhaoTestLoginInput} */ ({})) {
|
||||
const savedSource = getKhhaoSourceConfig()
|
||||
const session = await loginKhhaoSession({
|
||||
baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(),
|
||||
username: String(payload.username || savedSource.username || '').trim(),
|
||||
password: String(payload.password || savedSource.password || '').trim(),
|
||||
maxCaptchaAttempts: payload.maxCaptchaAttempts || savedSource.maxCaptchaAttempts,
|
||||
includeImageBase64: payload.includeImageBase64,
|
||||
requestId: `admin-khhao-test-login:${String(payload.username || savedSource.username || '').trim() || 'anonymous'}`,
|
||||
})
|
||||
|
||||
return {
|
||||
baseUrl: session.baseUrl,
|
||||
username: session.username,
|
||||
loggedInAt: session.loggedInAt,
|
||||
attempt: session.attempt,
|
||||
responseMessage: session.responseMessage,
|
||||
captcha: {
|
||||
recognizedText: session.captchaText,
|
||||
imageBase64: session.captchaImageBase64,
|
||||
},
|
||||
session: {
|
||||
cookieKeys: Object.keys(session.cookieMap),
|
||||
cookieCount: Object.keys(session.cookieMap).length,
|
||||
cookieHeaderMasked: maskSecret(session.cookieHeader),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminKhhaoOrderQueryInput} [payload] */
|
||||
export async function queryAdminKhhaoOrders(payload = /** @type {AdminKhhaoOrderQueryInput} */ ({})) {
|
||||
const savedSource = getKhhaoSourceConfig()
|
||||
const session = await loginKhhaoSession({
|
||||
baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(),
|
||||
username: String(payload.username || savedSource.username || '').trim(),
|
||||
password: String(payload.password || savedSource.password || '').trim(),
|
||||
maxCaptchaAttempts: payload.maxCaptchaAttempts || savedSource.maxCaptchaAttempts,
|
||||
requestId: `admin-khhao-query-orders:${String(payload.username || savedSource.username || '').trim() || 'anonymous'}`,
|
||||
})
|
||||
|
||||
const result = await queryKhhaoOrderList({
|
||||
baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(),
|
||||
page: payload.page,
|
||||
limit: payload.limit,
|
||||
session,
|
||||
})
|
||||
const previews = mapKhhaoOrderPreviewList(result.items)
|
||||
|
||||
return {
|
||||
baseUrl: session.baseUrl,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
total: result.total,
|
||||
itemCount: result.items.length,
|
||||
items: previews,
|
||||
rawItems: result.items,
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminKhhaoOrderQueryInput} [payload] */
|
||||
export async function syncAdminKhhaoOrders(payload = /** @type {AdminKhhaoOrderQueryInput} */ ({})) {
|
||||
const savedSource = getKhhaoSourceConfig()
|
||||
return syncKhhaoOrders({
|
||||
...payload,
|
||||
baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(),
|
||||
username: String(payload.username || savedSource.username || '').trim(),
|
||||
password: String(payload.password || savedSource.password || '').trim(),
|
||||
maxCaptchaAttempts: payload.maxCaptchaAttempts || savedSource.maxCaptchaAttempts,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminKuaishouEticketDetailQueryInput} [payload] */
|
||||
export async function queryAdminKuaishouEticketDetail(payload = /** @type {AdminKuaishouEticketDetailQueryInput} */ ({})) {
|
||||
const context = resolveKuaishouEticketAdminContext(payload, {
|
||||
savedSource: getKuaishouEticketSourceConfig(),
|
||||
findShopConfig: findKuaishouEticketShopConfig,
|
||||
getFirstAvailableShop: getFirstAvailableKuaishouEticketShop,
|
||||
})
|
||||
|
||||
return queryKuaishouEticketConsumeDetail({
|
||||
baseUrl: context.baseUrl,
|
||||
cookie: context.cookie,
|
||||
eTicketId: String(payload.eTicketId || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminKuaishouEticketShopInfoInput} [payload] */
|
||||
export async function queryAdminKuaishouEticketShopInfo(payload = /** @type {AdminKuaishouEticketShopInfoInput} */ ({})) {
|
||||
const context = resolveKuaishouEticketAdminContext(payload, {
|
||||
savedSource: getKuaishouEticketSourceConfig(),
|
||||
findShopConfig: findKuaishouEticketShopConfig,
|
||||
getFirstAvailableShop: getFirstAvailableKuaishouEticketShop,
|
||||
})
|
||||
return queryKuaishouEticketStableInfo({
|
||||
baseUrl: context.baseUrl,
|
||||
cookie: context.cookie,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminKuaishouEticketConsumeInput} [payload] */
|
||||
export async function consumeAdminKuaishouEticket(payload = /** @type {AdminKuaishouEticketConsumeInput} */ ({})) {
|
||||
const context = resolveKuaishouEticketAdminContext(payload, {
|
||||
savedSource: getKuaishouEticketSourceConfig(),
|
||||
findShopConfig: findKuaishouEticketShopConfig,
|
||||
getFirstAvailableShop: getFirstAvailableKuaishouEticketShop,
|
||||
})
|
||||
|
||||
return consumeKuaishouEticket({
|
||||
baseUrl: context.baseUrl,
|
||||
cookie: context.cookie,
|
||||
eTicketId: String(payload.eTicketId || '').trim(),
|
||||
oid: String(payload.oid || '').trim(),
|
||||
formToken: String(payload.formToken || '').trim(),
|
||||
num: payload.num,
|
||||
storeId: String(payload.storeId || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
export function getAdminCloudtentaclesSourceConfig() {
|
||||
const config = getCloudtentaclesSourceConfig()
|
||||
const session = getCloudtentaclesSessionState()
|
||||
|
||||
return {
|
||||
filePath: getCloudtentaclesSourcesFilePath(),
|
||||
sessionFilePath: getCloudtentaclesSessionFilePath(),
|
||||
source: mapAdminCloudtentaclesSourceConfig(config),
|
||||
session: mapAdminCloudtentaclesSession(session),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesSourceConfigInput} [payload] */
|
||||
export function updateAdminCloudtentaclesSourceConfig(payload = /** @type {AdminCloudtentaclesSourceConfigInput} */ ({})) {
|
||||
const current = getCloudtentaclesSourceConfig()
|
||||
const saved = saveCloudtentaclesSourceConfig({
|
||||
enabled: payload.enabled !== false,
|
||||
baseUrl: String(payload.baseUrl || '').trim() || 'https://123.207.217.176',
|
||||
username: String(payload.username || '').trim(),
|
||||
password: String(payload.password || '').trim(),
|
||||
phone: String(payload.phone || '').trim(),
|
||||
deviceId: String(payload.deviceId || '-').trim() || '-',
|
||||
deviceType: Number(payload.deviceType || 0),
|
||||
})
|
||||
const shouldClearSession = hasCloudtentaclesCredentialContextChanged(current, saved)
|
||||
const session = shouldClearSession ? clearCloudtentaclesSessionState() : getCloudtentaclesSessionState()
|
||||
|
||||
return {
|
||||
filePath: getCloudtentaclesSourcesFilePath(),
|
||||
sessionFilePath: getCloudtentaclesSessionFilePath(),
|
||||
source: mapAdminCloudtentaclesSourceConfig(saved),
|
||||
session: mapAdminCloudtentaclesSession(session),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesSendSmsCodeInput} [payload] */
|
||||
export async function sendAdminCloudtentaclesSmsCode(payload = /** @type {AdminCloudtentaclesSendSmsCodeInput} */ ({})) {
|
||||
const result = await sendCloudtentaclesSmsCode(resolveAdminCloudtentaclesCredentialPayload(payload))
|
||||
|
||||
return {
|
||||
...result,
|
||||
phoneMasked: maskPhone(result.phone),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesTestLoginInput} [payload] */
|
||||
export async function testAdminCloudtentaclesLogin(payload = /** @type {AdminCloudtentaclesTestLoginInput} */ ({})) {
|
||||
const savedSource = getCloudtentaclesSourceConfig()
|
||||
const credentialContext = resolveAdminCloudtentaclesCredentialPayload(payload, { savedSource })
|
||||
const session = await loginCloudtentaclesSession({
|
||||
...credentialContext,
|
||||
code: String(payload.code || '').trim(),
|
||||
})
|
||||
const savedSession = saveCloudtentaclesSessionState({
|
||||
token: session.token,
|
||||
baseUrl: session.baseUrl,
|
||||
username: session.username,
|
||||
phone: session.phone,
|
||||
loggedInAt: session.loggedInAt,
|
||||
deviceId: credentialContext.deviceId,
|
||||
deviceType: credentialContext.deviceType,
|
||||
})
|
||||
|
||||
return {
|
||||
baseUrl: session.baseUrl,
|
||||
username: session.username,
|
||||
phoneMasked: maskPhone(session.phone),
|
||||
loggedInAt: session.loggedInAt,
|
||||
responseMessage: session.responseMessage,
|
||||
token: session.token,
|
||||
session: {
|
||||
tokenMasked: maskSecret(session.token),
|
||||
permissionCount: session.permissions.length,
|
||||
permissions: session.permissions,
|
||||
persisted: Boolean(savedSession.token),
|
||||
},
|
||||
userInfo: session.userInfo,
|
||||
asset: session.asset,
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesValidateSessionInput} [payload] */
|
||||
export async function validateAdminCloudtentaclesSession(payload = /** @type {AdminCloudtentaclesValidateSessionInput} */ ({})) {
|
||||
const savedSource = getCloudtentaclesSourceConfig()
|
||||
const persistedSession = getCloudtentaclesSessionState()
|
||||
const sessionContext = resolveAdminCloudtentaclesSessionPayload(payload, { savedSource, persistedSession })
|
||||
const session = await validateCloudtentaclesSession(sessionContext)
|
||||
const savedSession = saveCloudtentaclesSessionState({
|
||||
token: session.token,
|
||||
baseUrl: session.baseUrl,
|
||||
username: pickFirstNonEmpty([persistedSession.username, savedSource.username]),
|
||||
phone: pickFirstNonEmpty([persistedSession.phone, savedSource.phone]),
|
||||
loggedInAt: session.loggedInAt,
|
||||
deviceId: sessionContext.deviceId,
|
||||
deviceType: sessionContext.deviceType,
|
||||
})
|
||||
|
||||
return {
|
||||
baseUrl: session.baseUrl,
|
||||
loggedInAt: session.loggedInAt,
|
||||
session: {
|
||||
tokenMasked: maskSecret(session.token),
|
||||
permissionCount: session.permissions.length,
|
||||
permissions: session.permissions,
|
||||
persisted: Boolean(savedSession.token),
|
||||
},
|
||||
userInfo: session.userInfo,
|
||||
asset: session.asset,
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesCatalogQueryInput} [payload] */
|
||||
export async function getAdminCloudtentaclesAsset(payload = /** @type {AdminCloudtentaclesCatalogQueryInput} */ ({})) {
|
||||
return getCloudtentaclesAsset(resolveAdminCloudtentaclesSessionPayload(payload))
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesCatalogQueryInput} [payload] */
|
||||
export async function getAdminCloudtentaclesCategories(payload = /** @type {AdminCloudtentaclesCatalogQueryInput} */ ({})) {
|
||||
return getCloudtentaclesCategories(resolveAdminCloudtentaclesSessionPayload(payload))
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesCatalogQueryInput} [payload] */
|
||||
export async function getAdminCloudtentaclesSkuList(payload = /** @type {AdminCloudtentaclesCatalogQueryInput} */ ({})) {
|
||||
return listCloudtentaclesSku(resolveAdminCloudtentaclesSessionPayload(payload))
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesSkuBuyInput} [payload] */
|
||||
export async function buyAdminCloudtentaclesSku(payload = /** @type {AdminCloudtentaclesSkuBuyInput} */ ({})) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload)
|
||||
return buyCloudtentaclesSku({
|
||||
...context,
|
||||
id: payload.id,
|
||||
count: payload.count,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesSkuUseInput} [payload] */
|
||||
export async function useAdminCloudtentaclesSku(payload = /** @type {AdminCloudtentaclesSkuUseInput} */ ({})) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload)
|
||||
return useCloudtentaclesSku({
|
||||
...context,
|
||||
id: payload.id,
|
||||
virtualNumberId: payload.virtualNumberId,
|
||||
phone: payload.phone,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesCatalogQueryInput} [payload] */
|
||||
export async function getAdminCloudtentaclesKnapsack(payload = /** @type {AdminCloudtentaclesCatalogQueryInput} */ ({})) {
|
||||
return getCloudtentaclesKnapsack(resolveAdminCloudtentaclesSessionPayload(payload))
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */
|
||||
export async function listAdminCloudtentaclesVirtualNumbers(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload)
|
||||
return listCloudtentaclesVirtualNumbers({
|
||||
...context,
|
||||
key: payload.key,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */
|
||||
export async function appointAdminCloudtentaclesVirtualNumber(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload)
|
||||
return appointCloudtentaclesVirtualNumber({
|
||||
...context,
|
||||
key: payload.key,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */
|
||||
export async function generateAdminCloudtentaclesLoginCode(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload)
|
||||
return generateCloudtentaclesLoginCode({
|
||||
...context,
|
||||
key: payload.key,
|
||||
id: payload.id,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */
|
||||
export async function fetchAdminCloudtentaclesVirtualNumberCode(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload)
|
||||
return fetchCloudtentaclesVirtualNumberCode({
|
||||
...context,
|
||||
key: payload.key,
|
||||
phone: payload.phone,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */
|
||||
export async function verifyAdminCloudtentaclesLoginCode(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload)
|
||||
return verifyCloudtentaclesLoginCode({
|
||||
...context,
|
||||
key: payload.key,
|
||||
id: payload.id,
|
||||
code: payload.code,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */
|
||||
export async function getAdminCloudtentaclesBindUrl(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload)
|
||||
return getCloudtentaclesBindUrl({
|
||||
...context,
|
||||
key: payload.key,
|
||||
id: payload.id,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */
|
||||
export async function backAdminCloudtentaclesVirtualNumber(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload)
|
||||
return backCloudtentaclesVirtualNumber({
|
||||
...context,
|
||||
key: payload.key,
|
||||
id: payload.id,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminCloudtentaclesFullFlowInput} [payload] */
|
||||
export async function runAdminCloudtentaclesFullFlow(payload = /** @type {AdminCloudtentaclesFullFlowInput} */ ({})) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload)
|
||||
return runCloudtentaclesFullDebugFlow({
|
||||
...context,
|
||||
skuId: payload.skuId,
|
||||
skuCount: payload.skuCount,
|
||||
vnKey: payload.vnKey,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAdminFulfillmentBindingConfigs() {
|
||||
const bindings = getOrderFulfillmentBindingConfigs()
|
||||
|
||||
return {
|
||||
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
|
||||
observedProducts: await listAdminObservedProducts(bindings),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminKuaishouCloudFulfillmentConfig() {
|
||||
const config = getKuaishouCloudFulfillmentConfig()
|
||||
|
||||
return {
|
||||
filePath: getKuaishouCloudFulfillmentFilePath(),
|
||||
source: mapAdminKuaishouCloudFulfillmentSource(config),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminKuaishouCloudFulfillmentConfigInput} [payload] */
|
||||
export async function updateAdminKuaishouCloudFulfillmentConfig(
|
||||
payload = /** @type {AdminKuaishouCloudFulfillmentConfigInput} */ ({}),
|
||||
) {
|
||||
const saved = saveKuaishouCloudFulfillmentConfig({
|
||||
enabled: payload.enabled !== false,
|
||||
items: Array.isArray(payload.items) ? payload.items : [],
|
||||
})
|
||||
|
||||
await syncConfiguredFulfillmentBindings()
|
||||
|
||||
return {
|
||||
filePath: getKuaishouCloudFulfillmentFilePath(),
|
||||
source: mapAdminKuaishouCloudFulfillmentSource(saved),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {AdminFulfillmentBindingLookupInput} [payload] */
|
||||
export async function lookupAdminFulfillmentBindingOrder(payload = /** @type {AdminFulfillmentBindingLookupInput} */ ({})) {
|
||||
const { provider, platform, shopId, platformOrderId } = normalizeFulfillmentLookupPayload(payload)
|
||||
|
||||
const detailResult = await enrichAgisoXianyuTradeOrder({
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: '',
|
||||
platformOrderId,
|
||||
orderStatus: 'created',
|
||||
payStatus: 'unpaid',
|
||||
buyerId: '',
|
||||
buyerName: '',
|
||||
receiverContact: '',
|
||||
totalAmount: 0,
|
||||
currency: 'CNY',
|
||||
paidAt: null,
|
||||
rawPayload: {},
|
||||
items: [],
|
||||
}, {
|
||||
requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`,
|
||||
})
|
||||
|
||||
const { detail } = resolveFulfillmentLookupDetail(detailResult, platformOrderId)
|
||||
const bindings = getOrderFulfillmentBindingConfigs()
|
||||
return buildFulfillmentLookupResult({
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
platformOrderId,
|
||||
detail,
|
||||
detailResult,
|
||||
bindings,
|
||||
fallbackShopName: getAgisoShopConfig(shopId)?.shopName,
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {AdminFulfillmentBindingConfigSaveInput} [payload] */
|
||||
export async function updateAdminFulfillmentBindingConfigs(
|
||||
payload = /** @type {AdminFulfillmentBindingConfigSaveInput} */ ({}),
|
||||
) {
|
||||
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
|
||||
const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput)
|
||||
const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings)
|
||||
await syncConfiguredFulfillmentBindings()
|
||||
|
||||
return {
|
||||
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||
bindings: saved.map(mapAdminFulfillmentBindingConfigItem),
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAdminFulfillmentBindingConfigs(bindings = []) {
|
||||
assertAdminFulfillmentBindingsInput(bindings)
|
||||
|
||||
const seenKeys = new Set()
|
||||
const kuaishouEticketSource = getKuaishouEticketSourceConfig()
|
||||
const normalizedBindings = []
|
||||
|
||||
for (const [index, rawBinding] of bindings.entries()) {
|
||||
const normalized = normalizeAdminFulfillmentBindingItem(rawBinding, {
|
||||
index,
|
||||
kuaishouEticketSource,
|
||||
resolveKuaishouEticketShopConfig,
|
||||
})
|
||||
|
||||
const profile = await getFulfillmentProfileByKey(normalized.profileKey)
|
||||
if (!profile) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${normalized.profileKey}`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_profile_key',
|
||||
})
|
||||
}
|
||||
|
||||
const uniqueKey = buildAdminFulfillmentBindingUniqueKey(normalized)
|
||||
|
||||
if (seenKeys.has(uniqueKey)) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_fulfillment_bindings_duplicate_rule',
|
||||
})
|
||||
}
|
||||
|
||||
seenKeys.add(uniqueKey)
|
||||
normalizedBindings.push(normalized)
|
||||
}
|
||||
|
||||
return normalizedBindings
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// @ts-check
|
||||
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { normalizeProductName } from '../../order/product-match-service.js'
|
||||
import { isPlainObject } from './context.js'
|
||||
import { resolveFulfillmentBindingMatchShopId } from './domain.js'
|
||||
|
||||
export function assertAdminFulfillmentBindingsInput(bindings) {
|
||||
if (Array.isArray(bindings)) {
|
||||
return
|
||||
}
|
||||
|
||||
throw createHttpError('履约配置格式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_payload',
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeAdminFulfillmentBindingItem(
|
||||
rawBinding,
|
||||
options = {},
|
||||
) {
|
||||
const index = Number(options.index || 0)
|
||||
const kuaishouEticketSource = /** @type {Record<string, unknown>} */ (options.kuaishouEticketSource || {})
|
||||
const resolveKuaishouEticketShopConfig = /** @type {(shop: Record<string, unknown>, source: Record<string, unknown>) => Record<string, unknown> | null} */ (
|
||||
options.resolveKuaishouEticketShopConfig || (() => null)
|
||||
)
|
||||
if (!isPlainObject(rawBinding)) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则格式不正确`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_item',
|
||||
})
|
||||
}
|
||||
|
||||
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(rawBinding.platform || '').trim()
|
||||
let shopId = String(rawBinding.shopId || '').trim()
|
||||
let shopName = String(rawBinding.shopName || '').trim()
|
||||
const khhaoShopId = String(rawBinding.khhaoShopId || '').trim()
|
||||
const skuCode = String(rawBinding.skuCode || '').trim()
|
||||
const skuName = String(rawBinding.skuName || '').trim()
|
||||
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
|
||||
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {}
|
||||
|
||||
if (provider === 'khhao' && platform === 'kuaishou') {
|
||||
if (!khhaoShopId) {
|
||||
throw createHttpError(`第 ${index + 1} 条快手规则缺少 khhao 店铺 ID`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_khhao_shop_id',
|
||||
})
|
||||
}
|
||||
|
||||
const matchedShop = resolveKuaishouEticketShopConfig(
|
||||
{
|
||||
shopId,
|
||||
shopName,
|
||||
},
|
||||
kuaishouEticketSource,
|
||||
)
|
||||
const matchedShopId = String(matchedShop?.shopId || '').trim()
|
||||
const matchedShopName = String(matchedShop?.kshopName || '').trim()
|
||||
|
||||
if (!matchedShopId || !matchedShopName) {
|
||||
throw createHttpError(
|
||||
`第 ${index + 1} 条快手规则的店铺未匹配到已配置的快手官方店铺,请先到“平台配置”补齐 Cookie 后再选择`,
|
||||
{
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_kuaishou_shop_not_configured',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
shopId = matchedShopId
|
||||
shopName = matchedShopName
|
||||
config.kuaishouShop = {
|
||||
...(isPlainObject(config.kuaishouShop) ? config.kuaishouShop : {}),
|
||||
shopId,
|
||||
shopName,
|
||||
khhaoShopId,
|
||||
}
|
||||
}
|
||||
|
||||
if (!skuCode) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_sku_code',
|
||||
})
|
||||
}
|
||||
|
||||
if (!externalItemId && !externalSkuCode && !externalSkuName) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_match_condition',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName,
|
||||
khhaoShopId,
|
||||
skuCode,
|
||||
skuName,
|
||||
profileKey,
|
||||
enabled: rawBinding.enabled !== false,
|
||||
priority: rawBinding.priority,
|
||||
config,
|
||||
match: {
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
externalSkuName,
|
||||
config: isPlainObject(match.config) ? match.config : {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAdminFulfillmentBindingUniqueKey(binding) {
|
||||
return [
|
||||
String(binding?.provider || '').trim(),
|
||||
String(binding?.platform || '').trim(),
|
||||
resolveFulfillmentBindingMatchShopId({
|
||||
provider: binding?.provider,
|
||||
platform: binding?.platform,
|
||||
shopId: binding?.shopId,
|
||||
khhaoShopId: binding?.khhaoShopId,
|
||||
}),
|
||||
String(binding?.match?.externalItemId || '').trim(),
|
||||
String(binding?.match?.externalSkuCode || '').trim(),
|
||||
normalizeProductName(String(binding?.match?.externalSkuName || '').trim()),
|
||||
String(binding?.skuCode || '').trim(),
|
||||
].join('::')
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
assertAdminFulfillmentBindingsInput,
|
||||
buildAdminFulfillmentBindingUniqueKey,
|
||||
normalizeAdminFulfillmentBindingItem,
|
||||
} from './validation.js'
|
||||
|
||||
test('assertAdminFulfillmentBindingsInput rejects non-array payloads', () => {
|
||||
assert.doesNotThrow(() => assertAdminFulfillmentBindingsInput([]))
|
||||
assert.throws(() => assertAdminFulfillmentBindingsInput({}), /履约配置格式不正确/)
|
||||
})
|
||||
|
||||
test('normalizeAdminFulfillmentBindingItem normalizes kuaishou binding shop mapping', () => {
|
||||
const result = normalizeAdminFulfillmentBindingItem(
|
||||
{
|
||||
provider: 'khhao',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'legacy-shop',
|
||||
shopName: '旧店铺',
|
||||
khhaoShopId: 'khhao-1',
|
||||
skuCode: 'sku-1',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
config: {
|
||||
kuaishouShop: {
|
||||
note: 'keep',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
kuaishouEticketSource: { source: true },
|
||||
resolveKuaishouEticketShopConfig() {
|
||||
return {
|
||||
shopId: 'ks-100',
|
||||
kshopName: '快手店铺A',
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
provider: 'khhao',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'ks-100',
|
||||
shopName: '快手店铺A',
|
||||
khhaoShopId: 'khhao-1',
|
||||
skuCode: 'sku-1',
|
||||
skuName: '',
|
||||
profileKey: 'manual_review',
|
||||
enabled: true,
|
||||
priority: undefined,
|
||||
config: {
|
||||
kuaishouShop: {
|
||||
note: 'keep',
|
||||
shopId: 'ks-100',
|
||||
shopName: '快手店铺A',
|
||||
khhaoShopId: 'khhao-1',
|
||||
},
|
||||
},
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
config: {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeAdminFulfillmentBindingItem rejects missing sku and match conditions', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAdminFulfillmentBindingItem(
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
},
|
||||
{ index: 1 },
|
||||
),
|
||||
/第 2 条规则缺少内部履约 SKU/,
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAdminFulfillmentBindingItem(
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
skuCode: 'sku-1',
|
||||
},
|
||||
{ index: 2 },
|
||||
),
|
||||
/第 3 条规则至少需要一种外部匹配条件/,
|
||||
)
|
||||
})
|
||||
|
||||
test('buildAdminFulfillmentBindingUniqueKey uses normalized external sku name and shop id rules', () => {
|
||||
assert.equal(
|
||||
buildAdminFulfillmentBindingUniqueKey({
|
||||
provider: 'khhao',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-a',
|
||||
khhaoShopId: 'khhao-a',
|
||||
skuCode: 'sku-1',
|
||||
match: {
|
||||
externalItemId: '',
|
||||
externalSkuCode: '',
|
||||
externalSkuName: ' 礼包 A ',
|
||||
},
|
||||
}),
|
||||
'khhao::kuaishou::khhao-a::::::礼包 a::sku-1',
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user