import path from 'node:path' import type { JsonObject } from '../../types/json.js' import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' import { PROJECT_ROOT } from '../../config/runtime.js' import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json') const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout' const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze' const DATA_RETENTION_JOB_ID = 'data-retention' const PDD_ORDER_SYNC_JOB_ID = 'pdd_order_sync' /** v2 管道作业:各自只负责一层,可单独启停。 */ const CHANNEL_ASSIGN_JOB_ID = 'channel-assign' const CARRIER_DISPATCH_JOB_ID = 'carrier-dispatch' const PRODUCT_NORMALIZE_JOB_ID = 'product-normalize' const COST_SNAPSHOT_JOB_ID = 'cost-snapshot' const SALES_FACTS_JOB_ID = 'sales-facts' const LEDGER_POST_JOB_ID = 'ledger-post' export function getScheduledJobsFilePath() { return SCHEDULED_JOBS_FILE_PATH } export function getScheduledJobsConfig() { return readAppConfigEntry({ configKey: APP_CONFIG_KEYS.scheduledJobs, legacyFilePath: SCHEDULED_JOBS_FILE_PATH, fallback: createDefaultScheduledJobsConfig, normalize: normalizeScheduledJobsConfig, }) } export function saveScheduledJobsConfig(rawValue: unknown) { return saveAppConfigEntry({ configKey: APP_CONFIG_KEYS.scheduledJobs, legacyFilePath: SCHEDULED_JOBS_FILE_PATH, value: rawValue, normalize: normalizeScheduledJobsConfig, }) } export function getWorkOrderTimeoutJob(config: JsonObject = getScheduledJobsConfig()) { return ( (Array.isArray(config.jobs) ? config.jobs : []).find( (item) => String(item.id || '').trim() === WORK_ORDER_TIMEOUT_JOB_ID, ) || createDefaultWorkOrderTimeoutJob() ) } export function getDepositUnfreezeJob(config: JsonObject = getScheduledJobsConfig()) { return ( (Array.isArray(config.jobs) ? config.jobs : []).find( (item) => String(item.id || '').trim() === DEPOSIT_UNFREEZE_JOB_ID, ) || createDefaultDepositUnfreezeJob() ) } export function getPddOrderSyncJob(config: JsonObject = getScheduledJobsConfig()) { return ( (Array.isArray(config.jobs) ? config.jobs : []).find( (item) => String(item.id || '').trim() === PDD_ORDER_SYNC_JOB_ID, ) || createDefaultPddOrderSyncJob() ) } export function getScheduledJobById(jobId: unknown, config: JsonObject = getScheduledJobsConfig()) { return ( (Array.isArray(config.jobs) ? config.jobs : []).find( (item) => String(item.id || '').trim() === String(jobId || '').trim(), ) || null ) } export function normalizeScheduledJobsConfig(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {} const rawJobs = Array.isArray(source.jobs) ? source.jobs : [] const jobs = rawJobs .map((item) => normalizeScheduledJob(item)) .filter((item): item is NonNullable> => Boolean(item)) const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID) const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID) const hasDataRetention = jobs.some((item) => item.id === DATA_RETENTION_JOB_ID) const hasPddOrderSync = jobs.some((item) => item.id === PDD_ORDER_SYNC_JOB_ID) const hasChannelAssign = jobs.some((item) => item.id === CHANNEL_ASSIGN_JOB_ID) const hasCarrierDispatch = jobs.some((item) => item.id === CARRIER_DISPATCH_JOB_ID) const hasProductNormalize = jobs.some((item) => item.id === PRODUCT_NORMALIZE_JOB_ID) const hasCostSnapshot = jobs.some((item) => item.id === COST_SNAPSHOT_JOB_ID) const hasSalesFacts = jobs.some((item) => item.id === SALES_FACTS_JOB_ID) const hasLedgerPost = jobs.some((item) => item.id === LEDGER_POST_JOB_ID) if (!hasWorkOrderTimeout) { jobs.push(createDefaultWorkOrderTimeoutJob()) } if (!hasDepositUnfreeze) { jobs.push(createDefaultDepositUnfreezeJob()) } if (!hasDataRetention) { jobs.push(createDefaultDataRetentionJob()) } if (!hasPddOrderSync) { jobs.push(createDefaultPddOrderSyncJob()) } // 旧作业 cost_finance_sync 已下线:v2 管道接管,且它写旧字段会覆盖新口径。 // 保留配置项仅用于兼容读取,运行期强制停用。 // v2 管道:老配置里没有这些任务时自动补齐,保证升级后无需手工添加。 if (!hasChannelAssign) jobs.push(createDefaultChannelAssignJob()) if (!hasCarrierDispatch) jobs.push(createDefaultCarrierDispatchJob()) if (!hasProductNormalize) jobs.push(createDefaultProductNormalizeJob()) if (!hasCostSnapshot) jobs.push(createDefaultCostSnapshotJob()) if (!hasSalesFacts) jobs.push(createDefaultSalesFactsJob()) if (!hasLedgerPost) jobs.push(createDefaultLedgerPostJob()) return { enabled: typeof source.enabled === 'boolean' ? source.enabled : true, jobs, } } function normalizeScheduledJob(rawValue: unknown) { if (!isPlainObject(rawValue)) { return null } const type = String(rawValue.type || '').trim() // 已下线的 cloudtentacles_health 配置直接丢弃 if (type === 'cloudtentacles_health') { return null } if (type === 'work_order_timeout') { return normalizeWorkOrderTimeoutJob(rawValue) } if (type === 'deposit_unfreeze') { return normalizeDepositUnfreezeJob(rawValue) } if (type === 'data_retention') { return normalizeDataRetentionJob(rawValue) } if (type === 'pdd_order_sync') { return normalizePddOrderSyncJob(rawValue) } if (type === 'channel_assign') { return normalizeChannelAssignJob(rawValue) } if (type === 'carrier_dispatch') { return normalizeCarrierDispatchJob(rawValue) } if (type === 'product_normalize') { return normalizeProductNormalizeJob(rawValue) } if (type === 'cost_snapshot') { return normalizeCostSnapshotJob(rawValue) } if (type === 'sales_facts') { return normalizeSalesFactsJob(rawValue) } if (type === 'ledger_post') { return normalizeLedgerPostJob(rawValue) } return null } function normalizePddOrderSyncJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: PDD_ORDER_SYNC_JOB_ID, type: 'pdd_order_sync', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 20, 10, 3600), config: { pageSize: normalizeRangeInteger(config.pageSize, 10, 1, 50), }, } } function normalizeDepositUnfreezeJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: DEPOSIT_UNFREEZE_JOB_ID, type: 'deposit_unfreeze', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600), config: { scanLimit: normalizeRangeInteger(config.scanLimit, 100, 1, 2000), }, } } function normalizeDataRetentionJob(rawValue: JsonObject) { return { id: DATA_RETENTION_JOB_ID, type: 'data_retention', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 86400, 3600, 604800), config: {}, } } function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: WORK_ORDER_TIMEOUT_JOB_ID, type: 'work_order_timeout', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600), config: { scanLimit: normalizeRangeInteger(config.scanLimit, 50, 1, 1000), }, } } function createDefaultScheduledJobsConfig() { return { enabled: true, jobs: [ createDefaultWorkOrderTimeoutJob(), createDefaultDepositUnfreezeJob(), createDefaultDataRetentionJob(), createDefaultPddOrderSyncJob(), createDefaultChannelAssignJob(), createDefaultCarrierDispatchJob(), createDefaultProductNormalizeJob(), createDefaultCostSnapshotJob(), createDefaultSalesFactsJob(), createDefaultLedgerPostJob(), ], } } function normalizeChannelAssignJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: CHANNEL_ASSIGN_JOB_ID, type: 'channel_assign', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600), config: { batchSize: normalizeRangeInteger(config.batchSize, 500, 50, 5000), maxBatches: normalizeRangeInteger(config.maxBatches, 20, 1, 200), }, } } function normalizeCarrierDispatchJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: CARRIER_DISPATCH_JOB_ID, type: 'carrier_dispatch', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600), config: { limit: normalizeRangeInteger(config.limit, 200, 10, 2000), }, } } function normalizeProductNormalizeJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: PRODUCT_NORMALIZE_JOB_ID, type: 'product_normalize', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 3600), config: { batchSize: normalizeRangeInteger(config.batchSize, 500, 50, 5000), maxBatches: normalizeRangeInteger(config.maxBatches, 20, 1, 200), }, } } function normalizeCostSnapshotJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: COST_SNAPSHOT_JOB_ID, type: 'cost_snapshot', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 3600), config: { batchSize: normalizeRangeInteger(config.batchSize, 500, 50, 5000), maxBatches: normalizeRangeInteger(config.maxBatches, 20, 1, 200), }, } } function normalizeSalesFactsJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: SALES_FACTS_JOB_ID, type: 'sales_facts', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 600, 60, 3600), config: { days: normalizeRangeInteger(config.days, 3, 1, 60), }, } } function normalizeLedgerPostJob(rawValue: JsonObject) { const config = isPlainObject(rawValue.config) ? rawValue.config : {} return { id: LEDGER_POST_JOB_ID, type: 'ledger_post', enabled: rawValue.enabled !== false, intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 600, 60, 3600), config: { days: normalizeRangeInteger(config.days, 3, 1, 60), }, } } function createDefaultChannelAssignJob() { return { id: CHANNEL_ASSIGN_JOB_ID, type: 'channel_assign', enabled: true, intervalSeconds: 60, config: { batchSize: 500, maxBatches: 20 }, } } function createDefaultCarrierDispatchJob() { return { id: CARRIER_DISPATCH_JOB_ID, type: 'carrier_dispatch', enabled: true, intervalSeconds: 60, config: { limit: 200 }, } } function createDefaultProductNormalizeJob() { return { id: PRODUCT_NORMALIZE_JOB_ID, type: 'product_normalize', enabled: true, intervalSeconds: 300, config: { batchSize: 500, maxBatches: 20 }, } } function createDefaultCostSnapshotJob() { return { id: COST_SNAPSHOT_JOB_ID, type: 'cost_snapshot', enabled: true, intervalSeconds: 300, config: { batchSize: 500, maxBatches: 20 }, } } function createDefaultSalesFactsJob() { return { id: SALES_FACTS_JOB_ID, type: 'sales_facts', enabled: true, intervalSeconds: 600, config: { days: 3 }, } } function createDefaultLedgerPostJob() { return { id: LEDGER_POST_JOB_ID, type: 'ledger_post', enabled: true, intervalSeconds: 600, config: { days: 3 }, } } function createDefaultPddOrderSyncJob() { return { id: PDD_ORDER_SYNC_JOB_ID, type: 'pdd_order_sync', enabled: true, intervalSeconds: 20, config: { pageSize: 10, }, } } function createDefaultDataRetentionJob() { return { id: DATA_RETENTION_JOB_ID, type: 'data_retention', enabled: true, intervalSeconds: 86400, config: {}, } } function createDefaultDepositUnfreezeJob() { return { id: DEPOSIT_UNFREEZE_JOB_ID, type: 'deposit_unfreeze', enabled: true, intervalSeconds: 60, config: { scanLimit: 100, }, } } function createDefaultWorkOrderTimeoutJob() { return { id: WORK_ORDER_TIMEOUT_JOB_ID, type: 'work_order_timeout', enabled: true, intervalSeconds: 60, config: { scanLimit: 50, }, } } function normalizeRangeInteger(value: unknown, fallback: number, min: number, max: number) { const parsed = Number(value) if (!Number.isInteger(parsed)) { return fallback } return Math.min(max, Math.max(min, parsed)) } function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === '[object Object]' }