diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts new file mode 100644 index 00000000..a281c47d --- /dev/null +++ b/apps/backend/src/config/app-config-keys.ts @@ -0,0 +1,10 @@ +export const APP_CONFIG_KEYS = { + notification: 'notification', + scheduledJobs: 'scheduled_jobs', + fulfillmentRouting: 'fulfillment_routing', + cloudtentaclesSources: 'cloudtentacles_sources', + cloudtentaclesSession: 'cloudtentacles_session', + cloudtentaclesOverrideRules: 'cloudtentacles_override_rules', + kuaishouFeifei: 'kuaishou_feifei', + kuaishouIndustrySource: 'kuaishou_industry_source', +} as const diff --git a/apps/backend/src/db/migrations/001_init.sql b/apps/backend/src/db/migrations/001_init.sql index 14457cbc..dd59408f 100644 --- a/apps/backend/src/db/migrations/001_init.sql +++ b/apps/backend/src/db/migrations/001_init.sql @@ -36,6 +36,25 @@ COMMENT ON TABLE admin_users IS '后台管理用户'; COMMENT ON TABLE admin_audit_logs IS '后台操作审计日志'; COMMENT ON COLUMN admin_audit_logs.payload_json IS '审计上下文快照,保留请求关键字段和变更内容'; +-- 运行时配置中心。 +CREATE TABLE IF NOT EXISTS app_config_entries ( + config_key TEXT PRIMARY KEY, + config_json JSONB NOT NULL DEFAULT '{}'::jsonb, + source_file_path TEXT NOT NULL DEFAULT '', + migrated_from_file BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_app_config_entries_updated_at + ON app_config_entries(updated_at DESC); + +COMMENT ON TABLE app_config_entries IS '运行时配置中心,保存从 JSON 文件迁移来的平台和系统配置'; +COMMENT ON COLUMN app_config_entries.config_key IS '配置唯一键'; +COMMENT ON COLUMN app_config_entries.config_json IS '配置内容 JSON'; +COMMENT ON COLUMN app_config_entries.source_file_path IS '首次迁移来源文件路径,便于排查历史来源'; +COMMENT ON COLUMN app_config_entries.migrated_from_file IS '是否由旧 JSON 文件迁移产生'; + -- 履约档案与履约资源要求。 CREATE TABLE IF NOT EXISTS fulfillment_profiles ( id BIGSERIAL PRIMARY KEY, diff --git a/apps/backend/src/repositories/app-config-repo.ts b/apps/backend/src/repositories/app-config-repo.ts new file mode 100644 index 00000000..ff21c959 --- /dev/null +++ b/apps/backend/src/repositories/app-config-repo.ts @@ -0,0 +1,67 @@ +import { query } from '../db/client.js' + +export type AppConfigEntryRow = { + config_key: string + config_json: unknown + source_file_path: string + migrated_from_file: boolean + created_at: string + updated_at: string +} + +type UpsertAppConfigEntryInput = { + configKey: string + configJson: unknown + sourceFilePath?: string | undefined + migratedFromFile?: boolean + timestamp: string +} + +export async function listAppConfigEntries(): Promise { + const result = await query( + ` + SELECT * + FROM app_config_entries + ORDER BY config_key ASC + `, + ) + + return result.rows +} + +export async function upsertAppConfigEntry( + input: UpsertAppConfigEntryInput, +): Promise { + const result = await query( + ` + INSERT INTO app_config_entries ( + config_key, + config_json, + source_file_path, + migrated_from_file, + created_at, + updated_at + ) VALUES ($1, $2::jsonb, $3, $4, $5, $6) + ON CONFLICT (config_key) DO UPDATE + SET + config_json = EXCLUDED.config_json, + source_file_path = CASE + WHEN EXCLUDED.source_file_path <> '' THEN EXCLUDED.source_file_path + ELSE app_config_entries.source_file_path + END, + migrated_from_file = app_config_entries.migrated_from_file OR EXCLUDED.migrated_from_file, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + String(input.configKey || '').trim(), + JSON.stringify(input.configJson ?? {}), + String(input.sourceFilePath || '').trim(), + input.migratedFromFile === true, + input.timestamp, + input.timestamp, + ], + ) + + return result.rows[0] || null +} diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts index f1a001cd..45a902b2 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts @@ -178,28 +178,28 @@ export function getAdminCloudtentaclesOverrideRules() { }; } -export function updateAdminCloudtentaclesOverrideRules( +export async function updateAdminCloudtentaclesOverrideRules( payload: AdminCloudtentaclesOverrideRuleConfigInput = {} ) { return { filePath: getCloudtentaclesOverrideRuleFilePath(), - ...saveCloudtentaclesOverrideRuleConfig(payload), + ...(await saveCloudtentaclesOverrideRuleConfig(payload)), }; } -export function updateAdminCloudtentaclesSourceConfig( +export async function updateAdminCloudtentaclesSourceConfig( payload: AdminCloudtentaclesSourceConfigInput = {} ) { - // Batch save: if payload.sources is an array, save the whole list + // 批量保存:payload.sources 是数组时替换整份账号列表。 if (Array.isArray(payload.sources)) { const normalizedList = payload.sources.map((item) => normalizeAdminCloudtentaclesSourceConfigPayload(item) ); - const savedList = saveCloudtentaclesSourcesList({ + const savedList = await saveCloudtentaclesSourcesList({ enabled: payload.enabled !== false, sources: normalizedList, }); - const sessions = pruneCloudtentaclesSessionStates( + const sessions = await pruneCloudtentaclesSessionStates( normalizedList.map((item) => item.key) ); @@ -220,20 +220,20 @@ export function updateAdminCloudtentaclesSourceConfig( }; } - // Single source save + // 单账号保存。 const sourceKey = String(payload.sourceKey || "").trim() || "default"; const current = getCloudtentaclesSourceByKey(sourceKey); const normalized = normalizeAdminCloudtentaclesSourceConfigPayload({ ...payload, sourceKey, }); - const saved = saveCloudtentaclesSourceByKey(sourceKey, normalized); + const saved = await saveCloudtentaclesSourceByKey(sourceKey, normalized); const shouldClearSession = hasCloudtentaclesCredentialContextChanged( current || undefined, saved ); const session = shouldClearSession - ? (clearCloudtentaclesSessionStateByKey(sourceKey), + ? (await clearCloudtentaclesSessionStateByKey(sourceKey), getCloudtentaclesSessionStateByKey(sourceKey)) : getCloudtentaclesSessionStateByKey(sourceKey); @@ -245,7 +245,7 @@ export function updateAdminCloudtentaclesSourceConfig( }; } -export function deleteAdminCloudtentaclesSource(sourceKey: string) { +export async function deleteAdminCloudtentaclesSource(sourceKey: string) { const key = String(sourceKey || "").trim(); if (!key) { throw createHttpError("cloudtentacles sourceKey 不能为空", { @@ -253,8 +253,8 @@ export function deleteAdminCloudtentaclesSource(sourceKey: string) { errorCode: "cloudtentacles_source_key_required", }); } - deleteCloudtentaclesSessionStateByKey(key); - deleteCloudtentaclesSourceByKey(key); + await deleteCloudtentaclesSessionStateByKey(key); + await deleteCloudtentaclesSourceByKey(key); } export async function sendAdminCloudtentaclesSmsCode( @@ -283,7 +283,7 @@ export async function testAdminCloudtentaclesLogin( ...credentialContext, code: String(payload.code || "").trim(), }); - const savedSession = saveCloudtentaclesSessionStateByKey( + const savedSession = await saveCloudtentaclesSessionStateByKey( sourceKey, buildAdminCloudtentaclesPersistedSessionPayload(session, { deviceId: credentialContext.deviceId, @@ -305,7 +305,7 @@ export async function validateAdminCloudtentaclesSession( persistedSession, }); const session = await validateCloudtentaclesSession(sessionContext); - const savedSession = saveCloudtentaclesSessionStateByKey( + const savedSession = await saveCloudtentaclesSessionStateByKey( sourceKey, buildAdminCloudtentaclesPersistedSessionPayload(session, { username: pickFirstNonEmpty([ diff --git a/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts b/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts index e1adecef..8afa082a 100644 --- a/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts +++ b/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts @@ -14,10 +14,10 @@ export function getAdminFulfillmentRoutingConfig() { } } -export function updateAdminFulfillmentRoutingConfig(payload: JsonRecord = {}) { +export async function updateAdminFulfillmentRoutingConfig(payload: JsonRecord = {}) { return { filePath: getFulfillmentRoutingConfigFilePath(), - ...saveFulfillmentRoutingConfig(payload), + ...(await saveFulfillmentRoutingConfig(payload)), } } diff --git a/apps/backend/src/services/admin/platform-config/kuaishou-feifei-service.ts b/apps/backend/src/services/admin/platform-config/kuaishou-feifei-service.ts index 9c81d7c9..e404167f 100644 --- a/apps/backend/src/services/admin/platform-config/kuaishou-feifei-service.ts +++ b/apps/backend/src/services/admin/platform-config/kuaishou-feifei-service.ts @@ -29,8 +29,8 @@ export function getAdminKuaishouFeifeiConfig() { } } -export function updateAdminKuaishouFeifeiConfig(payload: JsonObject = {}) { - const saved = saveKuaishouFeifeiSourceConfig(payload) +export async function updateAdminKuaishouFeifeiConfig(payload: JsonObject = {}) { + const saved = await saveKuaishouFeifeiSourceConfig(payload) const effective = getKuaishouFeifeiConfig() return { @@ -92,7 +92,7 @@ export async function syncAdminKuaishouFeifeiProductRules(payload: JsonObject = } }) .filter((rule): rule is NonNullable => Boolean(rule)) - const saved = saveKuaishouFeifeiSourceConfig({ + const saved = await saveKuaishouFeifeiSourceConfig({ ...source, productRules, }) diff --git a/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts b/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts index a47fea11..c9f7e4ef 100644 --- a/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts +++ b/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts @@ -35,9 +35,9 @@ export function getAdminKuaishouIndustrySourceConfig() { } } -export function updateAdminKuaishouIndustrySourceConfig(payload: JsonObject = {}) { +export async function updateAdminKuaishouIndustrySourceConfig(payload: JsonObject = {}) { const current = getKuaishouIndustrySourceConfig() - const saved = saveKuaishouIndustrySourceConfig({ + const saved = await saveKuaishouIndustrySourceConfig({ ...current, enabled: hasPayloadField(payload, 'enabled') ? payload.enabled !== false : current.enabled !== false, baseUrl: readConfigString(payload, 'baseUrl', current.baseUrl), diff --git a/apps/backend/src/services/admin/platform-config/notification-service.ts b/apps/backend/src/services/admin/platform-config/notification-service.ts index 04937424..472360e4 100644 --- a/apps/backend/src/services/admin/platform-config/notification-service.ts +++ b/apps/backend/src/services/admin/platform-config/notification-service.ts @@ -35,8 +35,8 @@ export function getAdminNotificationConfig() { } } -export function updateAdminNotificationConfig(payload: AdminNotificationConfigInput = {}) { - const saved = saveNotificationConfig(payload) +export async function updateAdminNotificationConfig(payload: AdminNotificationConfigInput = {}) { + const saved = await saveNotificationConfig(payload) return { filePath: getNotificationConfigFilePath(), @@ -64,8 +64,8 @@ export function getAdminScheduledJobsConfig() { } } -export function updateAdminScheduledJobsConfig(payload: AdminScheduledJobsConfigInput = {}) { - const saved = saveScheduledJobsConfig(payload) +export async function updateAdminScheduledJobsConfig(payload: AdminScheduledJobsConfigInput = {}) { + const saved = await saveScheduledJobsConfig(payload) reloadScheduledJobs() return { diff --git a/apps/backend/src/services/config/app-config-store.ts b/apps/backend/src/services/config/app-config-store.ts new file mode 100644 index 00000000..46bb1fb8 --- /dev/null +++ b/apps/backend/src/services/config/app-config-store.ts @@ -0,0 +1,108 @@ +import fs from 'node:fs' + +import { + listAppConfigEntries, + upsertAppConfigEntry, +} from '../../repositories/app-config-repo.js' +import { readJsonFile } from '../../utils/json-file-store.js' + +type NormalizeJsonValue = (value: unknown) => T + +const configCache = new Map() +let databaseCacheLoaded = false + +export function hasAppConfigEntry(configKey: string, legacyFilePath = '') { + const key = normalizeConfigKey(configKey) + if (configCache.has(key)) { + return true + } + + return Boolean(legacyFilePath) && fs.existsSync(legacyFilePath) +} + +export function readAppConfigEntry({ + configKey, + legacyFilePath, + fallback, + normalize, +}: { + configKey: string + legacyFilePath?: string | undefined + fallback: T | (() => T) + normalize: NormalizeJsonValue +}): T { + const key = normalizeConfigKey(configKey) + if (configCache.has(key)) { + return normalize(cloneJsonValue(configCache.get(key))) + } + + if (!databaseCacheLoaded && legacyFilePath) { + return readJsonFile(legacyFilePath, fallback, normalize) + } + + return normalize(resolveFallback(fallback)) +} + +export async function saveAppConfigEntry({ + configKey, + value, + legacyFilePath, + normalize, +}: { + configKey: string + value: unknown + legacyFilePath?: string | undefined + normalize: NormalizeJsonValue +}): Promise { + const key = normalizeConfigKey(configKey) + const normalized = normalize(value) + + await upsertAppConfigEntry({ + configKey: key, + configJson: normalized, + sourceFilePath: legacyFilePath, + migratedFromFile: false, + timestamp: new Date().toISOString(), + }) + + configCache.set(key, cloneJsonValue(normalized)) + + return normalized +} + +export async function loadAppConfigCacheFromDatabase() { + const rows = await listAppConfigEntries() + configCache.clear() + + for (const row of rows) { + const key = normalizeConfigKey(row.config_key) + if (!key) { + continue + } + + configCache.set(key, cloneJsonValue(row.config_json)) + } + + databaseCacheLoaded = true +} + +export function setAppConfigCacheEntry(configKey: string, value: unknown) { + const key = normalizeConfigKey(configKey) + if (!key) { + return + } + + configCache.set(key, cloneJsonValue(value)) +} + +function normalizeConfigKey(value: unknown) { + return String(value || '').trim() +} + +function resolveFallback(fallback: T | (() => T)): T { + return typeof fallback === 'function' ? (fallback as () => T)() : fallback +} + +function cloneJsonValue(value: T): T { + return JSON.parse(JSON.stringify(value ?? null)) as T +} diff --git a/apps/backend/src/services/config/json-config-migration-service.ts b/apps/backend/src/services/config/json-config-migration-service.ts new file mode 100644 index 00000000..f74cfd32 --- /dev/null +++ b/apps/backend/src/services/config/json-config-migration-service.ts @@ -0,0 +1,140 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' +import { PROJECT_ROOT } from '../../config/runtime.js' +import { upsertAppConfigEntry } from '../../repositories/app-config-repo.js' +import { logInfo, logWarn } from '../../utils/logger.js' +import { normalizeFulfillmentRoutingConfig } from '../fulfillment/routing-config-service.js' +import { normalizeNotificationConfig } from '../notification/config-service.js' +import { normalizeCloudtentaclesOverrideRuleConfig } from '../order/cloudtentacles-override-rule-service.js' +import { normalizeCloudtentaclesSourcesConfig } from '../platforms/cloudtentacles/source-config-service.js' +import { normalizeSessionStatesFile } from '../platforms/cloudtentacles/session-state-service.js' +import { normalizeKuaishouFeifeiSourceConfig } from '../platforms/kuaishou-feifei/source-config-service.js' +import { normalizeKuaishouIndustrySourceConfig } from '../platforms/kuaishou-industry/source-config-service.js' +import { normalizeScheduledJobsConfig } from '../scheduler/config-service.js' +import { loadAppConfigCacheFromDatabase } from './app-config-store.js' + +type JsonConfigMigrationItem = { + configKey: string + fileName: string + normalize: (value: unknown) => unknown +} + +const DATA_DIR = path.join(PROJECT_ROOT, 'data') + +const JSON_CONFIG_MIGRATION_ITEMS: JsonConfigMigrationItem[] = [ + { + configKey: APP_CONFIG_KEYS.notification, + fileName: 'notification-config.json', + normalize: normalizeNotificationConfig, + }, + { + configKey: APP_CONFIG_KEYS.scheduledJobs, + fileName: 'scheduled-jobs.json', + normalize: normalizeScheduledJobsConfig, + }, + { + configKey: APP_CONFIG_KEYS.fulfillmentRouting, + fileName: 'fulfillment-routing-config.json', + normalize: normalizeFulfillmentRoutingConfig, + }, + { + configKey: APP_CONFIG_KEYS.cloudtentaclesSources, + fileName: 'cloudtentacles-sources.json', + normalize: normalizeCloudtentaclesSourcesConfig, + }, + { + configKey: APP_CONFIG_KEYS.cloudtentaclesSession, + fileName: 'cloudtentacles-session.json', + normalize: normalizeSessionStatesFile, + }, + { + configKey: APP_CONFIG_KEYS.cloudtentaclesOverrideRules, + fileName: 'cloudtentacles-override-rules.json', + normalize: normalizeCloudtentaclesOverrideRuleConfig, + }, + { + configKey: APP_CONFIG_KEYS.kuaishouFeifei, + fileName: 'kuaishou-feifei-config.json', + normalize: normalizeKuaishouFeifeiSourceConfig, + }, + { + configKey: APP_CONFIG_KEYS.kuaishouIndustrySource, + fileName: 'kuaishou-industry-source.json', + normalize: normalizeKuaishouIndustrySourceConfig, + }, +] + +export async function migrateJsonConfigFilesToDatabase() { + const migrated: Array<{ configKey: string, filePath: string }> = [] + const skipped: Array<{ configKey: string, filePath: string, reason: string }> = [] + + for (const item of JSON_CONFIG_MIGRATION_ITEMS) { + const filePath = path.join(DATA_DIR, item.fileName) + if (!fs.existsSync(filePath)) { + continue + } + + let normalized: unknown + try { + const rawText = fs.readFileSync(filePath, 'utf8') + normalized = item.normalize(JSON.parse(rawText)) + } catch (error) { + skipped.push({ + configKey: item.configKey, + filePath, + reason: error instanceof Error ? error.message : String(error), + }) + logWarn('[config/migration]', 'JSON 配置解析失败,已保留原文件', { + configKey: item.configKey, + filePath, + error, + }) + continue + } + + await upsertAppConfigEntry({ + configKey: item.configKey, + configJson: normalized, + sourceFilePath: filePath, + migratedFromFile: true, + timestamp: new Date().toISOString(), + }) + + try { + fs.unlinkSync(filePath) + migrated.push({ + configKey: item.configKey, + filePath, + }) + } catch (error) { + skipped.push({ + configKey: item.configKey, + filePath, + reason: error instanceof Error ? error.message : String(error), + }) + logWarn('[config/migration]', 'JSON 配置已入库,但删除原文件失败', { + configKey: item.configKey, + filePath, + error, + }) + } + } + + await loadAppConfigCacheFromDatabase() + + if (migrated.length > 0 || skipped.length > 0) { + logInfo('[config/migration]', 'JSON 配置文件迁移完成', { + migratedCount: migrated.length, + skippedCount: skipped.length, + migrated, + skipped, + }) + } + + return { + migrated, + skipped, + } +} diff --git a/apps/backend/src/services/fulfillment/routing-config-service.ts b/apps/backend/src/services/fulfillment/routing-config-service.ts index 89272e24..072c06da 100644 --- a/apps/backend/src/services/fulfillment/routing-config-service.ts +++ b/apps/backend/src/services/fulfillment/routing-config-service.ts @@ -1,7 +1,11 @@ import path from 'node:path' +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' import { PROJECT_ROOT } from '../../config/runtime.js' -import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js' +import { + readAppConfigEntry, + saveAppConfigEntry, +} from '../config/app-config-store.js' import { normalizeCloudtentaclesMatchName } from '../order/cloudtentacles-match-utils.js' import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js' @@ -77,19 +81,21 @@ export function getFulfillmentRoutingConfigFilePath() { } export function getFulfillmentRoutingConfig(): FulfillmentRoutingConfig { - return readJsonFile( - FULFILLMENT_ROUTING_CONFIG_FILE_PATH, - createDefaultFulfillmentRoutingConfig, - normalizeFulfillmentRoutingConfig, - ) + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.fulfillmentRouting, + legacyFilePath: FULFILLMENT_ROUTING_CONFIG_FILE_PATH, + fallback: createDefaultFulfillmentRoutingConfig, + normalize: normalizeFulfillmentRoutingConfig, + }) } -export function saveFulfillmentRoutingConfig(rawValue: unknown): FulfillmentRoutingConfig { - return writeJsonFile( - FULFILLMENT_ROUTING_CONFIG_FILE_PATH, - rawValue, - normalizeFulfillmentRoutingConfig, - ) +export function saveFulfillmentRoutingConfig(rawValue: unknown): Promise { + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.fulfillmentRouting, + legacyFilePath: FULFILLMENT_ROUTING_CONFIG_FILE_PATH, + value: rawValue, + normalize: normalizeFulfillmentRoutingConfig, + }) } export function normalizeFulfillmentRoutingConfig(rawValue: unknown): FulfillmentRoutingConfig { diff --git a/apps/backend/src/services/notification/config-service.ts b/apps/backend/src/services/notification/config-service.ts index 56472ee8..d6e6e216 100644 --- a/apps/backend/src/services/notification/config-service.ts +++ b/apps/backend/src/services/notification/config-service.ts @@ -1,7 +1,11 @@ import path from 'node:path' +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' import { PROJECT_ROOT } from '../../config/runtime.js' -import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js' +import { + readAppConfigEntry, + saveAppConfigEntry, +} from '../config/app-config-store.js' const NOTIFICATION_CONFIG_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'notification-config.json') const DEFAULT_BARK_SERVER_URL = 'https://api.day.app' @@ -20,11 +24,21 @@ export function getNotificationConfigFilePath() { } export function getNotificationConfig() { - return loadNotificationConfigFromFile() + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.notification, + legacyFilePath: NOTIFICATION_CONFIG_FILE_PATH, + fallback: createDefaultNotificationConfig, + normalize: normalizeNotificationConfig, + }) } export function saveNotificationConfig(rawValue: unknown) { - return writeJsonFile(NOTIFICATION_CONFIG_FILE_PATH, rawValue, normalizeNotificationConfig) + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.notification, + legacyFilePath: NOTIFICATION_CONFIG_FILE_PATH, + value: rawValue, + normalize: normalizeNotificationConfig, + }) } export function listEnabledBarkRecipients(config: JsonObject = getNotificationConfig()) { @@ -45,14 +59,6 @@ export function listEnabledWpushRecipients(config: JsonObject = getNotificationC .filter((item: JsonObject) => item.enabled !== false && String(item.apiKey || item.apikey || '').trim()) } -function loadNotificationConfigFromFile() { - return readJsonFile( - NOTIFICATION_CONFIG_FILE_PATH, - createDefaultNotificationConfig, - normalizeNotificationConfig, - ) -} - export function normalizeNotificationConfig(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {} const bark = isPlainObject(source.channels?.bark) ? source.channels.bark : {} diff --git a/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts b/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts index 8c04b2c4..d90fd754 100644 --- a/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts +++ b/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts @@ -1,7 +1,11 @@ import path from 'node:path' +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' import { PROJECT_ROOT } from '../../config/runtime.js' -import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js' +import { + readAppConfigEntry, + saveAppConfigEntry, +} from '../config/app-config-store.js' import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js' const CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH = path.join( @@ -38,19 +42,21 @@ export function getCloudtentaclesOverrideRuleFilePath() { } export function getCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig { - return readJsonFile( - CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH, - createDefaultCloudtentaclesOverrideRuleConfig, - normalizeCloudtentaclesOverrideRuleConfig, - ) + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.cloudtentaclesOverrideRules, + legacyFilePath: CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH, + fallback: createDefaultCloudtentaclesOverrideRuleConfig, + normalize: normalizeCloudtentaclesOverrideRuleConfig, + }) } -export function saveCloudtentaclesOverrideRuleConfig(rawValue: unknown): CloudtentaclesOverrideRuleConfig { - return writeJsonFile( - CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH, - rawValue, - normalizeCloudtentaclesOverrideRuleConfig, - ) +export function saveCloudtentaclesOverrideRuleConfig(rawValue: unknown): Promise { + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.cloudtentaclesOverrideRules, + legacyFilePath: CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH, + value: rawValue, + normalize: normalizeCloudtentaclesOverrideRuleConfig, + }) } export function resolveCloudtentaclesOverrideRule(productName: unknown) { diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts b/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts index 31d6c545..496b30aa 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts @@ -1,8 +1,12 @@ -import fs from 'node:fs' import path from 'node:path' +import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js' import { PROJECT_ROOT } from '../../../config/runtime.js' import { createHttpError } from '../../../utils/http.js' +import { + readAppConfigEntry, + saveAppConfigEntry, +} from '../../config/app-config-store.js' import { DEFAULT_CLOUDTENTACLES_DEVICE_ID, DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, @@ -22,47 +26,38 @@ export function getCloudtentaclesSessionFilePath() { return CLOUDTENTACLES_SESSION_FILE_PATH } -/** - * Backward-compatible: returns the session for key='default'. - * Old callers that expect a single session object still work. - */ +// 兼容旧调用:返回 key='default' 的会话。 export function getCloudtentaclesSessionState() { - const states = loadCloudtentaclesSessionStatesFromFile() + const states = loadCloudtentaclesSessionStates() return states.sessions['default'] || createDefaultCloudtentaclesSessionState() } -/** - * Get a session by its sourceKey. Returns null if not found. - */ +// 按 sourceKey 获取会话,未命中时返回 null。 export function getCloudtentaclesSessionStateByKey(sourceKey: unknown) { - const states = loadCloudtentaclesSessionStatesFromFile() + const states = loadCloudtentaclesSessionStates() const key = String(sourceKey || '').trim() if (!key) return null return states.sessions[key] || null } -/** - * Return the entire session states map: { sessions: { 'default': {...}, ... } }. - */ +// 返回所有账号的会话状态。 export function getAllCloudtentaclesSessionStates() { - return loadCloudtentaclesSessionStatesFromFile() + return loadCloudtentaclesSessionStates() } -/** - * Backward-compatible save: accepts both old single-object format - * and new sessions-map format, normalizes, and persists. - */ +// 兼容旧保存格式:支持单会话对象和 sessions 映射。 export function saveCloudtentaclesSessionState(rawValue: unknown) { const normalized = normalizeSessionStatesFile(rawValue) - fs.mkdirSync(path.dirname(CLOUDTENTACLES_SESSION_FILE_PATH), { recursive: true }) - fs.writeFileSync(CLOUDTENTACLES_SESSION_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') - return normalized.sessions['default'] || createDefaultCloudtentaclesSessionState() + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.cloudtentaclesSession, + legacyFilePath: CLOUDTENTACLES_SESSION_FILE_PATH, + value: normalized, + normalize: normalizeSessionStatesFile, + }) } -/** - * Save a session for a specific sourceKey. - */ -export function saveCloudtentaclesSessionStateByKey(sourceKey: unknown, rawValue: unknown) { +// 保存指定 sourceKey 的会话。 +export async function saveCloudtentaclesSessionStateByKey(sourceKey: unknown, rawValue: unknown) { const key = String(sourceKey || '').trim() if (!key) { throw createHttpError('cloudtentacles sourceKey 不能为空', { @@ -71,18 +66,15 @@ export function saveCloudtentaclesSessionStateByKey(sourceKey: unknown, rawValue }) } - const states = loadCloudtentaclesSessionStatesFromFile() + const states = loadCloudtentaclesSessionStates() states.sessions[key] = normalizeCloudtentaclesSessionState(rawValue) - fs.mkdirSync(path.dirname(CLOUDTENTACLES_SESSION_FILE_PATH), { recursive: true }) - fs.writeFileSync(CLOUDTENTACLES_SESSION_FILE_PATH, `${JSON.stringify(states, null, 2)}\n`, 'utf8') + await saveCloudtentaclesSessionState(states) return states.sessions[key] } -/** - * Delete a session by sourceKey. - */ -export function deleteCloudtentaclesSessionStateByKey(sourceKey: unknown) { +// 删除指定 sourceKey 的会话。 +export async function deleteCloudtentaclesSessionStateByKey(sourceKey: unknown) { const key = String(sourceKey || '').trim() if (!key) { throw createHttpError('cloudtentacles sourceKey 不能为空', { @@ -91,25 +83,20 @@ export function deleteCloudtentaclesSessionStateByKey(sourceKey: unknown) { }) } - const states = loadCloudtentaclesSessionStatesFromFile() + const states = loadCloudtentaclesSessionStates() delete states.sessions[key] - fs.mkdirSync(path.dirname(CLOUDTENTACLES_SESSION_FILE_PATH), { recursive: true }) - fs.writeFileSync(CLOUDTENTACLES_SESSION_FILE_PATH, `${JSON.stringify(states, null, 2)}\n`, 'utf8') + await saveCloudtentaclesSessionState(states) return states } -/** - * Backward-compatible clear: clears the 'default' session only. - */ +// 兼容旧调用:只清理 default 会话。 export function clearCloudtentaclesSessionState() { return clearCloudtentaclesSessionStateByKey('default') } -/** - * Clear a session by sourceKey (sets it to default empty state). - */ -export function clearCloudtentaclesSessionStateByKey(sourceKey: unknown) { +// 清理指定 sourceKey 的会话,重置为空状态。 +export async function clearCloudtentaclesSessionStateByKey(sourceKey: unknown) { const key = String(sourceKey || '').trim() if (!key) { throw createHttpError('cloudtentacles sourceKey 不能为空', { @@ -119,50 +106,37 @@ export function clearCloudtentaclesSessionStateByKey(sourceKey: unknown) { } const cleared = createDefaultCloudtentaclesSessionState() - saveCloudtentaclesSessionStateByKey(key, cleared) + await saveCloudtentaclesSessionStateByKey(key, cleared) return cleared } -/** - * Keep only sessions whose sourceKey still exists in source config. - */ -export function pruneCloudtentaclesSessionStates(sourceKeys: unknown[] = []) { +// 只保留仍存在 source 配置的会话。 +export async function pruneCloudtentaclesSessionStates(sourceKeys: unknown[] = []) { const allowedKeys = new Set( Array.isArray(sourceKeys) ? sourceKeys.map((value) => String(value || '').trim()).filter(Boolean) : [] ) - const states = loadCloudtentaclesSessionStatesFromFile() + const states = loadCloudtentaclesSessionStates() states.sessions = Object.fromEntries( Object.entries(states.sessions || {}).filter(([key]) => allowedKeys.has(String(key || '').trim())) ) - fs.mkdirSync(path.dirname(CLOUDTENTACLES_SESSION_FILE_PATH), { recursive: true }) - fs.writeFileSync(CLOUDTENTACLES_SESSION_FILE_PATH, `${JSON.stringify(states, null, 2)}\n`, 'utf8') + await saveCloudtentaclesSessionState(states) return states } -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -function loadCloudtentaclesSessionStatesFromFile(): CloudtentaclesSessionStatesFile { - if (!fs.existsSync(CLOUDTENTACLES_SESSION_FILE_PATH)) { - return createDefaultCloudtentaclesSessionStates() - } - - try { - const rawText = fs.readFileSync(CLOUDTENTACLES_SESSION_FILE_PATH, 'utf8') - return normalizeSessionStatesFile(JSON.parse(rawText)) - } catch { - return createDefaultCloudtentaclesSessionStates() - } +function loadCloudtentaclesSessionStates(): CloudtentaclesSessionStatesFile { + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.cloudtentaclesSession, + legacyFilePath: CLOUDTENTACLES_SESSION_FILE_PATH, + fallback: createDefaultCloudtentaclesSessionStates, + normalize: normalizeSessionStatesFile, + }) } -/** - * Normalize a single session state item. - */ +// 标准化单个会话状态。 function normalizeCloudtentaclesSessionState(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {} @@ -177,13 +151,8 @@ function normalizeCloudtentaclesSessionState(rawValue: unknown) { } } -/** - * Normalize the overall session states file format. - * Handles old format (single object without sessions key) by auto-wrapping - * into { sessions: { 'default': ... } }. - */ -function normalizeSessionStatesFile(rawValue: unknown): CloudtentaclesSessionStatesFile { - // Old format: { token: 'xxx', ... } (single object, no sessions key) +// 标准化整体会话状态,同时兼容旧版单会话对象。 +export function normalizeSessionStatesFile(rawValue: unknown): CloudtentaclesSessionStatesFile { if (isPlainObject(rawValue) && !rawValue.sessions) { return { sessions: { @@ -192,7 +161,6 @@ function normalizeSessionStatesFile(rawValue: unknown): CloudtentaclesSessionSta } } - // New format: { sessions: { 'default': {...}, ... } } return { sessions: isPlainObject(rawValue) && isPlainObject(rawValue.sessions) ? Object.fromEntries( diff --git a/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts b/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts index 35a9f621..f2ee4f3b 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts @@ -1,8 +1,12 @@ -import fs from 'node:fs' import path from 'node:path' +import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js' import { PROJECT_ROOT } from '../../../config/runtime.js' import { createHttpError } from '../../../utils/http.js' +import { + readAppConfigEntry, + saveAppConfigEntry, +} from '../../config/app-config-store.js' import { normalizeCloudtentaclesDeviceId, normalizeCloudtentaclesDeviceType, @@ -16,56 +20,44 @@ export function getCloudtentaclesSourcesFilePath() { return CLOUDTENTACLES_SOURCES_FILE_PATH } -/** - * Backward-compatible: returns the source with key='default'. - * Old callers that expect a single source object still work. - */ +// 兼容旧调用:返回 key='default' 的单账号配置。 export function getCloudtentaclesSourceConfig() { - const config = loadCloudtentaclesSourcesConfigFromFile() + const config = loadCloudtentaclesSourcesConfig() const defaultSource = config.sources.find(s => s.key === 'default') return defaultSource || normalizeCloudtentaclesSourceItem({ key: 'default' }) } -/** - * Get a source item by its key. Returns null if not found. - */ +// 按 sourceKey 获取账号配置,未命中时返回 null。 export function getCloudtentaclesSourceByKey(sourceKey: unknown) { - const config = loadCloudtentaclesSourcesConfigFromFile() + const config = loadCloudtentaclesSourcesConfig() const key = String(sourceKey || '').trim() if (!key) return null return config.sources.find(s => s.key === key) || null } -/** - * Return the entire normalized config: { enabled, sources }. - */ +// 返回完整的多账号配置。 export function listCloudtentaclesSources() { - return loadCloudtentaclesSourcesConfigFromFile() + return loadCloudtentaclesSourcesConfig() } -/** - * Backward-compatible save: accepts both old single-object format - * and new list format, normalizes, and persists. - */ +// 兼容旧保存格式:支持单账号对象和 { enabled, sources } 多账号格式。 export function saveCloudtentaclesSourceConfig(rawValue: unknown) { const normalized = normalizeCloudtentaclesSourcesConfig(rawValue) - fs.mkdirSync(path.dirname(CLOUDTENTACLES_SOURCES_FILE_PATH), { recursive: true }) - fs.writeFileSync(CLOUDTENTACLES_SOURCES_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') - return normalized + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.cloudtentaclesSources, + legacyFilePath: CLOUDTENTACLES_SOURCES_FILE_PATH, + value: normalized, + normalize: normalizeCloudtentaclesSourcesConfig, + }) } -/** - * Save the entire list-format config object: { enabled, sources: [...] }. - */ +// 保存完整多账号配置。 export function saveCloudtentaclesSourcesList(rawValue: unknown) { return saveCloudtentaclesSourceConfig(rawValue) } -/** - * Save or update a single source item identified by sourceKey. - * If a source with the same key exists, it is replaced; otherwise it is appended. - */ -export function saveCloudtentaclesSourceByKey(sourceKey: unknown, data: JsonObject = {}) { +// 按 sourceKey 新增或替换单个账号配置。 +export async function saveCloudtentaclesSourceByKey(sourceKey: unknown, data: JsonObject = {}) { const key = String(sourceKey || '').trim() if (!key) { throw createHttpError('cloudtentacles sourceKey 不能为空', { @@ -74,7 +66,7 @@ export function saveCloudtentaclesSourceByKey(sourceKey: unknown, data: JsonObje }) } - const config = loadCloudtentaclesSourcesConfigFromFile() + const config = loadCloudtentaclesSourcesConfig() const normalizedItem = normalizeCloudtentaclesSourceItem({ ...data, key }) const existingIndex = config.sources.findIndex(s => s.key === key) @@ -84,15 +76,12 @@ export function saveCloudtentaclesSourceByKey(sourceKey: unknown, data: JsonObje config.sources.push(normalizedItem) } - fs.mkdirSync(path.dirname(CLOUDTENTACLES_SOURCES_FILE_PATH), { recursive: true }) - fs.writeFileSync(CLOUDTENTACLES_SOURCES_FILE_PATH, `${JSON.stringify(config, null, 2)}\n`, 'utf8') - return config + await saveCloudtentaclesSourceConfig(config) + return normalizedItem } -/** - * Delete a single source by key. - */ -export function deleteCloudtentaclesSourceByKey(sourceKey: unknown) { +// 删除单个账号配置。 +export async function deleteCloudtentaclesSourceByKey(sourceKey: unknown) { const key = String(sourceKey || '').trim() if (!key) { throw createHttpError('cloudtentacles sourceKey 不能为空', { @@ -101,7 +90,7 @@ export function deleteCloudtentaclesSourceByKey(sourceKey: unknown) { }) } - const config = loadCloudtentaclesSourcesConfigFromFile() + const config = loadCloudtentaclesSourcesConfig() const existingIndex = config.sources.findIndex(s => s.key === key) if (existingIndex < 0) { @@ -110,31 +99,20 @@ export function deleteCloudtentaclesSourceByKey(sourceKey: unknown) { config.sources.splice(existingIndex, 1) - fs.mkdirSync(path.dirname(CLOUDTENTACLES_SOURCES_FILE_PATH), { recursive: true }) - fs.writeFileSync(CLOUDTENTACLES_SOURCES_FILE_PATH, `${JSON.stringify(config, null, 2)}\n`, 'utf8') + await saveCloudtentaclesSourceConfig(config) return config } -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -function loadCloudtentaclesSourcesConfigFromFile() { - if (!fs.existsSync(CLOUDTENTACLES_SOURCES_FILE_PATH)) { - return createDefaultCloudtentaclesSourcesConfig() - } - - try { - const rawText = fs.readFileSync(CLOUDTENTACLES_SOURCES_FILE_PATH, 'utf8') - return normalizeCloudtentaclesSourcesConfig(JSON.parse(rawText)) - } catch { - return createDefaultCloudtentaclesSourcesConfig() - } +function loadCloudtentaclesSourcesConfig() { + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.cloudtentaclesSources, + legacyFilePath: CLOUDTENTACLES_SOURCES_FILE_PATH, + fallback: createDefaultCloudtentaclesSourcesConfig, + normalize: normalizeCloudtentaclesSourcesConfig, + }) } -/** - * Normalize a single source item. Adds key (required) and label (optional). - */ +// 标准化单个账号配置,补齐 key 和 label。 function normalizeCloudtentaclesSourceItem(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {} @@ -151,12 +129,8 @@ function normalizeCloudtentaclesSourceItem(rawValue: unknown) { } } -/** - * Normalize the overall config. Handles both old single-object format - * (auto-migrates to new list format) and new { enabled, sources } format. - */ -function normalizeCloudtentaclesSourcesConfig(rawValue: unknown) { - // Old format: { enabled: true, username: 'xxx', ... } (single object, no sources array) +// 标准化整体配置,同时兼容旧版单账号对象。 +export function normalizeCloudtentaclesSourcesConfig(rawValue: unknown) { if (isPlainObject(rawValue) && !Array.isArray(rawValue.sources)) { return { enabled: rawValue.enabled !== false, @@ -164,13 +138,12 @@ function normalizeCloudtentaclesSourcesConfig(rawValue: unknown) { normalizeCloudtentaclesSourceItem({ key: 'default', label: '默认账号', - ...rawValue, // old fields auto-map to default source + ...rawValue, // 旧字段自动映射为 default 账号。 }), ].filter(Boolean), } } - // New format: { enabled, sources: [...] } return { enabled: isPlainObject(rawValue) ? rawValue.enabled !== false : true, sources: isPlainObject(rawValue) && Array.isArray(rawValue.sources) diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/source-config-service.ts b/apps/backend/src/services/platforms/kuaishou-feifei/source-config-service.ts index 88040569..63f05e73 100644 --- a/apps/backend/src/services/platforms/kuaishou-feifei/source-config-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-feifei/source-config-service.ts @@ -1,8 +1,12 @@ -import fs from 'node:fs' import path from 'node:path' +import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js' import { PROJECT_ROOT } from '../../../config/runtime.js' -import { readJsonFile, writeJsonFile } from '../../../utils/json-file-store.js' +import { + hasAppConfigEntry, + readAppConfigEntry, + saveAppConfigEntry, +} from '../../config/app-config-store.js' import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js' import type { KuaishouFeifeiProductRule } from '../../../types/runtime-config.js' @@ -29,23 +33,28 @@ export function getKuaishouFeifeiConfigFilePath() { } export function hasKuaishouFeifeiConfigFile() { - return fs.existsSync(KUAISHOU_FEIFEI_CONFIG_FILE_PATH) + return hasAppConfigEntry( + APP_CONFIG_KEYS.kuaishouFeifei, + KUAISHOU_FEIFEI_CONFIG_FILE_PATH, + ) } export function getKuaishouFeifeiSourceConfig(): KuaishouFeifeiSourceConfig { - return readJsonFile( - KUAISHOU_FEIFEI_CONFIG_FILE_PATH, - createDefaultKuaishouFeifeiSourceConfig, - normalizeKuaishouFeifeiSourceConfig, - ) + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.kuaishouFeifei, + legacyFilePath: KUAISHOU_FEIFEI_CONFIG_FILE_PATH, + fallback: createDefaultKuaishouFeifeiSourceConfig, + normalize: normalizeKuaishouFeifeiSourceConfig, + }) } -export function saveKuaishouFeifeiSourceConfig(rawValue: unknown): KuaishouFeifeiSourceConfig { - return writeJsonFile( - KUAISHOU_FEIFEI_CONFIG_FILE_PATH, - rawValue, - normalizeKuaishouFeifeiSourceConfig, - ) +export function saveKuaishouFeifeiSourceConfig(rawValue: unknown): Promise { + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.kuaishouFeifei, + legacyFilePath: KUAISHOU_FEIFEI_CONFIG_FILE_PATH, + value: rawValue, + normalize: normalizeKuaishouFeifeiSourceConfig, + }) } export function normalizeKuaishouFeifeiSourceConfig(rawValue: unknown): KuaishouFeifeiSourceConfig { diff --git a/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts index 76c2ee65..863297e3 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts @@ -1,7 +1,11 @@ import path from 'node:path' +import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js' import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js' -import { readJsonFile, writeJsonFile } from '../../../utils/json-file-store.js' +import { + readAppConfigEntry, + saveAppConfigEntry, +} from '../../config/app-config-store.js' const KUAISHOU_INDUSTRY_SOURCE_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'kuaishou-industry-source.json') const DEFAULT_CALLBACK_BASE_URL = 'https://openapi.kwaixiaodian.com' @@ -62,24 +66,26 @@ export function getKuaishouIndustrySourceFilePath(): string { } export function getKuaishouIndustrySourceConfig(): KuaishouIndustrySourceConfig { - return readJsonFile( - KUAISHOU_INDUSTRY_SOURCE_FILE_PATH, - createDefaultKuaishouIndustrySourceConfig, - normalizeKuaishouIndustrySourceConfig, - ) + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.kuaishouIndustrySource, + legacyFilePath: KUAISHOU_INDUSTRY_SOURCE_FILE_PATH, + fallback: createDefaultKuaishouIndustrySourceConfig, + normalize: normalizeKuaishouIndustrySourceConfig, + }) } -export function saveKuaishouIndustrySourceConfig(rawValue: unknown): KuaishouIndustrySourceConfig { - return writeJsonFile( - KUAISHOU_INDUSTRY_SOURCE_FILE_PATH, - rawValue, - normalizeKuaishouIndustrySourceConfig, - ) +export function saveKuaishouIndustrySourceConfig(rawValue: unknown): Promise { + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.kuaishouIndustrySource, + legacyFilePath: KUAISHOU_INDUSTRY_SOURCE_FILE_PATH, + value: rawValue, + normalize: normalizeKuaishouIndustrySourceConfig, + }) } export function patchKuaishouIndustrySourceConfig( patch: Partial, -): KuaishouIndustrySourceConfig { +): Promise { return saveKuaishouIndustrySourceConfig({ ...getKuaishouIndustrySourceConfig(), ...patch, @@ -108,7 +114,7 @@ export function findKuaishouIndustryShopConfig( export function patchKuaishouIndustryShopConfig( sellerId: unknown, patch: Partial, -): KuaishouIndustrySourceConfig { +): Promise { const source = getKuaishouIndustrySourceConfig() const normalizedSellerId = String(patch.sellerId || sellerId || '').trim() const existing = findKuaishouIndustryShopConfig(normalizedSellerId, source) @@ -119,7 +125,7 @@ export function patchKuaishouIndustryShopConfig( }) if (!nextShop) { - return source + return Promise.resolve(source) } const shops = listKuaishouIndustryShopConfigs(source) @@ -131,7 +137,7 @@ export function patchKuaishouIndustryShopConfig( }) } -function normalizeKuaishouIndustrySourceConfig(rawValue: unknown): KuaishouIndustrySourceConfig { +export function normalizeKuaishouIndustrySourceConfig(rawValue: unknown): KuaishouIndustrySourceConfig { const fallback = createDefaultKuaishouIndustrySourceConfig() const source = isPlainObject(rawValue) ? rawValue : {} const shops = normalizeKuaishouIndustryShopList(source) diff --git a/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts index 6faba428..6a6e7cbb 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts @@ -76,7 +76,7 @@ export async function refreshKuaishouIndustryAccessToken( failureMessage: '快手 accessToken 刷新失败', errorCode: 'kuaishou_industry_access_token_refresh_failed', }) - const saved = saveTokenPayload(tokenPayload, config, shop) + const saved = await saveTokenPayload(tokenPayload, config, shop) logInfo('[kuaishou-industry/token]', 'accessToken 刷新成功', { durationMs: Date.now() - startedAt, @@ -94,10 +94,10 @@ export async function refreshKuaishouIndustryAccessToken( } } catch (error) { const message = error instanceof Error ? error.message : String(error) - patchKuaishouIndustryShopConfig(shop.sellerId, { + await patchKuaishouIndustryShopConfig(shop.sellerId, { lastRefreshError: message, }) - patchKuaishouIndustrySourceConfig({ + await patchKuaishouIndustrySourceConfig({ lastRefreshError: message, }) logWarn('[kuaishou-industry/token]', 'accessToken 刷新失败', resolveRefreshErrorDetail(error)) @@ -133,7 +133,7 @@ export async function exchangeKuaishouIndustryAuthorizationCode( failureMessage: '快手授权码换取 accessToken 失败', errorCode: 'kuaishou_industry_authorization_code_exchange_failed', }) - const saved = saveTokenPayload(tokenPayload, config, { + const saved = await saveTokenPayload(tokenPayload, config, { ...createEmptyShopConfig(), sellerId: String(options.sellerId || '').trim(), shopId: String(options.sellerId || '').trim(), @@ -157,7 +157,7 @@ export async function exchangeKuaishouIndustryAuthorizationCode( } } catch (error) { const message = error instanceof Error ? error.message : String(error) - patchKuaishouIndustrySourceConfig({ + await patchKuaishouIndustrySourceConfig({ lastRefreshError: message, }) logWarn('[kuaishou-industry/token]', '授权码换取 accessToken 失败', resolveRefreshErrorDetail(error)) @@ -314,7 +314,7 @@ async function requestKuaishouIndustryToken({ if (!response.ok || !isTokenResponseSuccess(json) || !tokenPayload.accessToken) { const message = resolveTokenErrorMessage(json, response.status, failureMessage) - patchKuaishouIndustrySourceConfig({ + await patchKuaishouIndustrySourceConfig({ lastRefreshError: message, }) throw createHttpError(message, { @@ -326,7 +326,7 @@ async function requestKuaishouIndustryToken({ return tokenPayload } -function saveTokenPayload( +async function saveTokenPayload( tokenPayload: ReturnType, config: KuaishouIndustrySourceConfig, shop: KuaishouIndustryShopConfig, @@ -340,7 +340,7 @@ function saveTokenPayload( } const nextRefreshToken = tokenPayload.refreshToken || shop.refreshToken - const savedConfig = patchKuaishouIndustryShopConfig(nextSellerId, { + const savedConfig = await patchKuaishouIndustryShopConfig(nextSellerId, { ...shop, enabled: shop.enabled !== false, sellerId: nextSellerId, diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index 21a11a05..47daea99 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -1,7 +1,11 @@ import path from 'node:path' +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' import { PROJECT_ROOT } from '../../config/runtime.js' -import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js' +import { + readAppConfigEntry, + saveAppConfigEntry, +} from '../config/app-config-store.js' const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json') const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health' @@ -13,11 +17,21 @@ export function getScheduledJobsFilePath() { } export function getScheduledJobsConfig() { - return loadScheduledJobsConfigFromFile() + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.scheduledJobs, + legacyFilePath: SCHEDULED_JOBS_FILE_PATH, + fallback: createDefaultScheduledJobsConfig, + normalize: normalizeScheduledJobsConfig, + }) } export function saveScheduledJobsConfig(rawValue: unknown) { - return writeJsonFile(SCHEDULED_JOBS_FILE_PATH, rawValue, normalizeScheduledJobsConfig) + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.scheduledJobs, + legacyFilePath: SCHEDULED_JOBS_FILE_PATH, + value: rawValue, + normalize: normalizeScheduledJobsConfig, + }) } export function getCloudtentaclesHealthJob(config: JsonObject = getScheduledJobsConfig()) { @@ -26,14 +40,6 @@ export function getCloudtentaclesHealthJob(config: JsonObject = getScheduledJobs || createDefaultCloudtentaclesHealthJob() } -function loadScheduledJobsConfigFromFile() { - return readJsonFile( - SCHEDULED_JOBS_FILE_PATH, - createDefaultScheduledJobsConfig, - normalizeScheduledJobsConfig, - ) -} - export function normalizeScheduledJobsConfig(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {} const rawJobs = Array.isArray(source.jobs) ? source.jobs : [] diff --git a/apps/backend/src/startup/bootstrap.ts b/apps/backend/src/startup/bootstrap.ts index afef4764..73a3eec6 100644 --- a/apps/backend/src/startup/bootstrap.ts +++ b/apps/backend/src/startup/bootstrap.ts @@ -1,6 +1,7 @@ import { runDatabaseMigrations } from "../db/migrate.js"; import { ensureAdminUsersBootstrapped } from "../services/admin/admin-auth-service.js"; import { ensureFulfillmentCatalogBootstrapped } from "../services/bootstrap/fulfillment-bootstrap-service.js"; +import { migrateJsonConfigFilesToDatabase } from "../services/config/json-config-migration-service.js"; import { startKuaishouIndustrySendCallbackRetryWorker } from "../services/platforms/kuaishou-industry/send-code-service.js"; import { startScheduledJobs } from "../services/scheduler/scheduler-service.js"; import { logError, logInfo } from "../utils/logger.js"; @@ -30,6 +31,7 @@ export async function bootstrapCoreServices( }); await runDatabaseMigrations(); + await migrateJsonConfigFilesToDatabase(); await ensureFulfillmentCatalogBootstrapped(); await ensureAdminUsersBootstrapped();