diff --git a/apps/backend/src/config/runtime-env.ts b/apps/backend/src/config/runtime-env.ts new file mode 100644 index 00000000..0b052246 --- /dev/null +++ b/apps/backend/src/config/runtime-env.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs' +import process from 'node:process' + +export function loadEnvFiles(filePaths: string[]): void { + for (const filePath of filePaths) { + loadEnvFile(filePath) + } +} + +function loadEnvFile(filePath: string): void { + if (!fs.existsSync(filePath)) { + return + } + + const rawText = fs.readFileSync(filePath, 'utf8') + const lines = rawText.split(/\r?\n/) + + for (const rawLine of lines) { + const line = rawLine.trim() + + if (!line || line.startsWith('#')) { + continue + } + + const separatorIndex = line.indexOf('=') + if (separatorIndex <= 0) { + continue + } + + const key = line.slice(0, separatorIndex).trim() + if (!key || key in process.env) { + continue + } + + process.env[key] = parseEnvValue(line.slice(separatorIndex + 1)) + } +} + +function parseEnvValue(rawValue: string): string { + const value = String(rawValue || '').trim() + + if (!value) { + return '' + } + + const quote = value[0] + if ((quote === '"' || quote === "'") && value.endsWith(quote)) { + return value.slice(1, -1) + } + + return value +} + +export function parseBoolean(rawValue: unknown): boolean | null { + const normalized = String(rawValue || '') + .trim() + .toLowerCase() + + if (!normalized) { + return null + } + + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true + } + + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false + } + + return null +} + +export function parseInteger(rawValue: unknown): number | null { + const normalized = String(rawValue || '').trim() + + if (!normalized) { + return null + } + + const parsed = Number(normalized) + return Number.isFinite(parsed) ? parsed : null +} + +export function parseJsonArray(rawValue: unknown): T[] | null { + const normalized = String(rawValue || '').trim() + + if (!normalized) { + return null + } + + try { + const parsed = JSON.parse(normalized) + return Array.isArray(parsed) ? parsed as T[] : null + } catch { + return null + } +} diff --git a/apps/backend/src/config/runtime-loader.ts b/apps/backend/src/config/runtime-loader.ts new file mode 100644 index 00000000..85b21632 --- /dev/null +++ b/apps/backend/src/config/runtime-loader.ts @@ -0,0 +1,15 @@ +import fs from 'node:fs' +import { createRequire } from 'node:module' + +import { isPlainObject } from './runtime-merge.js' + +const require = createRequire(import.meta.url) + +export function loadConfig(configPath: string): Record { + if (!fs.existsSync(configPath)) { + return {} + } + + const loaded = require(configPath) + return isPlainObject(loaded) ? loaded : {} +} diff --git a/apps/backend/src/config/runtime-merge.ts b/apps/backend/src/config/runtime-merge.ts new file mode 100644 index 00000000..a7d3a750 --- /dev/null +++ b/apps/backend/src/config/runtime-merge.ts @@ -0,0 +1,44 @@ +type PlainObject = Record + +export function deepMerge(baseValue: T, overrideValue: PlainObject): T { + if (!isPlainObject(baseValue)) { + return cloneValue(overrideValue) as T + } + + const result = cloneValue(baseValue) as PlainObject + + if (!isPlainObject(overrideValue)) { + return result as T + } + + for (const [key, value] of Object.entries(overrideValue)) { + if (isPlainObject(value) && isPlainObject(result[key])) { + result[key] = deepMerge(result[key] as PlainObject, value) + continue + } + + result[key] = cloneValue(value) + } + + return result as T +} + +function cloneValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneValue(item)) as T + } + + if (isPlainObject(value)) { + const output: PlainObject = {} + for (const [key, item] of Object.entries(value)) { + output[key] = cloneValue(item) + } + return output as T + } + + return value +} + +export function isPlainObject(value: unknown): value is PlainObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.ts similarity index 82% rename from apps/backend/src/config/runtime.js rename to apps/backend/src/config/runtime.ts index b680fdaf..8d99c4b7 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.ts @@ -1,14 +1,12 @@ -// @ts-check - -import fs from "node:fs"; import path from "node:path"; import process from "node:process"; -import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -/** @typedef {import('../types/runtime-config.js').RuntimeConfig} RuntimeConfig */ +import { loadEnvFiles, parseBoolean, parseInteger, parseJsonArray } from "./runtime-env.js"; +import { loadConfig } from "./runtime-loader.js"; +import { deepMerge } from "./runtime-merge.js"; +import type { AdminDefaultUser, RuntimeConfig } from "../types/runtime-config.js"; -const require = createRequire(import.meta.url); const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); export const PROJECT_ROOT = path.resolve(CURRENT_DIR, "../.."); const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, "../.."); @@ -19,74 +17,11 @@ loadEnvFiles([ path.join(PROJECT_ROOT, ".env"), ]); -const defaultConfig = /** @type {RuntimeConfig} */ ( - loadConfig(path.join(CONFIG_ROOT, "default.cjs")) -); +const defaultConfig = loadConfig(path.join(CONFIG_ROOT, "default.cjs")) as RuntimeConfig; -export const runtimeConfig = /** @type {RuntimeConfig} */ ( - applyEnvOverrides(defaultConfig) -); +export const runtimeConfig = applyEnvOverrides(defaultConfig); -function loadConfig(configPath) { - if (!fs.existsSync(configPath)) { - return {}; - } - - const loaded = require(configPath); - return isPlainObject(loaded) ? loaded : {}; -} - -function loadEnvFiles(filePaths) { - for (const filePath of filePaths) { - loadEnvFile(filePath); - } -} - -function loadEnvFile(filePath) { - if (!fs.existsSync(filePath)) { - return; - } - - const rawText = fs.readFileSync(filePath, "utf8"); - const lines = rawText.split(/\r?\n/); - - for (const rawLine of lines) { - const line = rawLine.trim(); - - if (!line || line.startsWith("#")) { - continue; - } - - const separatorIndex = line.indexOf("="); - if (separatorIndex <= 0) { - continue; - } - - const key = line.slice(0, separatorIndex).trim(); - if (!key || key in process.env) { - continue; - } - - process.env[key] = parseEnvValue(line.slice(separatorIndex + 1)); - } -} - -function parseEnvValue(rawValue) { - const value = String(rawValue || "").trim(); - - if (!value) { - return ""; - } - - const quote = value[0]; - if ((quote === '"' || quote === "'") && value.endsWith(quote)) { - return value.slice(1, -1); - } - - return value; -} - -function applyEnvOverrides(baseConfig) { +function applyEnvOverrides(baseConfig: RuntimeConfig): RuntimeConfig { const nextConfig = deepMerge(baseConfig, {}); const port = parseInteger(process.env.PORT); @@ -192,7 +127,7 @@ function applyEnvOverrides(baseConfig) { nextConfig.admin.sessionTtlHours = adminSessionTtlHours; } - const adminDefaultUsers = parseJsonArray( + const adminDefaultUsers = parseJsonArray( process.env.ADMIN_DEFAULT_USERS_JSON ); if (adminDefaultUsers) { @@ -582,100 +517,3 @@ function applyEnvOverrides(baseConfig) { return nextConfig; } - -function deepMerge(baseValue, overrideValue) { - if (!isPlainObject(baseValue)) { - return cloneValue(overrideValue); - } - - const result = cloneValue(baseValue); - - if (!isPlainObject(overrideValue)) { - return result; - } - - for (const [key, value] of Object.entries(overrideValue)) { - if (isPlainObject(value) && isPlainObject(result[key])) { - result[key] = deepMerge(result[key], value); - continue; - } - - result[key] = cloneValue(value); - } - - return result; -} - -function cloneValue(value) { - if (Array.isArray(value)) { - return value.map((item) => cloneValue(item)); - } - - if (isPlainObject(value)) { - const output = {}; - for (const [key, item] of Object.entries(value)) { - output[key] = cloneValue(item); - } - return output; - } - - return value; -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === "[object Object]"; -} - -function parseBoolean(rawValue) { - const normalized = String(rawValue || "") - .trim() - .toLowerCase(); - - if (!normalized) { - return null; - } - - if (["1", "true", "yes", "on"].includes(normalized)) { - return true; - } - - if (["0", "false", "no", "off"].includes(normalized)) { - return false; - } - - return null; -} - -function parseInteger(rawValue) { - const normalized = String(rawValue || "").trim(); - - if (!normalized) { - return null; - } - - const parsed = Number(normalized); - return Number.isFinite(parsed) ? parsed : null; -} - -function parseJsonArray(rawValue) { - const normalized = String(rawValue || "").trim(); - - if (!normalized) { - return null; - } - - try { - const parsed = JSON.parse(normalized); - return Array.isArray(parsed) ? parsed : null; - } catch { - return null; - } -} - -function normalizeBooleanLike(value) { - if (typeof value === "boolean") { - return value; - } - - return parseBoolean(value); -} diff --git a/docs/backend-typescript-migration-plan.md b/docs/backend-typescript-migration-plan.md index aa196167..081802e4 100644 --- a/docs/backend-typescript-migration-plan.md +++ b/docs/backend-typescript-migration-plan.md @@ -237,13 +237,23 @@ - `src/repositories/task-repo.ts` 18. `TaskRow` 已补齐任务读取链路实际使用的主表、领取 token、腾讯上下文、库存绑定字段 19. 核心 repository 迁移阶段已收口,Docker 内 `typecheck / build / test` 继续通过 +20. `runtime.js` 已拆分并迁移到 `.ts`: + - `src/config/runtime.ts` + - `src/config/runtime-env.ts` + - `src/config/runtime-loader.ts` + - `src/config/runtime-merge.ts` +21. env 文件加载、env 值解析、默认配置加载、深合并逻辑已从 runtime 主入口分离 +22. Docker 内再次验证通过: + - `npm run typecheck` + - `npm run build` + - `npm test` ## 下一步建议 第一批继续推进时,建议按这个顺序: -1. 拆分并迁移 `runtime.js`,把 env 解析、默认配置加载、配置合并分开 -2. 为 webhook、库存换码、自动发货补测试 +1. 为 webhook、库存换码、自动发货补测试 +2. 继续迁移服务层中最核心、最常改的订单 / 履约 / claim 模块 ## 执行原则