后端拆分运行时配置模块

This commit is contained in:
yml
2026-05-21 14:06:49 +08:00
parent 5753971c92
commit 84ca9cac09
5 changed files with 177 additions and 172 deletions
+98
View File
@@ -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<T = unknown>(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
}
}
+15
View File
@@ -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<string, unknown> {
if (!fs.existsSync(configPath)) {
return {}
}
const loaded = require(configPath)
return isPlainObject(loaded) ? loaded : {}
}
+44
View File
@@ -0,0 +1,44 @@
type PlainObject = Record<string, unknown>
export function deepMerge<T extends PlainObject>(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<T>(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]'
}
@@ -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<AdminDefaultUser>(
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);
}
+12 -2
View File
@@ -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 模块
## 执行原则