From 0029147bc4d0e0a143eeb2d45cd1249a729254b9 Mon Sep 17 00:00:00 2001 From: yml Date: Sun, 12 Apr 2026 13:16:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=96=E9=83=A8=E6=B5=8B=E8=AF=95=E5=86=85?= =?UTF-8?q?=E9=83=A8=E7=BB=91=E5=AE=9Aok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/config/default.cjs | 1 - apps/backend/data/agiso-shops.json | 7 + .../data/order-fulfillment-bindings.json | 23 + apps/backend/scripts/seed-dev-data.js | 1 + apps/backend/src/config/runtime.js | 5 - .../db/migrations/002_product_match_rules.sql | 30 + .../src/repositories/inventory-repo.js | 6 + .../repositories/product-match-rule-repo.js | 123 +++ .../src/routes/admin/platform-config.js | 34 +- .../src/services/admin/admin-service.js | 201 ++++ .../fulfillment-bootstrap-service.js | 64 +- .../services/order/delivery-task-service.js | 6 +- .../fulfillment-binding-config-service.js | 121 +++ .../src/services/order/order-service.js | 13 +- .../services/order/product-match-service.js | 113 +++ .../src/services/order/webhook-service.js | 96 +- .../agiso/xianyu/order-detail-service.js | 95 +- .../src/components/admin/AdminStatusTag.vue | 3 +- apps/frontend/src/router/index.ts | 4 + apps/frontend/src/services/admin.ts | 17 + apps/frontend/src/types/admin.ts | 31 + apps/frontend/src/utils/admin-display.ts | 1 + apps/frontend/src/utils/admin-options.ts | 9 + .../admin/AdminFulfillmentBindingsView.vue | 945 ++++++++++++++++++ .../src/views/admin/AdminInventoryView.vue | 886 ++++++++++++++-- apps/frontend/src/views/admin/AdminLayout.vue | 3 +- .../views/admin/AdminPlatformShopsView.vue | 2 +- docker-compose.dev.yml | 1 - docker-compose.yml | 1 - docs/咸鱼商品详情.logs | 31 + docs/商品识别与履约SKU配置.md | 77 ++ 31 files changed, 2826 insertions(+), 124 deletions(-) create mode 100644 apps/backend/data/order-fulfillment-bindings.json create mode 100644 apps/backend/src/db/migrations/002_product_match_rules.sql create mode 100644 apps/backend/src/repositories/product-match-rule-repo.js create mode 100644 apps/backend/src/services/order/fulfillment-binding-config-service.js create mode 100644 apps/backend/src/services/order/product-match-service.js create mode 100644 apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue create mode 100644 docs/咸鱼商品详情.logs create mode 100644 docs/商品识别与履约SKU配置.md diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs index 394d3a60..f1429115 100644 --- a/apps/backend/config/default.cjs +++ b/apps/backend/config/default.cjs @@ -35,7 +35,6 @@ module.exports = { orders: { claimBaseUrl: 'http://127.0.0.1:5173/#/claim', tokenTtlHours: 24, - fulfillmentBindings: [], }, admin: { diff --git a/apps/backend/data/agiso-shops.json b/apps/backend/data/agiso-shops.json index 4a695c5c..810ba8fe 100644 --- a/apps/backend/data/agiso-shops.json +++ b/apps/backend/data/agiso-shops.json @@ -1,10 +1,17 @@ { "693760716": { + "enabled": true, "shopName": "大锤商行", "accessToken": "AldsIdlefmykv7w6xdvgf9r2ymsxrmznux74tyaehc9pwygdnrgr4" }, "2037495620": { + "enabled": true, "shopName": "羊小胖。", "accessToken": "AldsIdle4925bh8c9n3ka79fxauc9egk7uea6f6frfbbb5btvxpvu" + }, + "2209880145223": { + "enabled": true, + "shopName": "大锤号商", + "accessToken": "AldsIdleu5rzb68g8gh32sk9e8rbunes6gwyg59aph2nn5em6fp4r" } } diff --git a/apps/backend/data/order-fulfillment-bindings.json b/apps/backend/data/order-fulfillment-bindings.json new file mode 100644 index 00000000..2c821051 --- /dev/null +++ b/apps/backend/data/order-fulfillment-bindings.json @@ -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": "三角洲行动-自动领取" + } + } + } +] diff --git a/apps/backend/scripts/seed-dev-data.js b/apps/backend/scripts/seed-dev-data.js index 1eda9581..50b0f7b9 100644 --- a/apps/backend/scripts/seed-dev-data.js +++ b/apps/backend/scripts/seed-dev-data.js @@ -24,6 +24,7 @@ const RESET_TABLES = [ 'order_items', 'orders', 'webhook_events', + 'product_match_rules', 'inventory_items', ] diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index 892a7497..4e915e9b 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -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 diff --git a/apps/backend/src/db/migrations/002_product_match_rules.sql b/apps/backend/src/db/migrations/002_product_match_rules.sql new file mode 100644 index 00000000..b500c726 --- /dev/null +++ b/apps/backend/src/db/migrations/002_product_match_rules.sql @@ -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); diff --git a/apps/backend/src/repositories/inventory-repo.js b/apps/backend/src/repositories/inventory-repo.js index 3b1413f5..4d6cb676 100644 --- a/apps/backend/src/repositories/inventory-repo.js +++ b/apps/backend/src/repositories/inventory-repo.js @@ -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}`) diff --git a/apps/backend/src/repositories/product-match-rule-repo.js b/apps/backend/src/repositories/product-match-rule-repo.js new file mode 100644 index 00000000..1d88ab4b --- /dev/null +++ b/apps/backend/src/repositories/product-match-rule-repo.js @@ -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 +} diff --git a/apps/backend/src/routes/admin/platform-config.js b/apps/backend/src/routes/admin/platform-config.js index 9d2603ec..6b499fe8 100644 --- a/apps/backend/src/routes/admin/platform-config.js +++ b/apps/backend/src/routes/admin/platform-config.js @@ -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 diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index 68a3324d..44c5bb09 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -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() diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js index 55a0792b..6f84dde9 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js @@ -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) -} diff --git a/apps/backend/src/services/order/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js index 1fee9c5a..0ba39552 100644 --- a/apps/backend/src/services/order/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -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', diff --git a/apps/backend/src/services/order/fulfillment-binding-config-service.js b/apps/backend/src/services/order/fulfillment-binding-config-service.js new file mode 100644 index 00000000..5fb39185 --- /dev/null +++ b/apps/backend/src/services/order/fulfillment-binding-config-service.js @@ -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]' +} diff --git a/apps/backend/src/services/order/order-service.js b/apps/backend/src/services/order/order-service.js index 60d57b56..2840f62a 100644 --- a/apps/backend/src/services/order/order-service.js +++ b/apps/backend/src/services/order/order-service.js @@ -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) { diff --git a/apps/backend/src/services/order/product-match-service.js b/apps/backend/src/services/order/product-match-service.js new file mode 100644 index 00000000..46dbec0f --- /dev/null +++ b/apps/backend/src/services/order/product-match-service.js @@ -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]' +} diff --git a/apps/backend/src/services/order/webhook-service.js b/apps/backend/src/services/order/webhook-service.js index da1e400d..01a5f9bc 100644 --- a/apps/backend/src/services/order/webhook-service.js +++ b/apps/backend/src/services/order/webhook-service.js @@ -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 : {} } diff --git a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.js b/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.js index 77944c86..459bf230 100644 --- a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.js +++ b/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.js @@ -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 diff --git a/apps/frontend/src/components/admin/AdminStatusTag.vue b/apps/frontend/src/components/admin/AdminStatusTag.vue index 1cda9846..2389f4b6 100644 --- a/apps/frontend/src/components/admin/AdminStatusTag.vue +++ b/apps/frontend/src/components/admin/AdminStatusTag.vue @@ -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: '管理员', diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index b672e623..8320b119 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -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'), diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index d0273225..c740085f 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -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('/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> }) { + return apiPost<{ + filePath: string + bindings: AdminFulfillmentBindingConfigItem[] + }>('/api/v1/admin/platform-config/fulfillment-bindings', payload) +} + export function fetchAdminOrders(params?: Record) { return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params) } diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index 126e4b9e..46abd97e 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -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 + match: { + externalSkuCode: string + externalItemId: string + externalSkuName: string + config: Record + } +} + +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 diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index 44e2a855..b0195958 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -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) diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 0f709720..6b098eb4 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -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' }, diff --git a/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue b/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue new file mode 100644 index 00000000..45006f7a --- /dev/null +++ b/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue @@ -0,0 +1,945 @@ + + + + + diff --git a/apps/frontend/src/views/admin/AdminInventoryView.vue b/apps/frontend/src/views/admin/AdminInventoryView.vue index 07c29687..be65b92d 100644 --- a/apps/frontend/src/views/admin/AdminInventoryView.vue +++ b/apps/frontend/src/views/admin/AdminInventoryView.vue @@ -1,24 +1,61 @@