外部测试内部绑定ok

This commit is contained in:
yml
2026-04-12 13:16:50 +08:00
parent a95247cd01
commit 0029147bc4
31 changed files with 2826 additions and 124 deletions
-1
View File
@@ -35,7 +35,6 @@ module.exports = {
orders: {
claimBaseUrl: 'http://127.0.0.1:5173/#/claim',
tokenTtlHours: 24,
fulfillmentBindings: [],
},
admin: {
+7
View File
@@ -1,10 +1,17 @@
{
"693760716": {
"enabled": true,
"shopName": "大锤商行",
"accessToken": "AldsIdlefmykv7w6xdvgf9r2ymsxrmznux74tyaehc9pwygdnrgr4"
},
"2037495620": {
"enabled": true,
"shopName": "羊小胖。",
"accessToken": "AldsIdle4925bh8c9n3ka79fxauc9egk7uea6f6frfbbb5btvxpvu"
},
"2209880145223": {
"enabled": true,
"shopName": "大锤号商",
"accessToken": "AldsIdleu5rzb68g8gh32sk9e8rbunes6gwyg59aph2nn5em6fp4r"
}
}
@@ -0,0 +1,23 @@
[
{
"provider": "agiso",
"platform": "xianyu",
"shopId": "693760716",
"skuCode": "sjz_hdl_test01",
"skuName": "三角洲行动-自动领取",
"profileKey": "tencent_claim_redeem",
"enabled": true,
"priority": 100,
"config": {
"note": "三角洲行动腾讯兑换码自动领取"
},
"match": {
"externalSkuCode": "6228372634012",
"externalItemId": "1040695550204",
"externalSkuName": "周边",
"config": {
"resolvedSkuName": "三角洲行动-自动领取"
}
}
}
]
+1
View File
@@ -24,6 +24,7 @@ const RESET_TABLES = [
'order_items',
'orders',
'webhook_events',
'product_match_rules',
'inventory_items',
]
-5
View File
@@ -161,11 +161,6 @@ function applyEnvOverrides(baseConfig) {
nextConfig.orders.tokenTtlHours = tokenTtlHours
}
const fulfillmentBindings = parseJsonArray(process.env.ORDER_FULFILLMENT_BINDINGS_JSON)
if (fulfillmentBindings) {
nextConfig.orders.fulfillmentBindings = fulfillmentBindings
}
const adminSessionSecret = String(process.env.ADMIN_SESSION_SECRET || '').trim()
if (adminSessionSecret) {
nextConfig.admin.sessionSecret = adminSessionSecret
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS product_match_rules (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL DEFAULT '',
shop_id TEXT NOT NULL DEFAULT '',
external_item_id TEXT NOT NULL DEFAULT '',
external_sku_code TEXT NOT NULL DEFAULT '',
external_sku_name TEXT NOT NULL DEFAULT '',
external_sku_name_normalized TEXT NOT NULL DEFAULT '',
resolved_sku_code TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
priority INTEGER NOT NULL DEFAULT 100,
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_product_match_rules_unique
ON product_match_rules(
provider,
platform,
shop_id,
external_item_id,
external_sku_code,
external_sku_name_normalized,
resolved_sku_code
);
CREATE INDEX IF NOT EXISTS idx_product_match_rules_lookup
ON product_match_rules(provider, platform, shop_id, enabled, priority, id);
@@ -115,6 +115,7 @@ export async function listInventoryItems({
page = 1,
pageSize = 20,
skuCode = '',
credentialType = '',
status = '',
batchNo = '',
} = {}) {
@@ -132,6 +133,11 @@ export async function listInventoryItems({
filters.push(`ii.status = $${params.length}`)
}
if (credentialType) {
params.push(credentialType)
filters.push(`ii.credential_type = $${params.length}`)
}
if (batchNo) {
params.push(`%${batchNo}%`)
filters.push(`ii.batch_no ILIKE $${params.length}`)
@@ -0,0 +1,123 @@
import { query } from '../db/client.js'
export async function upsertProductMatchRule(input) {
const result = await query(
`
INSERT INTO product_match_rules (
provider,
platform,
shop_id,
external_item_id,
external_sku_code,
external_sku_name,
external_sku_name_normalized,
resolved_sku_code,
enabled,
priority,
config_json,
created_at,
updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13
)
ON CONFLICT (
provider,
platform,
shop_id,
external_item_id,
external_sku_code,
external_sku_name_normalized,
resolved_sku_code
) DO UPDATE
SET
external_sku_name = EXCLUDED.external_sku_name,
enabled = EXCLUDED.enabled,
priority = EXCLUDED.priority,
config_json = EXCLUDED.config_json,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[
input.provider || '',
input.platform || '',
input.shopId || '',
input.externalItemId || '',
input.externalSkuCode || '',
input.externalSkuName || '',
input.externalSkuNameNormalized || '',
input.resolvedSkuCode,
input.enabled !== false,
Number(input.priority || 100),
input.configJson || '{}',
input.createdAt,
input.updatedAt,
],
)
return result.rows[0] || null
}
export async function resolveProductMatchRule({
provider = '',
platform = '',
shopId = '',
externalItemId = '',
externalSkuCode = '',
externalSkuNameNormalized = '',
}) {
const result = await query(
`
SELECT
pmr.*,
CASE
WHEN $4 != '' AND pmr.external_sku_code = $4 THEN 'external_sku_code'
WHEN $5 != '' AND pmr.external_item_id = $5 THEN 'external_item_id'
WHEN $6 != '' AND pmr.external_sku_name_normalized = $6 THEN 'external_sku_name_exact'
WHEN $6 != '' AND pmr.external_sku_name_normalized != '' AND (
position(pmr.external_sku_name_normalized in $6) > 0
OR position($6 in pmr.external_sku_name_normalized) > 0
) THEN 'external_sku_name_contains'
ELSE ''
END AS matched_by,
CASE
WHEN $4 != '' AND pmr.external_sku_code = $4 THEN 400
WHEN $5 != '' AND pmr.external_item_id = $5 THEN 300
WHEN $6 != '' AND pmr.external_sku_name_normalized = $6 THEN 200
WHEN $6 != '' AND pmr.external_sku_name_normalized != '' AND (
position(pmr.external_sku_name_normalized in $6) > 0
OR position($6 in pmr.external_sku_name_normalized) > 0
) THEN 100
ELSE 0
END AS match_score
FROM product_match_rules pmr
WHERE pmr.enabled = TRUE
AND (pmr.provider = '' OR pmr.provider = $1)
AND (pmr.platform = '' OR pmr.platform = $2)
AND (pmr.shop_id = '' OR pmr.shop_id = $3)
AND (
($4 != '' AND pmr.external_sku_code = $4)
OR ($5 != '' AND pmr.external_item_id = $5)
OR (
$6 != ''
AND pmr.external_sku_name_normalized != ''
AND (
pmr.external_sku_name_normalized = $6
OR position(pmr.external_sku_name_normalized in $6) > 0
OR position($6 in pmr.external_sku_name_normalized) > 0
)
)
)
ORDER BY
match_score DESC,
CASE WHEN pmr.shop_id = '' THEN 1 ELSE 0 END,
CASE WHEN pmr.platform = '' THEN 1 ELSE 0 END,
CASE WHEN pmr.provider = '' THEN 1 ELSE 0 END,
pmr.priority ASC,
pmr.id ASC
LIMIT 1
`,
[provider, platform, shopId, externalSkuCode, externalItemId, externalSkuNameNormalized],
)
return result.rows[0] || null
}
@@ -1,6 +1,11 @@
import { Router } from 'express'
import { getAdminAgisoShopConfigs, updateAdminAgisoShopConfigs } from '../../services/admin/admin-service.js'
import {
getAdminAgisoShopConfigs,
getAdminFulfillmentBindingConfigs,
updateAdminAgisoShopConfigs,
updateAdminFulfillmentBindingConfigs,
} from '../../services/admin/admin-service.js'
import { createJsonHandler, requireAdminRoles } from './shared.js'
const router = Router()
@@ -34,4 +39,31 @@ router.post('/platform-config/agiso-shops', createJsonHandler(
},
))
router.get('/platform-config/fulfillment-bindings', createJsonHandler(
() => getAdminFulfillmentBindingConfigs(),
{
successMessage: 'ok',
errorMessage: '读取履约配置失败',
scope: '[admin/platform-config/fulfillment-bindings]',
},
))
router.post('/platform-config/fulfillment-bindings', createJsonHandler(
(req) => updateAdminFulfillmentBindingConfigs(req.body),
{
successMessage: '履约配置已保存',
errorMessage: '保存履约配置失败',
scope: '[admin/platform-config/fulfillment-bindings]',
audit: (_req, data) => ({
action: 'platform_fulfillment_config_updated',
targetType: 'platform_config',
targetId: 'fulfillment_bindings',
data: {
bindingCount: data.bindings.length,
filePath: data.filePath,
},
}),
},
))
export default router
@@ -7,6 +7,12 @@ import {
getAgisoShopsFilePath,
saveAgisoShopConfigMap,
} from '../platforms/agiso/shop-config-service.js'
import {
getOrderFulfillmentBindingConfigs,
getOrderFulfillmentBindingsFilePath,
saveOrderFulfillmentBindingConfigs,
} from '../order/fulfillment-binding-config-service.js'
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
import { getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js'
import {
createInventoryItems,
@@ -31,6 +37,8 @@ import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../../utils/m
import { nowIso } from '../../utils/time.js'
import { reserveInventoryForTask } from '../order/inventory-service.js'
import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js'
import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js'
import { normalizeProductName } from '../order/product-match-service.js'
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
export async function getAdminDashboardSummary() {
@@ -294,6 +302,7 @@ export async function getAdminInventoryItems(query = {}) {
page,
pageSize,
skuCode: String(query.skuCode || '').trim(),
credentialType: String(query.credentialType || '').trim(),
status: String(query.status || '').trim(),
batchNo: String(query.batchNo || '').trim(),
})
@@ -581,6 +590,145 @@ export function updateAdminAgisoShopConfigs(payload = {}) {
}
}
export async function getAdminFulfillmentBindingConfigs() {
const bindings = getOrderFulfillmentBindingConfigs()
const rowsResult = await 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
`,
)
return {
filePath: getOrderFulfillmentBindingsFilePath(),
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
observedProducts: rowsResult.rows.map((row) => {
const item = {
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),
}
return {
...item,
configured: bindings.some((binding) => matchesObservedProduct(binding, item)),
}
}),
}
}
export async function updateAdminFulfillmentBindingConfigs(payload = {}) {
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
await validateAdminFulfillmentBindingConfigs(bindingsInput)
const saved = saveOrderFulfillmentBindingConfigs(bindingsInput)
await syncConfiguredFulfillmentBindings()
return {
filePath: getOrderFulfillmentBindingsFilePath(),
bindings: saved.map(mapAdminFulfillmentBindingConfigItem),
}
}
async function validateAdminFulfillmentBindingConfigs(bindings = []) {
if (!Array.isArray(bindings)) {
throw createHttpError('履约配置格式不正确', {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_payload',
})
}
const seenKeys = new Set()
for (const [index, rawBinding] of bindings.entries()) {
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()
const shopId = String(rawBinding.shopId || '').trim()
const skuCode = String(rawBinding.skuCode || '').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()
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',
})
}
const profile = await getFulfillmentProfileByKey(profileKey)
if (!profile) {
throw createHttpError(`${index + 1} 条规则使用了不存在的履约方式: ${profileKey}`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_profile_key',
})
}
const uniqueKey = [
provider,
platform,
shopId,
externalItemId,
externalSkuCode,
normalizeProductName(externalSkuName),
skuCode,
].join('::')
if (seenKeys.has(uniqueKey)) {
throw createHttpError(`${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, {
statusCode: 409,
errorCode: 'admin_fulfillment_bindings_duplicate_rule',
})
}
seenKeys.add(uniqueKey)
}
}
export async function retryAdminTask(taskId) {
const task = await getRequiredTask(taskId)
const now = nowIso()
@@ -934,6 +1082,55 @@ function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummar
}
}
function mapAdminFulfillmentBindingConfigItem(item) {
const match = item?.match || {}
return {
provider: String(item?.provider || '').trim(),
platform: String(item?.platform || '').trim(),
shopId: String(item?.shopId || '').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 || {},
},
}
}
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())
)
}
async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) {
const headers = normalizeRecord(safeParseJson(item.headers_json))
const query = normalizeRecord(safeParseJson(item.query_json))
@@ -1666,6 +1863,10 @@ function normalizeRecord(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function extractWebhookPayload(body) {
const normalizedBody = normalizeRecord(body)
const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim()
@@ -1,4 +1,3 @@
import { runtimeConfig } from '../../config/runtime.js'
import {
getFulfillmentProfileByKey,
listFulfillmentProfileRequirements,
@@ -6,6 +5,10 @@ import {
upsertFulfillmentProfile,
upsertSkuFulfillmentBinding,
} from '../../repositories/fulfillment-profile-repo.js'
import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js'
import { normalizeProductName } from '../order/product-match-service.js'
import { query } from '../../db/client.js'
import { getOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js'
import { nowIso } from '../../utils/time.js'
const CORE_PROFILES = [
@@ -37,6 +40,11 @@ const CORE_PROFILES = [
]
export async function ensureFulfillmentCatalogBootstrapped() {
const profileMap = await ensureCoreProfiles()
await syncConfiguredFulfillmentBindings(profileMap)
}
async function ensureCoreProfiles() {
const timestamp = nowIso()
const profileMap = {}
@@ -64,7 +72,15 @@ export async function ensureFulfillmentCatalogBootstrapped() {
}
}
const bindingsToApply = normalizeConfiguredBindings(runtimeConfig.orders?.fulfillmentBindings)
return profileMap
}
export async function syncConfiguredFulfillmentBindings(profileMap = {}) {
const timestamp = nowIso()
const bindingsToApply = getOrderFulfillmentBindingConfigs()
await query('DELETE FROM product_match_rules')
await query('DELETE FROM sku_fulfillment_bindings')
for (const binding of bindingsToApply) {
const profile = profileMap[binding.profileKey] || await getFulfillmentProfileByKey(binding.profileKey)
@@ -84,24 +100,30 @@ export async function ensureFulfillmentCatalogBootstrapped() {
createdAt: timestamp,
updatedAt: timestamp,
})
const match = binding.match || {}
const externalSkuName = String(match.externalSkuName || '').trim()
const externalItemId = String(match.externalItemId || '').trim()
const externalSkuCode = String(match.externalSkuCode || '').trim()
if (!externalSkuName && !externalItemId && !externalSkuCode) {
continue
}
await upsertProductMatchRule({
provider: binding.provider,
platform: binding.platform,
shopId: binding.shopId,
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized: normalizeProductName(externalSkuName),
resolvedSkuCode: binding.skuCode,
enabled: binding.enabled,
priority: binding.priority,
configJson: JSON.stringify(match.config || {}),
createdAt: timestamp,
updatedAt: timestamp,
})
}
}
function normalizeConfiguredBindings(bindings) {
if (!Array.isArray(bindings)) {
return []
}
return bindings
.map((binding) => ({
skuCode: String(binding?.skuCode || '').trim(),
provider: String(binding?.provider || '').trim(),
platform: String(binding?.platform || '').trim(),
shopId: String(binding?.shopId || '').trim(),
profileKey: String(binding?.profileKey || '').trim() || 'manual_review',
enabled: binding?.enabled !== false,
priority: Number(binding?.priority || 100),
config: binding?.config || {},
}))
.filter((binding) => binding.skuCode)
}
@@ -77,6 +77,7 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
profileName: String(profile.profile_name || profile.name || ''),
skuCode: item.sku_code,
skuName: item.sku_name,
inventorySkuCode: item.sku_code,
primaryRequirement: primaryRequirement
? {
roleKey: String(primaryRequirement.role_key || primaryRequirement.roleKey || 'primary_code'),
@@ -107,6 +108,7 @@ async function preparePaidTask(task) {
const now = nowIso()
const taskContext = parseTaskContext(task)
const primaryRequirement = taskContext.primaryRequirement || null
const inventorySkuCode = String(taskContext.inventorySkuCode || task.skuCode || '').trim()
if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed'].includes(task.task_status)) {
return task
@@ -122,7 +124,7 @@ async function preparePaidTask(task) {
})
}
if (!task.skuCode) {
if (!inventorySkuCode) {
return updateTask(task.id, {
task_status: 'manual_review',
last_error: '未匹配到 SKU,无法为任务分配库存凭据',
@@ -147,7 +149,7 @@ async function preparePaidTask(task) {
}
const reserved = await reserveInventoryForTask({
skuCode: task.skuCode,
skuCode: inventorySkuCode,
taskId: task.id,
credentialType: primaryRequirement.credentialType,
roleKey: primaryRequirement.roleKey || 'primary_code',
@@ -0,0 +1,121 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../config/runtime.js'
const ORDER_FULFILLMENT_BINDINGS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'order-fulfillment-bindings.json')
export function getOrderFulfillmentBindingsFilePath() {
return ORDER_FULFILLMENT_BINDINGS_FILE_PATH
}
export function getOrderFulfillmentBindingConfigs() {
return loadOrderFulfillmentBindingConfigsFromFile()
}
export function saveOrderFulfillmentBindingConfigs(rawValue) {
const normalized = normalizeOrderFulfillmentBindingConfigs(rawValue)
fs.mkdirSync(path.dirname(ORDER_FULFILLMENT_BINDINGS_FILE_PATH), { recursive: true })
fs.writeFileSync(
ORDER_FULFILLMENT_BINDINGS_FILE_PATH,
`${JSON.stringify(normalized, null, 2)}\n`,
'utf8',
)
return normalized
}
function loadOrderFulfillmentBindingConfigsFromFile() {
if (!fs.existsSync(ORDER_FULFILLMENT_BINDINGS_FILE_PATH)) {
return []
}
try {
const rawText = fs.readFileSync(ORDER_FULFILLMENT_BINDINGS_FILE_PATH, 'utf8')
const parsed = JSON.parse(rawText)
return normalizeOrderFulfillmentBindingConfigs(parsed)
} catch {
return []
}
}
function normalizeOrderFulfillmentBindingConfigs(rawValue) {
if (!Array.isArray(rawValue)) {
return []
}
return rawValue
.map((item) => normalizeOrderFulfillmentBinding(item))
.filter(Boolean)
}
function normalizeOrderFulfillmentBinding(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const provider = String(rawValue.provider || 'agiso').trim() || 'agiso'
const platform = String(rawValue.platform || '').trim()
const shopId = String(rawValue.shopId || '').trim()
const skuCode = String(rawValue.skuCode || '').trim()
const skuName = String(rawValue.skuName || '').trim()
const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review'
const priority = normalizePriority(rawValue.priority)
const enabled = rawValue.enabled !== false
const config = normalizeJsonObject(rawValue.config)
const match = normalizeOrderFulfillmentMatch(rawValue.match)
if (!skuCode || !match) {
return null
}
return {
provider,
platform,
shopId,
skuCode,
skuName,
profileKey,
enabled,
priority,
config,
match,
}
}
function normalizeOrderFulfillmentMatch(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const externalSkuCode = String(rawValue.externalSkuCode || '').trim()
const externalItemId = String(rawValue.externalItemId || '').trim()
const externalSkuName = String(rawValue.externalSkuName || '').trim()
if (!externalSkuCode && !externalItemId && !externalSkuName) {
return null
}
return {
externalSkuCode,
externalItemId,
externalSkuName,
config: normalizeJsonObject(rawValue.config),
}
}
function normalizePriority(value) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return 100
}
return Math.max(1, Math.round(parsed))
}
function normalizeJsonObject(value) {
return isPlainObject(value) ? value : {}
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -4,6 +4,7 @@ import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
import { buildClaimUrl } from '../claim/claim-service.js'
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
import { ensureAgisoXianyuClaimMessageDeliveredForTask } from '../platforms/agiso/xianyu/message-service.js'
import { resolveOrderItemForFulfillment } from './product-match-service.js'
import { nowIso } from '../../utils/time.js'
import { logWebhook } from '../../utils/logger.js'
@@ -53,9 +54,18 @@ export async function upsertOrderFromWebhook(event) {
updatedAt: now,
})
const resolvedItems = await Promise.all(
event.items.map((item) => resolveOrderItemForFulfillment({
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
item,
})),
)
const orderItems = await replaceOrderItems(
order.id,
event.items.map((item) => ({
resolvedItems.map((item) => ({
skuCode: item.skuCode,
skuName: item.skuName,
quantity: item.quantity,
@@ -78,6 +88,7 @@ export async function upsertOrderFromWebhook(event) {
platformOrderId: order.platform_order_id,
orderItemCount: orderItems.length,
taskCount: tasks.length,
resolvedSkuCodes: resolvedItems.map((item) => item.skuCode),
})
for (const task of tasks) {
@@ -0,0 +1,113 @@
import { resolveProductMatchRule } from '../../repositories/product-match-rule-repo.js'
export async function resolveOrderItemForFulfillment({
provider = '',
platform = '',
shopId = '',
item = {},
}) {
const externalItemId = pickFirstNonEmpty([
item.externalItemId,
item.itemId,
])
const externalSkuCode = pickFirstNonEmpty([
item.externalSkuCode,
item.skuCode,
externalItemId,
])
const externalSkuName = pickFirstNonEmpty([
item.externalSkuName,
item.skuName,
externalSkuCode,
])
const externalSkuNameNormalized = normalizeProductName(externalSkuName)
const matchedRule = await resolveProductMatchRule({
provider,
platform,
shopId,
externalItemId,
externalSkuCode,
externalSkuNameNormalized,
})
const resolvedSkuCode = pickFirstNonEmpty([
matchedRule?.resolved_sku_code,
externalSkuCode,
externalItemId,
])
const resolvedSkuName = pickFirstNonEmpty([
readConfigValue(matchedRule?.config_json, 'resolvedSkuName'),
readConfigValue(matchedRule?.config_json, 'internalProductName'),
item.skuName,
externalSkuName,
resolvedSkuCode,
])
const snapshot = {
...(isPlainObject(item.snapshot) ? item.snapshot : {}),
externalItemId,
externalSkuCode,
externalSkuName,
externalSkuNameNormalized,
resolvedSkuCode,
matchedProductRuleId: matchedRule ? Number(matchedRule.id) : null,
matchedProductRuleBy: String(matchedRule?.matched_by || '').trim(),
}
return {
...item,
itemId: externalItemId,
externalItemId,
externalSkuCode,
externalSkuName,
skuCode: resolvedSkuCode,
skuName: resolvedSkuName,
snapshot,
}
}
export function normalizeProductName(value) {
return String(value || '')
.toLowerCase()
.replace(/[【】\[\]()()]/g, ' ')
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function pickFirstNonEmpty(values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function readConfigValue(rawValue, key) {
if (!rawValue) {
return ''
}
const parsed = typeof rawValue === 'string' ? safeParseJson(rawValue) : rawValue
if (!isPlainObject(parsed)) {
return ''
}
return String(parsed[key] || '').trim()
}
function safeParseJson(rawValue) {
try {
return JSON.parse(String(rawValue || '{}'))
} catch {
return null
}
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -533,7 +533,17 @@ function normalizeOrderItems(payload) {
return items.map((item) => {
const source = isPlainObject(item) ? item : {}
const skuDescriptor = parseExternalSkuDescriptor(
pickFirstNonEmpty([
source.sku,
source.Sku,
payload.sku,
payload.Sku,
payload._agisoTradeDetail?.sku,
]),
)
const rawSkuCandidates = [
skuDescriptor.externalSkuCode,
source.OuterSkuId,
source.outerSkuId,
source.outer_sku_id,
@@ -553,18 +563,31 @@ function normalizeOrderItems(payload) {
payload.itemId,
]
const skuCode = resolveSkuCode(rawSkuCandidates)
const externalItemId = pickFirstNonEmpty([
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
payload.item_id,
payload.itemId,
])
const externalSkuName = pickFirstNonEmpty([
skuDescriptor.externalSkuName,
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
])
return {
skuCode,
skuName: pickFirstNonEmpty([
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
]),
skuName: externalSkuName,
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
quantity: Math.max(
1,
normalizeInteger(
@@ -580,6 +603,11 @@ function normalizeOrderItems(payload) {
) || 1,
),
spec: source,
snapshot: {
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
},
}
})
}
@@ -756,6 +784,56 @@ function resolveSkuCode(rawKey) {
return ''
}
function parseExternalSkuDescriptor(rawValue) {
const raw = String(rawValue || '').trim()
if (!raw) {
return {
raw,
externalSkuCode: '',
externalSkuName: '',
}
}
const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean)
let externalSkuCode = ''
let externalSkuName = ''
for (const part of parts) {
if (!externalSkuCode && !part.includes(':') && !part.includes('')) {
externalSkuCode = part
continue
}
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
if (separatorIndex >= 0) {
const label = part.slice(0, separatorIndex).trim()
const value = part.slice(separatorIndex + 1).trim()
if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) {
externalSkuName = value
}
}
}
if (!externalSkuCode && parts[0]) {
externalSkuCode = parts[0]
}
if (!externalSkuName) {
externalSkuName = parts
.map((part) => {
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : ''
})
.find(Boolean) || ''
}
return {
raw,
externalSkuCode,
externalSkuName,
}
}
function normalizeRecord(value) {
return isPlainObject(value) ? value : {}
}
@@ -340,7 +340,16 @@ function normalizeOrderItems(payload, fallbackItems = []) {
return items.map((item) => {
const source = isPlainObject(item) ? item : {}
const skuDescriptor = parseExternalSkuDescriptor(
pickFirstNonEmpty([
source.sku,
source.Sku,
payload.sku,
payload.Sku,
]),
)
const skuCode = pickFirstNonEmpty([
skuDescriptor.externalSkuCode,
source.OuterSkuId,
source.outerSkuId,
source.outer_sku_id,
@@ -359,18 +368,31 @@ function normalizeOrderItems(payload, fallbackItems = []) {
payload.item_id,
payload.itemId,
])
const externalItemId = pickFirstNonEmpty([
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
payload.item_id,
payload.itemId,
])
const externalSkuName = pickFirstNonEmpty([
skuDescriptor.externalSkuName,
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
])
return {
skuCode,
skuName: pickFirstNonEmpty([
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
]),
skuName: externalSkuName,
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
quantity: Math.max(
1,
normalizeInteger(
@@ -386,10 +408,65 @@ function normalizeOrderItems(payload, fallbackItems = []) {
) || 1,
),
spec: source,
snapshot: {
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
},
}
})
}
function parseExternalSkuDescriptor(rawValue) {
const raw = String(rawValue || '').trim()
if (!raw) {
return {
raw,
externalSkuCode: '',
externalSkuName: '',
}
}
const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean)
let externalSkuCode = ''
let externalSkuName = ''
for (const part of parts) {
if (!externalSkuCode && !part.includes(':') && !part.includes('')) {
externalSkuCode = part
continue
}
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
if (separatorIndex >= 0) {
const label = part.slice(0, separatorIndex).trim()
const value = part.slice(separatorIndex + 1).trim()
if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) {
externalSkuName = value
}
}
}
if (!externalSkuCode && parts[0]) {
externalSkuCode = parts[0]
}
if (!externalSkuName) {
externalSkuName = parts
.map((part) => {
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : ''
})
.find(Boolean) || ''
}
return {
raw,
externalSkuCode,
externalSkuName,
}
}
function isAgisoDetailSuccess(payload) {
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
return false
@@ -8,7 +8,7 @@ const props = defineProps<{
function resolveTone(status: string) {
const normalized = String(status || '').trim().toLowerCase()
if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'success', 'active', 'system_bound', 'binding_completed'].includes(normalized)) {
if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'consumed', 'success', 'active', 'system_bound', 'binding_completed'].includes(normalized)) {
return 'success'
}
@@ -56,6 +56,7 @@ function resolveLabel(status: string) {
available: '可用',
reserved: '已预占',
delivered: '已发放',
consumed: '已发放',
invalid: '已作废',
active: '生效中',
admin: '管理员',
+4
View File
@@ -76,6 +76,10 @@ const router = createRouter({
path: 'platform-shops',
component: () => import('@/views/admin/AdminPlatformShopsView.vue'),
},
{
path: 'platform-fulfillment',
component: () => import('@/views/admin/AdminFulfillmentBindingsView.vue'),
},
{
path: 'webhook-events/:eventId',
component: () => import('@/views/admin/AdminWebhookEventDetailView.vue'),
+17
View File
@@ -3,7 +3,9 @@ import type {
AdminAgisoObservedShopItem,
AdminAgisoShopConfigItem,
AdminAuditLogItem,
AdminFulfillmentBindingConfigItem,
AdminInventoryItemListItem,
AdminObservedProductItem,
AdminTaskActionResponse,
AdminDashboardSummary,
AdminLoginResponse,
@@ -74,6 +76,21 @@ export function saveAdminAgisoShopConfigs(payload: { shops: Array<Record<string,
}>('/api/v1/admin/platform-config/agiso-shops', payload)
}
export function fetchAdminFulfillmentBindingConfigs() {
return apiGet<{
filePath: string
bindings: AdminFulfillmentBindingConfigItem[]
observedProducts: AdminObservedProductItem[]
}>('/api/v1/admin/platform-config/fulfillment-bindings')
}
export function saveAdminFulfillmentBindingConfigs(payload: { bindings: Array<Record<string, unknown>> }) {
return apiPost<{
filePath: string
bindings: AdminFulfillmentBindingConfigItem[]
}>('/api/v1/admin/platform-config/fulfillment-bindings', payload)
}
export function fetchAdminOrders(params?: Record<string, unknown>) {
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params)
}
+31
View File
@@ -69,6 +69,37 @@ export interface AdminAgisoObservedShopItem {
configured: boolean
}
export interface AdminFulfillmentBindingConfigItem {
provider: string
platform: string
shopId: string
skuCode: string
skuName: string
profileKey: string
enabled: boolean
priority: number
config: Record<string, unknown>
match: {
externalSkuCode: string
externalItemId: string
externalSkuName: string
config: Record<string, unknown>
}
}
export interface AdminObservedProductItem {
provider: string
platform: string
shopId: string
shopName: string
externalItemId: string
externalSkuCode: string
externalSkuName: string
latestSeenAt: string | null
orderItemCount: number
configured: boolean
}
export interface AdminDashboardSummary {
todayOrders: number
paidPendingClaim: number
+1
View File
@@ -31,6 +31,7 @@ export function formatAuditAction(action: string) {
inventory_item_invalidated: '作废库存项',
webhook_replayed: '重放 Webhook',
platform_shop_config_updated: '更新店铺配置',
platform_fulfillment_config_updated: '更新履约配置',
}
return formatStatusWithRaw(action, labelMap)
+9
View File
@@ -31,6 +31,15 @@ export const adminInventoryStatusOptions = [
{ label: '已作废', value: 'invalid' },
]
export const adminInventoryCredentialTypeOptions = [
{ label: '全部凭据类型', value: '' },
{ label: '腾讯兑换码(自动领取)', value: 'tencent_code' },
{ label: '卡号卡密', value: 'card_password' },
{ label: '账号密码', value: 'account_password' },
{ label: '激活链接', value: 'activation_link' },
{ label: '纯文本凭据', value: 'text_credential' },
]
export const adminWebhookProcessedOptions = [
{ label: '全部', value: '' },
{ label: '处理成功', value: '1' },
@@ -0,0 +1,945 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchAdminFulfillmentBindingConfigs,
saveAdminFulfillmentBindingConfigs,
} from '@/services/admin'
import type { AdminFulfillmentBindingConfigItem, AdminObservedProductItem } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
type EditableBinding = {
id: string
provider: string
platform: string
shopId: string
skuCode: string
skuName: string
profileKey: string
enabled: boolean
priority: number
externalSkuCode: string
externalItemId: string
externalSkuName: string
resolvedSkuName: string
}
type SaveBindingPayload = {
provider: string
platform: string
shopId: string
skuCode: string
skuName: string
profileKey: string
enabled: boolean
priority: number
match: {
externalSkuCode: string
externalItemId: string
externalSkuName: string
config: {
resolvedSkuName: string
}
}
}
type ValidationField = 'skuCode' | 'match' | 'profileKey'
type ValidationState = {
bindingId: string
field: ValidationField
message: string
} | null
const PROFILE_OPTIONS = [
{ label: '腾讯领取兑换', value: 'tencent_claim_redeem' },
{ label: '人工发货', value: 'manual_review' },
]
const loading = ref(true)
const saving = ref(false)
const errorMessage = ref('')
const filePath = ref('')
const bindings = ref<EditableBinding[]>([])
const observedProducts = ref<AdminObservedProductItem[]>([])
const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value))
const validationState = ref<ValidationState>(null)
const collapsedBindingIds = ref<string[]>([])
function createEmptyBinding(): EditableBinding {
return {
id: crypto.randomUUID(),
provider: 'agiso',
platform: '',
shopId: '',
skuCode: '',
skuName: '',
profileKey: 'manual_review',
enabled: true,
priority: 100,
externalSkuCode: '',
externalItemId: '',
externalSkuName: '',
resolvedSkuName: '',
}
}
function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding {
return {
id: crypto.randomUUID(),
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shopId,
skuCode: item.skuCode,
skuName: item.skuName,
profileKey: item.profileKey || 'manual_review',
enabled: item.enabled !== false,
priority: item.priority || 100,
externalSkuCode: item.match.externalSkuCode,
externalItemId: item.match.externalItemId,
externalSkuName: item.match.externalSkuName,
resolvedSkuName: String(item.match.config?.resolvedSkuName || ''),
}
}
function hasMatchCondition(item: Pick<EditableBinding, 'externalSkuCode' | 'externalItemId' | 'externalSkuName'>) {
return Boolean(item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim())
}
function isEditableBindingComplete(item: EditableBinding) {
return Boolean(item.skuCode.trim() && hasMatchCondition(item) && VALID_PROFILE_KEYS.has(item.profileKey.trim() || 'manual_review'))
}
function buildCollapsedIds(items: EditableBinding[]) {
return items.filter(isEditableBindingComplete).map((item) => item.id)
}
function isBindingCollapsed(bindingId: string) {
return collapsedBindingIds.value.includes(bindingId)
}
function setBindingCollapsed(bindingId: string, collapsed: boolean) {
const next = new Set(collapsedBindingIds.value)
if (collapsed) {
next.add(bindingId)
} else {
next.delete(bindingId)
}
collapsedBindingIds.value = Array.from(next)
}
function toggleBindingCollapsed(bindingId: string) {
setBindingCollapsed(bindingId, !isBindingCollapsed(bindingId))
}
function getBindingTitle(binding: EditableBinding, index: number) {
const internalName = binding.skuName.trim()
const externalName = binding.externalSkuName.trim()
const skuCode = binding.skuCode.trim()
return internalName || externalName || skuCode || `规则 ${index + 1}`
}
function getBindingStatusLabel(binding: EditableBinding) {
return isEditableBindingComplete(binding) ? '已完成' : '待完善'
}
function getBindingSummary(binding: EditableBinding) {
const parts = [
binding.provider.trim() || 'agiso',
binding.platform.trim() || '未选平台',
binding.shopId.trim() || '跨店铺',
binding.profileKey.trim() || 'manual_review',
]
return parts.join(' / ')
}
async function loadConfigs() {
if (!hasAdminRole('admin')) {
loading.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
const response = await fetchAdminFulfillmentBindingConfigs()
filePath.value = response.data.filePath
const nextBindings = response.data.bindings.map(mapEditableBinding)
bindings.value = nextBindings
collapsedBindingIds.value = buildCollapsedIds(nextBindings)
observedProducts.value = response.data.observedProducts
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取履约配置失败'
} finally {
loading.value = false
}
}
function addBinding() {
validationState.value = null
const next = createEmptyBinding()
setBindingCollapsed(next.id, false)
bindings.value.unshift(next)
}
function removeBinding(id: string) {
if (validationState.value?.bindingId === id) {
validationState.value = null
}
setBindingCollapsed(id, false)
bindings.value = bindings.value.filter((item) => item.id !== id)
}
function importObservedProduct(item: AdminObservedProductItem) {
validationState.value = null
const nextBinding = {
id: crypto.randomUUID(),
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shopId,
skuCode: '',
skuName: item.externalSkuName,
profileKey: 'manual_review',
enabled: true,
priority: 100,
externalSkuCode: item.externalSkuCode,
externalItemId: item.externalItemId,
externalSkuName: item.externalSkuName,
resolvedSkuName: item.externalSkuName,
}
setBindingCollapsed(nextBinding.id, false)
bindings.value.unshift(nextBinding)
}
function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload {
return {
provider: item.provider.trim() || 'agiso',
platform: item.platform.trim(),
shopId: item.shopId.trim(),
skuCode: item.skuCode.trim(),
skuName: item.skuName.trim(),
profileKey: item.profileKey.trim() || 'manual_review',
enabled: item.enabled,
priority: Number(item.priority || 100),
match: {
externalSkuCode: item.externalSkuCode.trim(),
externalItemId: item.externalItemId.trim(),
externalSkuName: item.externalSkuName.trim(),
config: {
resolvedSkuName: item.resolvedSkuName.trim(),
},
},
}
}
function hasBindingContent(item: SaveBindingPayload) {
return Boolean(
item.platform
|| item.shopId
|| item.skuCode
|| item.skuName
|| item.match.externalSkuCode
|| item.match.externalItemId
|| item.match.externalSkuName
|| item.match.config.resolvedSkuName
|| item.provider !== 'agiso'
|| item.profileKey !== 'manual_review'
|| item.priority !== 100
|| item.enabled !== true,
)
}
function resolveBindingValidationState(item: SaveBindingPayload, index: number, bindingId: string): ValidationState {
const label = `${index + 1} 条规则`
if (!item.skuCode) {
return {
bindingId,
field: 'skuCode',
message: `${label} 还没填写“内部履约 SKU”`,
}
}
if (!item.match.externalSkuCode && !item.match.externalItemId && !item.match.externalSkuName) {
return {
bindingId,
field: 'match',
message: `${label} 至少填写一种外部匹配条件`,
}
}
if (!VALID_PROFILE_KEYS.has(item.profileKey)) {
return {
bindingId,
field: 'profileKey',
message: `${label} 的履约方式无效`,
}
}
return null
}
async function focusValidationTarget(state: ValidationState) {
if (!state?.bindingId) {
return
}
setBindingCollapsed(state.bindingId, false)
await nextTick()
const card = document.querySelector<HTMLElement>(`[data-binding-id="${state.bindingId}"]`)
if (!card) {
return
}
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
const selectors: Record<ValidationField, string> = {
skuCode: '[data-field="skuCode"]',
match: '[data-field="externalSkuCode"]',
profileKey: '[data-field="profileKey"]',
}
const target = card.querySelector<HTMLInputElement | HTMLSelectElement>(selectors[state.field])
target?.focus()
}
function clearValidationState() {
validationState.value = null
}
function isBindingInvalid(bindingId: string) {
return validationState.value?.bindingId === bindingId
}
function isFieldInvalid(bindingId: string, field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName') {
if (validationState.value?.bindingId !== bindingId) {
return false
}
if (validationState.value.field === 'match') {
return ['externalSkuCode', 'externalItemId', 'externalSkuName'].includes(field)
}
return validationState.value.field === field
}
async function saveConfigs() {
const normalizedBindings = bindings.value.map(normalizeBindingForSave)
const nonEmptyBindings = normalizedBindings.filter(hasBindingContent)
const invalidState = normalizedBindings.reduce<ValidationState>((state, item, index) => {
if (state || !hasBindingContent(item)) {
return state
}
return resolveBindingValidationState(item, index, bindings.value[index]?.id || '')
}, null)
if (invalidState) {
validationState.value = invalidState
await focusValidationTarget(invalidState)
return
}
if (bindings.value.length > 0 && nonEmptyBindings.length === 0) {
validationState.value = {
bindingId: '',
field: 'skuCode',
message: '当前没有可保存的规则,请至少填写内部履约 SKU 和外部匹配条件',
}
return
}
const payload = nonEmptyBindings
saving.value = true
validationState.value = null
try {
const response = await saveAdminFulfillmentBindingConfigs({ bindings: payload })
filePath.value = response.data.filePath
bindings.value = response.data.bindings.map(mapEditableBinding)
ElMessage.success('履约配置已保存')
await loadConfigs()
} catch (error) {
validationState.value = {
bindingId: '',
field: 'skuCode',
message: error instanceof Error ? error.message : '保存履约配置失败',
}
} finally {
saving.value = false
}
}
onMounted(loadConfigs)
</script>
<template>
<section class="admin-panel">
<header class="panel-header">
<div>
<h1>履约配置</h1>
<p>把外部商品识别成内部履约 SKU再决定绑定库存还是转人工保存后立即同步到后端规则表</p>
</div>
<div class="header-actions">
<el-button round @click="loadConfigs">刷新</el-button>
<el-button round type="primary" @click="addBinding">新增规则</el-button>
</div>
</header>
<div v-if="!hasAdminRole('admin')" class="empty-block">仅管理员可以维护履约配置</div>
<template v-else>
<section class="meta-card">
<div class="meta-line">
<span class="meta-label">配置文件</span>
<code>{{ filePath || '-' }}</code>
</div>
<div class="meta-line">
<span class="meta-label">说明</span>
<span>每条规则同时定义外部商品匹配条件内部履约 SKU / 履约方式未命中的商品会进入人工处理</span>
</div>
<div class="meta-line">
<span class="meta-label">字段怎么理解</span>
<div class="hint-grid">
<div class="hint-item">
<strong>外部 SKU 编码 / 外部商品 ID / 外部商品名</strong>
<span>这是平台或店铺回传的原始商品标识用来识别你卖的是哪一种商品</span>
</div>
<div class="hint-item">
<strong>内部履约 SKU</strong>
<span>这是你系统里的统一商品编码后续库存绑定自动履约人工处理都靠它继续往下走</span>
</div>
<div class="hint-item">
<strong>内部商品名</strong>
<span>主要给后台展示看建议填你们内部真正认识的商品名</span>
</div>
<div class="hint-item">
<strong>履约方式</strong>
<span>`腾讯领取兑换` 表示走自动库存链路`人工发货` 表示落到人工处理</span>
</div>
</div>
</div>
<div class="meta-line">
<span class="meta-label">内部履约 SKU 怎么填</span>
<div class="sku-guide">
<span>推荐用你们内部统一命名不要直接抄店铺里的外部 SKU</span>
<span>建议格式`游戏或业务_商品类型_规格`例如 `delta_havabi_500``dnf_cdk_blackdiamond``manual_peripheral_common`</span>
<span>原则是同一种履约商品无论来自哪个平台哪个店铺都尽量落到同一个内部履约 SKU</span>
</div>
</div>
</section>
<p v-if="errorMessage" class="error-copy">{{ errorMessage }}</p>
<div v-if="loading" class="empty-block">履约配置加载中</div>
<template v-else>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>已配置规则</h3>
<p>建议至少填写平台店铺外部 SKU 编码或外部商品名再指定内部履约 SKU 和履约档案</p>
</div>
<el-button :loading="saving" round type="primary" @click="saveConfigs">保存配置</el-button>
</div>
<p v-if="validationState?.message" class="validation-banner">{{ validationState.message }}</p>
<div v-if="bindings.length === 0" class="empty-inline">当前还没有履约规则先新增一条</div>
<div
v-for="(binding, index) in bindings"
:key="binding.id"
:data-binding-id="binding.id"
:class="['binding-card', { 'binding-card--invalid': isBindingInvalid(binding.id) }]"
>
<div class="binding-header">
<div class="binding-summary">
<div class="binding-title-row">
<strong>{{ getBindingTitle(binding, index) }}</strong>
<span :class="['binding-status-chip', { 'binding-status-chip--done': isEditableBindingComplete(binding) }]">
{{ getBindingStatusLabel(binding) }}
</span>
</div>
<span class="binding-subtle">{{ getBindingSummary(binding) }}</span>
</div>
<button class="collapse-button" type="button" @click="toggleBindingCollapsed(binding.id)">
{{ isBindingCollapsed(binding.id) ? '展开' : '折叠' }}
</button>
</div>
<div v-if="isBindingCollapsed(binding.id)" class="binding-collapsed-preview">
<span>外部商品{{ binding.externalSkuName || binding.externalSkuCode || binding.externalItemId || '-' }}</span>
<span>内部履约 SKU{{ binding.skuCode || '-' }}</span>
<span>履约方式{{ PROFILE_OPTIONS.find((item) => item.value === binding.profileKey)?.label || binding.profileKey }}</span>
</div>
<div v-else class="binding-grid">
<label class="field-block">
<span>渠道</span>
<input v-model="binding.provider" class="text-input" placeholder="agiso" @input="clearValidationState" />
</label>
<label class="field-block">
<span>平台</span>
<input v-model="binding.platform" class="text-input" placeholder="xianyu / taobao / pdd" @input="clearValidationState" />
</label>
<label class="field-block">
<span>店铺 ID</span>
<input v-model="binding.shopId" class="text-input" placeholder="留空表示跨店铺通用" @input="clearValidationState" />
</label>
<label class="field-block">
<span>优先级</span>
<input v-model.number="binding.priority" class="text-input" type="number" min="1" @input="clearValidationState" />
</label>
<label class="field-block">
<span>外部 SKU 编码</span>
<input
v-model="binding.externalSkuCode"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuCode') }]"
data-field="externalSkuCode"
placeholder="例如 6046726460016"
@input="clearValidationState"
/>
</label>
<label class="field-block">
<span>外部商品 ID</span>
<input
v-model="binding.externalItemId"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalItemId') }]"
data-field="externalItemId"
placeholder="例如 1033324289962"
@input="clearValidationState"
/>
</label>
<label class="field-block field-wide">
<span>外部商品名</span>
<input
v-model="binding.externalSkuName"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'externalSkuName') }]"
data-field="externalSkuName"
placeholder="例如 奥利奥动作"
@input="clearValidationState"
/>
</label>
<label class="field-block">
<span>内部履约 SKU</span>
<input
v-model="binding.skuCode"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'skuCode') }]"
data-field="skuCode"
placeholder="例如 sjz_hdl_test01"
@input="clearValidationState"
/>
<small class="field-help">填你们系统内部统一 SKU相同履约商品尽量跨平台共用同一个内部 SKU</small>
</label>
<label class="field-block">
<span>内部商品名</span>
<input v-model="binding.skuName" class="text-input" placeholder="用于后台展示" @input="clearValidationState" />
</label>
<label class="field-block">
<span>履约方式</span>
<select
v-model="binding.profileKey"
:class="['text-input', { 'text-input--invalid': isFieldInvalid(binding.id, 'profileKey') }]"
data-field="profileKey"
@change="clearValidationState"
>
<option v-for="item in PROFILE_OPTIONS" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
</label>
<label class="field-block">
<span>匹配后展示名</span>
<input
v-model="binding.resolvedSkuName"
class="text-input"
placeholder="例如 三角洲行动-自动领取"
@input="clearValidationState"
/>
</label>
</div>
<p v-if="isBindingInvalid(binding.id)" class="binding-inline-error">{{ validationState?.message }}</p>
<div class="binding-actions">
<label class="checkbox-line">
<input v-model="binding.enabled" type="checkbox" />
<span>启用规则</span>
</label>
<el-button link type="danger" @click="removeBinding(binding.id)">删除</el-button>
</div>
</div>
</section>
<section class="table-card">
<div class="section-title-row">
<div>
<h3>近期识别到的外部商品</h3>
<p>从已入库订单里提取的外部商品信息可一键导入规则后补充内部履约 SKU</p>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>来源</th>
<th>外部商品</th>
<th>订单商品数</th>
<th>最近出现时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in observedProducts" :key="`${item.provider}:${item.platform}:${item.shopId}:${item.externalSkuCode}:${item.externalItemId}:${item.externalSkuName}`">
<td>
<div class="cell-stack">
<strong>{{ item.platform || '-' }}</strong>
<span class="cell-subtle">{{ item.provider }} · {{ item.shopName || item.shopId || '全店铺' }}</span>
</div>
</td>
<td>
<div class="cell-stack">
<strong>{{ item.externalSkuName || item.externalSkuCode || item.externalItemId || '-' }}</strong>
<span class="cell-subtle">SKU: {{ item.externalSkuCode || '-' }}</span>
<span class="cell-subtle">ItemId: {{ item.externalItemId || '-' }}</span>
</div>
</td>
<td>{{ item.orderItemCount }}</td>
<td>{{ formatAdminDateTime(item.latestSeenAt) }}</td>
<td>{{ item.configured ? '已配置' : '未配置' }}</td>
<td>
<el-button
v-if="!item.configured"
link
type="primary"
@click="importObservedProduct(item)"
>
导入到规则
</el-button>
<span v-else class="cell-subtle">已存在</span>
</td>
</tr>
<tr v-if="observedProducts.length === 0">
<td colspan="6" class="empty-inline">最近还没有识别到可用的外部商品</td>
</tr>
</tbody>
</table>
</section>
</template>
</template>
</section>
</template>
<style scoped>
.admin-panel {
display: grid;
gap: 16px;
}
.panel-header,
.section-title-row {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.panel-header h1,
.section-title-row h3 {
margin: 0;
color: #1d3555;
}
.panel-header p,
.section-title-row p {
margin: 8px 0 0;
color: #64748b;
}
.header-actions {
display: flex;
gap: 10px;
}
.meta-card,
.table-card,
.empty-block,
.error-copy {
padding: 18px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(86, 108, 138, 0.1);
}
.meta-card {
display: grid;
gap: 10px;
}
.hint-grid,
.sku-guide {
display: grid;
gap: 8px;
}
.hint-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.hint-item {
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid rgba(148, 163, 184, 0.14);
}
.hint-item strong {
color: #1d3555;
font-size: 13px;
}
.hint-item span,
.sku-guide span {
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.meta-line {
display: grid;
gap: 6px;
}
.meta-label {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.binding-card {
margin-top: 14px;
padding: 16px;
border-radius: 18px;
background: #f8fafc;
border: 1px solid rgba(148, 163, 184, 0.18);
}
.binding-card--invalid {
border-color: rgba(220, 38, 38, 0.3);
box-shadow: 0 0 0 4px rgba(254, 226, 226, 0.85);
}
.binding-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 12px;
}
.binding-summary {
display: grid;
gap: 4px;
}
.binding-title-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
color: #1d3555;
}
.binding-subtle {
color: #64748b;
font-size: 13px;
}
.binding-status-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 2px 10px;
background: #fff1f2;
color: #be123c;
font-size: 12px;
font-weight: 700;
}
.binding-status-chip--done {
background: #ecfdf3;
color: #027a48;
}
.collapse-button {
border: 1px solid rgba(86, 108, 138, 0.18);
background: rgba(255, 255, 255, 0.96);
color: #1d3555;
border-radius: 999px;
padding: 8px 14px;
font: inherit;
cursor: pointer;
}
.binding-collapsed-preview {
display: grid;
gap: 6px;
color: #64748b;
font-size: 13px;
}
.binding-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.validation-banner {
margin: 14px 0 0;
padding: 12px 14px;
border-radius: 14px;
background: #fff4f2;
border: 1px solid rgba(244, 63, 94, 0.18);
color: #b42318;
font-weight: 600;
}
.field-block {
display: grid;
gap: 8px;
}
.field-block span {
color: #475467;
font-size: 12px;
font-weight: 700;
}
.field-help {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.field-wide {
grid-column: 1 / -1;
}
.text-input {
width: 100%;
min-height: 42px;
padding: 0 14px;
border-radius: 14px;
border: 1px solid rgba(86, 108, 138, 0.18);
background: rgba(255, 255, 255, 0.96);
}
.text-input--invalid {
border-color: rgba(220, 38, 38, 0.45);
background: #fff8f8;
}
.text-input--invalid:focus {
outline: none;
border-color: rgba(220, 38, 38, 0.6);
box-shadow: 0 0 0 3px rgba(254, 226, 226, 0.95);
}
.binding-inline-error {
margin: 12px 0 0;
color: #b42318;
font-size: 13px;
font-weight: 600;
}
.binding-actions {
margin-top: 12px;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.checkbox-line {
display: inline-flex;
gap: 8px;
align-items: center;
color: #475467;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
.data-table th,
.data-table td {
padding: 12px 10px;
text-align: left;
border-bottom: 1px solid rgba(86, 108, 138, 0.08);
color: #334155;
vertical-align: top;
}
.data-table th {
color: #64748b;
font-size: 13px;
}
.cell-stack {
display: grid;
gap: 4px;
}
.cell-subtle {
color: #64748b;
font-size: 12px;
}
.empty-block,
.empty-inline {
color: #64748b;
}
.error-copy {
color: #b42318;
}
@media (max-width: 980px) {
.panel-header,
.section-title-row,
.binding-actions,
.binding-header {
flex-direction: column;
align-items: stretch;
}
.hint-grid,
.binding-grid {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -20,7 +20,8 @@ const navItems = computed(() => {
if (isAdmin.value) {
baseItems.splice(1, 0, { to: '/admin/users', label: '用户' })
baseItems.push({ to: '/admin/platform-shops', label: '店铺配置' })
baseItems.push({ to: '/admin/platform-shops', label: 'Agiso店铺' })
baseItems.push({ to: '/admin/platform-fulfillment', label: '履约配置' })
baseItems.push({ to: '/admin/audit-logs', label: '审计' })
}
@@ -118,7 +118,7 @@ onMounted(loadConfigs)
<section class="admin-panel">
<header class="panel-header">
<div>
<h1>店铺配置</h1>
<h1>Agiso 店铺配置</h1>
<p>Agiso 多店铺配置改为文件维护保存后立即生效不需要再手改 `.env`</p>
</div>
<div class="header-actions">
-1
View File
@@ -47,7 +47,6 @@ services:
TENCENT_SESSION_DEBUG: ${TENCENT_SESSION_DEBUG:-false}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
ORDER_FULFILLMENT_BINDINGS_JSON: ${ORDER_FULFILLMENT_BINDINGS_JSON:-[]}
AGISO_APP_SECRET: ${AGISO_APP_SECRET:-}
AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false}
AGISO_APP_ID: ${AGISO_APP_ID:-}
-1
View File
@@ -40,7 +40,6 @@ services:
TENCENT_SESSION_DEBUG: ${TENCENT_SESSION_DEBUG:-false}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
ORDER_FULFILLMENT_BINDINGS_JSON: ${ORDER_FULFILLMENT_BINDINGS_JSON:-[]}
AGISO_APP_SECRET: ${AGISO_APP_SECRET:-}
AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false}
AGISO_APP_ID: ${AGISO_APP_ID:-}
+31
View File
@@ -0,0 +1,31 @@
{
"item_id": 1033324289962,
"seller_id": 2209880145223,
"MsgTypeDes": "订单已付款",
"biz_order_id": "2701827483088138281",
"order_status": 2,
"_agisoTradeDetail": {
"sku": "6046726460016|商品名称:奥利奥动作",
"item": {
"price": 70,
"title": "【自动发货】蛋仔派对奥利奥皮肤、奥利奥呦呦、奥利奥动作",
"item_id": 1033324289962,
"pic_url": "https://gw.alicdn.com/bao/uploaded/i1/2209880145223/O1CN01QAHaJL1oSBmQaGffk_!!4611686018427387207-53-xy_item.heic"
},
"payment": 70,
"end_time": 0,
"pay_time": 1775953431000,
"post_fee": 0,
"isEticket": false,
"ship_time": 1775953434000,
"buy_amount": 1,
"buyer_nick": "梦里梦",
"isRecharge": false,
"create_time": 1775953408000,
"seller_nick": "大锤号商",
"biz_order_id": "2701827483088138281",
"order_status": 3,
"coupon_eticket_info": [],
"encryption_buyer_id": "enV7xJs5DRbJBzIH8uUUcg=="
}
}
+77
View File
@@ -0,0 +1,77 @@
# 商品识别与履约 SKU 配置
当前后端已支持把外部商品先识别为内部履约 SKU,再继续匹配库存或人工履约。
配置来源已经统一为后台管理页和后端数据文件,不再使用 `.env` 里的 `ORDER_FULFILLMENT_BINDINGS_JSON`
## 配置目标
- `match` 负责识别外部商品
- `skuCode` 代表内部履约 SKU
- `profileKey` 决定后续走库存自动链路还是人工链路
## 推荐配置格式
后台保存后会写入后端数据文件:
- `/app/data/order-fulfillment-bindings.json`
配置结构如下:
```json
[
{
"provider": "agiso",
"platform": "xianyu",
"shopId": "2209880145223",
"skuCode": "sjz_hdl_test01",
"profileKey": "tencent_claim_redeem",
"priority": 10,
"config": {
"note": "三角洲行动腾讯兑换码自动领取"
},
"match": {
"externalSkuCode": "6228372634012",
"externalItemId": "1040695550204",
"externalSkuName": "周边",
"config": {
"resolvedSkuName": "三角洲行动-自动领取"
}
}
},
{
"provider": "agiso",
"platform": "xianyu",
"shopId": "2209880145223",
"skuCode": "danzai_manual_special",
"profileKey": "manual_review",
"priority": 20,
"match": {
"externalSkuCode": "7000000000001",
"externalSkuName": "需要人工发货的商品"
}
}
]
```
## 匹配顺序
系统会按下面顺序匹配外部商品:
1. `externalSkuCode`
2. `externalItemId`
3. 归一化后的 `externalSkuName`
4. 名称包含关系兜底
其中店铺级规则优先于平台级和全局级规则。
## 当前落库行为
- 订单商品的 `sku_code` 会保存为内部履约 SKU
- 原始外部商品信息会保留在 `item_snapshot_json`
- 后续履约任务、库存预占、领取链接都会按内部履约 SKU 继续执行
## 生效方式
- 后台保存后会立即同步到 `sku_fulfillment_bindings`
- 同时会同步到 `product_match_rules`
- 未匹配到规则的商品会自动落到人工处理