增加feifei履约配置界面

This commit is contained in:
yml2213
2026-07-07 14:02:56 +08:00
parent fcb3ac3b38
commit 92d819c616
17 changed files with 927 additions and 11 deletions
+1
View File
@@ -58,6 +58,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
version: "1",
},
kuaishouFeifei: {
enabled: true,
baseUrl: "http://skin-exchange.yiquyou.icu",
appKey: "",
appSecret: "",
@@ -2,6 +2,7 @@ import { Router } from "express";
import { requireAdminRoles } from "./session.js";
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
import kuaishouFeifeiRouter from "./platform-config/kuaishou-feifei.js";
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
import ninetyoneRouter from "./platform-config/ninetyone.js";
import notificationsRouter from "./platform-config/notifications.js";
@@ -11,6 +12,7 @@ const router = Router();
router.use("/platform-config", requireAdminRoles(["admin"]));
router.use("/platform-config", notificationsRouter);
router.use("/platform-config", kuaishouEticketRouter);
router.use("/platform-config", kuaishouFeifeiRouter);
router.use("/platform-config", ninetyoneRouter);
router.use("/platform-config", cloudtentaclesRouter);
@@ -0,0 +1,62 @@
import { Router } from "express";
import {
getAdminKuaishouFeifeiConfig,
matchAdminKuaishouFeifeiProduct,
updateAdminKuaishouFeifeiConfig,
} from "../../../services/admin/platform-config/kuaishou-feifei-service.js";
import { createJsonHandler } from "../session.js";
import type { JsonRecord } from "../../../types/json.js";
const router = Router();
router.get(
"/kuaishou-feifei",
createJsonHandler(() => getAdminKuaishouFeifeiConfig(), {
successMessage: "ok",
errorMessage: "读取 kuaishou-feifei 配置失败",
scope: "[admin/platform-config/kuaishou-feifei]",
})
);
router.post(
"/kuaishou-feifei",
createJsonHandler(
(req) => updateAdminKuaishouFeifeiConfig(req.body as JsonRecord),
{
successMessage: "kuaishou-feifei 配置已保存",
errorMessage: "保存 kuaishou-feifei 配置失败",
scope: "[admin/platform-config/kuaishou-feifei]",
audit: (_req, data) => {
const result = data as JsonRecord;
const source = result.source as JsonRecord | undefined;
return {
action: "platform_kuaishou_feifei_config_updated",
targetType: "platform_config",
targetId: "kuaishou_feifei",
data: {
filePath: String(result.filePath || "").trim(),
enabled: source?.enabled !== false,
ruleCount: Array.isArray(source?.productRules)
? source.productRules.length
: 0,
},
};
},
}
)
);
router.post(
"/kuaishou-feifei/match",
createJsonHandler(
(req) => matchAdminKuaishouFeifeiProduct(req.body as JsonRecord),
{
successMessage: "ok",
errorMessage: "匹配 kuaishou-feifei 商品失败",
scope: "[admin/platform-config/kuaishou-feifei/match]",
}
)
);
export default router;
@@ -0,0 +1,70 @@
import { getKuaishouFeifeiConfig } from '../../platforms/kuaishou-feifei/config.js'
import { resolveKuaishouFeifeiProductByName } from '../../platforms/kuaishou-feifei/product-rule-service.js'
import {
getKuaishouFeifeiConfigFilePath,
getKuaishouFeifeiSourceConfig,
hasKuaishouFeifeiConfigFile,
normalizeKuaishouFeifeiSourceConfig,
saveKuaishouFeifeiSourceConfig,
} from '../../platforms/kuaishou-feifei/source-config-service.js'
import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js'
type JsonObject = Record<string, any>
export function getAdminKuaishouFeifeiConfig() {
const source = getAdminEditableKuaishouFeifeiConfig()
const effective = getKuaishouFeifeiConfig()
return {
filePath: getKuaishouFeifeiConfigFilePath(),
source,
effective: mapEffectiveKuaishouFeifeiConfig(effective),
}
}
export function updateAdminKuaishouFeifeiConfig(payload: JsonObject = {}) {
const saved = saveKuaishouFeifeiSourceConfig(payload)
const effective = getKuaishouFeifeiConfig()
return {
filePath: getKuaishouFeifeiConfigFilePath(),
source: saved,
effective: mapEffectiveKuaishouFeifeiConfig(effective),
}
}
export function matchAdminKuaishouFeifeiProduct(payload: JsonObject = {}) {
const productName = String(payload.productName || '').trim()
const source = getAdminEditableKuaishouFeifeiConfig()
const match = resolveKuaishouFeifeiProductByName(productName, {
enabled: source.enabled,
rules: source.productRules,
})
return {
productName,
normalizedProductName: normalizeCloudtentaclesMatchName(productName),
matched: Boolean(match),
match,
}
}
function getAdminEditableKuaishouFeifeiConfig() {
if (hasKuaishouFeifeiConfigFile()) {
return getKuaishouFeifeiSourceConfig()
}
return normalizeKuaishouFeifeiSourceConfig(getKuaishouFeifeiConfig())
}
function mapEffectiveKuaishouFeifeiConfig(config: ReturnType<typeof getKuaishouFeifeiConfig>) {
return {
enabled: config.enabled !== false,
baseUrl: config.baseUrl,
timeoutMs: config.timeoutMs,
notifyUrl: config.notifyUrl,
hasAppKey: Boolean(config.appKey),
hasAppSecret: Boolean(config.appSecret),
productRuleCount: config.productRules.length,
}
}
@@ -1,6 +1,10 @@
import { runtimeConfig } from '../../../config/runtime.js'
import { createHttpError } from '../../../utils/http.js'
import type { RuntimeConfig } from '../../../types/runtime-config.js'
import {
getKuaishouFeifeiSourceConfig,
hasKuaishouFeifeiConfigFile,
} from './source-config-service.js'
export const KUAISHOU_FEIFEI_EXECUTOR_KEY = 'kuaishou_feifei'
export const KUAISHOU_FEIFEI_PROFILE_KEY = 'kuaishou_feifei'
@@ -8,12 +12,14 @@ export const KUAISHOU_FEIFEI_PROFILE_KEY = 'kuaishou_feifei'
type KuaishouFeifeiRuntimeConfig = RuntimeConfig['platforms']['kuaishouFeifei']
export function getKuaishouFeifeiConfig(overrides: Partial<KuaishouFeifeiRuntimeConfig> = {}) {
const config = {
...(runtimeConfig.platforms?.kuaishouFeifei || {}),
...overrides,
}
const runtimeValue = runtimeConfig.platforms?.kuaishouFeifei || {}
const savedValue = hasKuaishouFeifeiConfigFile()
? getKuaishouFeifeiSourceConfig()
: null
const config = mergeKuaishouFeifeiConfig(runtimeValue, savedValue, overrides)
return {
enabled: config.enabled !== false,
baseUrl: String(config.baseUrl || 'http://skin-exchange.yiquyou.icu').trim().replace(/\/+$/, ''),
appKey: String(config.appKey || '').trim(),
appSecret: String(config.appSecret || '').trim(),
@@ -26,6 +32,13 @@ export function getKuaishouFeifeiConfig(overrides: Partial<KuaishouFeifeiRuntime
export function assertKuaishouFeifeiConfig() {
const config = getKuaishouFeifeiConfig()
if (config.enabled === false) {
throw createHttpError('kuaishou-feifei 已停用', {
statusCode: 409,
errorCode: 'kuaishou_feifei_disabled',
})
}
if (!config.baseUrl) {
throw createHttpError('kuaishou-feifei baseUrl 未配置', {
statusCode: 500,
@@ -42,3 +55,41 @@ export function assertKuaishouFeifeiConfig() {
return config
}
function mergeKuaishouFeifeiConfig(
runtimeValue: Partial<KuaishouFeifeiRuntimeConfig>,
savedValue: Partial<KuaishouFeifeiRuntimeConfig> | null,
overrides: Partial<KuaishouFeifeiRuntimeConfig>,
): KuaishouFeifeiRuntimeConfig {
const base: KuaishouFeifeiRuntimeConfig = savedValue
? {
enabled: savedValue.enabled !== false,
baseUrl: savedValue.baseUrl || runtimeValue.baseUrl || 'http://skin-exchange.yiquyou.icu',
appKey: savedValue.appKey || runtimeValue.appKey || '',
appSecret: savedValue.appSecret || runtimeValue.appSecret || '',
timeoutMs: savedValue.timeoutMs || runtimeValue.timeoutMs || 10000,
notifyUrl: savedValue.notifyUrl || runtimeValue.notifyUrl || '',
productRules: Array.isArray(savedValue.productRules)
? savedValue.productRules
: runtimeValue.productRules || [],
}
: {
enabled: runtimeValue.enabled !== false,
baseUrl: runtimeValue.baseUrl || 'http://skin-exchange.yiquyou.icu',
appKey: runtimeValue.appKey || '',
appSecret: runtimeValue.appSecret || '',
timeoutMs: runtimeValue.timeoutMs || 10000,
notifyUrl: runtimeValue.notifyUrl || '',
productRules: runtimeValue.productRules || [],
}
return {
enabled: overrides.enabled ?? base.enabled,
baseUrl: overrides.baseUrl ?? base.baseUrl,
appKey: overrides.appKey ?? base.appKey,
appSecret: overrides.appSecret ?? base.appSecret,
timeoutMs: overrides.timeoutMs ?? base.timeoutMs,
notifyUrl: overrides.notifyUrl ?? base.notifyUrl,
productRules: overrides.productRules ?? base.productRules,
}
}
@@ -1,5 +1,5 @@
import { runtimeConfig } from '../../../config/runtime.js'
import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js'
import { getKuaishouFeifeiConfig } from './config.js'
import type { KuaishouFeifeiProductRule } from '../../../types/runtime-config.js'
export type KuaishouFeifeiProductMatch = {
@@ -12,15 +12,19 @@ export type KuaishouFeifeiProductMatch = {
export function resolveKuaishouFeifeiProductByName(
productName: unknown,
options: {
rules?: KuaishouFeifeiProductRule[]
enabled?: boolean
} = {},
): KuaishouFeifeiProductMatch | null {
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
if (!normalizedProductName) {
return null
}
const rules = listKuaishouFeifeiProductRules()
const rules = listKuaishouFeifeiProductRules(options)
const matched = rules.find((rule) =>
normalizeCloudtentaclesMatchName(rule.productName) === normalizedProductName,
normalizeCloudtentaclesMatchName(rule.productName || rule.normalizedProductName) === normalizedProductName,
)
if (!matched) {
@@ -41,14 +45,25 @@ export function resolveKuaishouFeifeiProductByName(
}
}
function listKuaishouFeifeiProductRules(): KuaishouFeifeiProductRule[] {
const rules = runtimeConfig.platforms?.kuaishouFeifei?.productRules
function listKuaishouFeifeiProductRules(options: {
rules?: KuaishouFeifeiProductRule[]
enabled?: boolean
}): KuaishouFeifeiProductRule[] {
const config = options.rules ? null : getKuaishouFeifeiConfig()
if (options.enabled === false || config?.enabled === false) {
return []
}
const rules = options.rules || config?.productRules
return (Array.isArray(rules) ? rules : [])
.map((rule) => ({
id: String(rule.id || '').trim(),
productName: String(rule.productName || '').trim(),
normalizedProductName: String(rule.normalizedProductName || '').trim(),
productCode: String(rule.productCode || '').trim(),
skuName: String(rule.skuName || '').trim(),
enabled: rule.enabled !== false,
notes: String(rule.notes || '').trim(),
}))
.filter((rule) => rule.enabled && rule.productName && rule.productCode)
}
@@ -0,0 +1,40 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { normalizeKuaishouFeifeiSourceConfig } from './source-config-service.js'
test('normalizeKuaishouFeifeiSourceConfig keeps valid product rules and drops invalid ones', () => {
const config = normalizeKuaishouFeifeiSourceConfig({
enabled: true,
baseUrl: ' https://feifei.example.com/ ',
appKey: ' app-key ',
appSecret: ' secret ',
timeoutMs: 0,
productRules: [
{
productName: '套装-Alan Walker',
productCode: 'FF-1001',
skuName: 'Alan Walker 套装',
notes: '测试规则',
},
{
productName: '套装-Alan Walker',
productCode: 'FF-1002',
},
{
productName: '缺少编码',
productCode: '',
},
],
})
assert.equal(config.enabled, true)
assert.equal(config.baseUrl, 'https://feifei.example.com/')
assert.equal(config.appKey, 'app-key')
assert.equal(config.appSecret, 'secret')
assert.equal(config.timeoutMs, 10000)
assert.equal(config.productRules.length, 1)
assert.equal(config.productRules[0].productName, '套装-Alan Walker')
assert.equal(config.productRules[0].productCode, 'FF-1002')
assert.equal(config.productRules[0].normalizedProductName, '套装 alan walker')
})
@@ -0,0 +1,129 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../../config/runtime.js'
import { readJsonFile, writeJsonFile } from '../../../utils/json-file-store.js'
import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js'
import type { KuaishouFeifeiProductRule } from '../../../types/runtime-config.js'
const KUAISHOU_FEIFEI_CONFIG_FILE_PATH = path.join(
PROJECT_ROOT,
'data',
'kuaishou-feifei-config.json',
)
type JsonObject = Record<string, any>
export type KuaishouFeifeiSourceConfig = {
enabled: boolean
baseUrl: string
appKey: string
appSecret: string
timeoutMs: number
notifyUrl: string
productRules: KuaishouFeifeiProductRule[]
}
export function getKuaishouFeifeiConfigFilePath() {
return KUAISHOU_FEIFEI_CONFIG_FILE_PATH
}
export function hasKuaishouFeifeiConfigFile() {
return fs.existsSync(KUAISHOU_FEIFEI_CONFIG_FILE_PATH)
}
export function getKuaishouFeifeiSourceConfig(): KuaishouFeifeiSourceConfig {
return readJsonFile(
KUAISHOU_FEIFEI_CONFIG_FILE_PATH,
createDefaultKuaishouFeifeiSourceConfig,
normalizeKuaishouFeifeiSourceConfig,
)
}
export function saveKuaishouFeifeiSourceConfig(rawValue: unknown): KuaishouFeifeiSourceConfig {
return writeJsonFile(
KUAISHOU_FEIFEI_CONFIG_FILE_PATH,
rawValue,
normalizeKuaishouFeifeiSourceConfig,
)
}
export function normalizeKuaishouFeifeiSourceConfig(rawValue: unknown): KuaishouFeifeiSourceConfig {
const source = isPlainObject(rawValue) ? rawValue : {}
return {
enabled: source.enabled !== false,
baseUrl: String(source.baseUrl || 'http://skin-exchange.yiquyou.icu').trim(),
appKey: String(source.appKey || '').trim(),
appSecret: String(source.appSecret || '').trim(),
timeoutMs: normalizePositiveInteger(source.timeoutMs, 10000),
notifyUrl: String(source.notifyUrl || '').trim(),
productRules: normalizeKuaishouFeifeiProductRules(source.productRules),
}
}
export function normalizeKuaishouFeifeiProductRules(value: unknown): KuaishouFeifeiProductRule[] {
const rules = Array.isArray(value) ? value : []
const normalizedRules = rules
.map((item) => normalizeKuaishouFeifeiProductRule(item))
.filter((item): item is KuaishouFeifeiProductRule => Boolean(item))
return dedupeProductRules(normalizedRules)
}
function normalizeKuaishouFeifeiProductRule(rawValue: unknown): KuaishouFeifeiProductRule | null {
const source = isPlainObject(rawValue) ? rawValue : {}
const productName = String(source.productName || source.name || '').trim()
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
const productCode = String(source.productCode || source.code || '').trim()
if (!productName || !normalizedProductName || !productCode) {
return null
}
return {
id: String(source.id || normalizedProductName).trim() || normalizedProductName,
enabled: source.enabled !== false,
productName,
normalizedProductName,
productCode,
skuName: String(source.skuName || source.productName || productName).trim(),
notes: String(source.notes || '').trim(),
}
}
function dedupeProductRules(rules: KuaishouFeifeiProductRule[]) {
const map = new Map<string, KuaishouFeifeiProductRule>()
for (const rule of rules) {
const key = normalizeCloudtentaclesMatchName(rule.productName)
if (!key) {
continue
}
map.set(key, rule)
}
return Array.from(map.values())
}
function createDefaultKuaishouFeifeiSourceConfig(): KuaishouFeifeiSourceConfig {
return {
enabled: true,
baseUrl: 'http://skin-exchange.yiquyou.icu',
appKey: '',
appSecret: '',
timeoutMs: 10000,
notifyUrl: '',
productRules: [],
}
}
function normalizePositiveInteger(value: unknown, fallback: number) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
+4
View File
@@ -6,10 +6,13 @@ export type AdminDefaultUser = {
};
export type KuaishouFeifeiProductRule = {
id?: string;
productName: string;
normalizedProductName?: string;
productCode: string;
skuName?: string;
enabled?: boolean;
notes?: string;
};
export type RuntimeConfig = {
@@ -98,6 +101,7 @@ export type RuntimeConfig = {
version: string;
};
kuaishouFeifei: {
enabled: boolean;
baseUrl: string;
appKey: string;
appSecret: string;