From e4c9873c8d3020713c91cc5ff197103d2e41314f Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 28 May 2026 12:32:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=97=A5=E5=BF=97=E5=88=87?= =?UTF-8?q?=E5=88=86=E5=92=8C=E4=BF=9D=E7=95=99=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.mac-docker.example | 1 + .env.server.example | 1 + apps/backend/src/config/defaults.ts | 1 + apps/backend/src/config/env-overrides.test.ts | 5 ++ apps/backend/src/config/env-overrides.ts | 1 + apps/backend/src/types/runtime-config.ts | 1 + apps/backend/src/utils/logger.test.ts | 39 +++++++++----- apps/backend/src/utils/logger.ts | 51 +++++++++++++++---- docker-compose.dev.yml | 11 ++++ docker-compose.yml | 10 ++++ 10 files changed, 99 insertions(+), 22 deletions(-) diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 47226826..baa39b49 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -19,6 +19,7 @@ POSTGRES_PORT=5432 # Logging LOG_LEVEL=info +LOG_RETENTION_DAYS=30 # Admin ADMIN_SESSION_SECRET=dev-local-session-secret diff --git a/.env.server.example b/.env.server.example index 46f5f3ce..9aef4e82 100644 --- a/.env.server.example +++ b/.env.server.example @@ -18,6 +18,7 @@ DATABASE_URL=postgres://postgres:change-me-postgres-password@postgres:5432/order # Logging LOG_LEVEL=info +LOG_RETENTION_DAYS=30 # Admin ADMIN_SESSION_SECRET=change-me-long-random-session-secret diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 467de9e2..1991a58c 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -14,6 +14,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { logging: { level: "info", + retentionDays: 30, }, database: { diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts index 6b7b86af..db4a5e12 100644 --- a/apps/backend/src/config/env-overrides.test.ts +++ b/apps/backend/src/config/env-overrides.test.ts @@ -12,6 +12,7 @@ test('applyEnvOverrides maps typed environment values without mutating base conf PORT: '4000', DATA_ROOT: './tmp/backend-data', DATABASE_SSL: 'yes', + LOG_RETENTION_DAYS: '45', ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]', KAQUAN91_USER_ID: 'kaquan-user', KAQUAN91_SECRET: 'kaquan-secret', @@ -21,6 +22,7 @@ test('applyEnvOverrides maps typed environment values without mutating base conf assert.equal(config.server.port, 4000) assert.equal(config.data.root, path.resolve('./tmp/backend-data')) assert.equal(config.database.ssl, true) + assert.equal(config.logging.retentionDays, 45) assert.deepEqual(config.admin.defaultUsers, [ { username: 'admin', password: 'secret', role: 'admin' }, ]) @@ -55,12 +57,14 @@ test('applyEnvOverrides ignores empty or invalid values', () => { DATABASE_SSL: 'maybe', ADMIN_DEFAULT_USERS_JSON: '{"username":"admin"}', LOG_LEVEL: ' ', + LOG_RETENTION_DAYS: 'not-a-number', }) assert.equal(config.server.port, 3000) assert.equal(config.database.ssl, false) assert.deepEqual(config.admin.defaultUsers, []) assert.equal(config.logging.level, 'info') + assert.equal(config.logging.retentionDays, 30) }) test('ENV_OVERRIDES keeps environment variable names unique', () => { @@ -78,6 +82,7 @@ function createRuntimeConfig(): RuntimeConfig { }, logging: { level: 'info', + retentionDays: 30, }, database: { url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site', diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index d8bad757..13dc2498 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -31,6 +31,7 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ integerEnv("PORT", ["server", "port"]), stringEnv("DATA_ROOT", ["data", "root"], path.resolve), stringEnv("LOG_LEVEL", ["logging", "level"]), + integerEnv("LOG_RETENTION_DAYS", ["logging", "retentionDays"]), stringEnv("DATABASE_URL", ["database", "url"]), booleanEnv("DATABASE_SSL", ["database", "ssl"]), integerEnv("DATABASE_MAX_CONNECTIONS", ["database", "maxConnections"]), diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index cb953d73..f1b96c2e 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -14,6 +14,7 @@ export type RuntimeConfig = { }; logging: { level: string; + retentionDays: number; }; database: { url: string; diff --git a/apps/backend/src/utils/logger.test.ts b/apps/backend/src/utils/logger.test.ts index dfe5efe2..0280c63a 100644 --- a/apps/backend/src/utils/logger.test.ts +++ b/apps/backend/src/utils/logger.test.ts @@ -27,27 +27,42 @@ test('shouldWriteLog respects configured minimum log level', () => { }) test('resolveLogFilePath uses daily log file names by channel', () => { - assert.match(resolveLogFilePath('app', '2026-04-14T10:00:00.000Z'), /app-2026-04-14\.log$/) - assert.match(resolveLogFilePath('integration', '2026-04-14T10:00:00.000Z'), /integration-2026-04-14\.log$/) + assert.match(resolveLogFilePath('app', '2026-05-28T00:00:19.194+08:00'), /app-2026-05-28\.log$/) + assert.match(resolveLogFilePath('integration', '2026-05-28T00:00:19.194+08:00'), /integration-2026-05-28\.log$/) }) -test('resolveExpiredLogFilenames keeps latest seven days and ignores unknown files', () => { +test('resolveLogFilePath uses local date for utc timestamps', () => { + assert.match(resolveLogFilePath('app', '2026-05-27T16:00:19.194Z'), /app-2026-05-28\.log$/) +}) + +test('resolveExpiredLogFilenames keeps latest thirty days by default and ignores unknown files', () => { const expired = resolveExpiredLogFilenames([ - 'app-2026-04-14.log', - 'app-2026-04-13.log', - 'app-2026-04-08.log', - 'app-2026-04-07.log', - 'integration-2026-04-06.log', + 'app-2026-05-30.log', + 'app-2026-05-01.log', + 'app-2026-04-30.log', + 'integration-2026-04-29.log', 'app.log', 'integration.log', 'random.txt', - ], '2026-04-14T12:00:00.000Z', 7) + ], '2026-05-30') + + assert.deepEqual(expired, [ + 'app-2026-04-30.log', + 'integration-2026-04-29.log', + 'app.log', + 'integration.log', + ]) +}) + +test('resolveExpiredLogFilenames supports explicit retention days', () => { + const expired = resolveExpiredLogFilenames([ + 'app-2026-04-14.log', + 'app-2026-04-08.log', + 'app-2026-04-07.log', + ], '2026-04-14', 7) assert.deepEqual(expired, [ 'app-2026-04-07.log', - 'integration-2026-04-06.log', - 'app.log', - 'integration.log', ]) }) diff --git a/apps/backend/src/utils/logger.ts b/apps/backend/src/utils/logger.ts index ee0f9a8a..f85f9a3a 100644 --- a/apps/backend/src/utils/logger.ts +++ b/apps/backend/src/utils/logger.ts @@ -48,7 +48,8 @@ const ANSI = { const DATA_ROOT = resolveDataRoot() const LOG_DIR = path.join(DATA_ROOT, 'logs') const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level) -const LOG_RETENTION_DAYS = 7 +const DEFAULT_LOG_RETENTION_DAYS = 30 +const LOG_RETENTION_DAYS = normalizeLogRetentionDays(runtimeConfig.logging?.retentionDays) const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log']) const SENSITIVE_KEY_PATTERN = /token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session/i const MAX_SANITIZE_DEPTH = 8 @@ -203,7 +204,7 @@ export function resolveExpiredLogFilenames( referenceTime = new Date().toISOString(), retentionDays = LOG_RETENTION_DAYS, ): string[] { - const normalizedRetentionDays = Math.max(1, Number(retentionDays) || LOG_RETENTION_DAYS) + const normalizedRetentionDays = normalizeLogRetentionDays(retentionDays) const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1))) return (Array.isArray(fileNames) ? fileNames : []) @@ -444,7 +445,7 @@ async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise { try { const fileNames = await fs.readdir(LOG_DIR) - const expired = resolveExpiredLogFilenames(fileNames, `${dateKey}T00:00:00.000Z`) + const expired = resolveExpiredLogFilenames(fileNames, dateKey, LOG_RETENTION_DAYS) await Promise.all(expired.map((fileName) => fs.rm(path.join(LOG_DIR, fileName), { force: true }))) } catch (error) { const reason = error instanceof Error ? error.message : String(error || '未知错误') @@ -453,26 +454,56 @@ async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise { } function extractLogDateKey(time: unknown): string { - const normalized = String(time || '').trim() - return /^\d{4}-\d{2}-\d{2}/.test(normalized) ? normalized.slice(0, 10) : toDateKey(normalized) + return toDateKey(time) } function toDateKey(value: unknown): string { - const date = new Date(value instanceof Date ? value : String(value || '')) + const date = parseLogDate(value) if (Number.isNaN(date.getTime())) { - return new Date().toISOString().slice(0, 10) + return formatLocalDate(new Date()) } - return date.toISOString().slice(0, 10) + return formatLocalDate(date) } function offsetDate(value: unknown, offsetDays: unknown): Date { - const date = new Date(value instanceof Date ? value : String(value || '')) - date.setUTCDate(date.getUTCDate() + Number(offsetDays || 0)) + const date = parseLogDate(value) + if (Number.isNaN(date.getTime())) { + return new Date() + } + + date.setDate(date.getDate() + Number(offsetDays || 0)) return date } +function parseLogDate(value: unknown): Date { + if (value instanceof Date) { + return new Date(value.getTime()) + } + + const text = String(value || '').trim() + const matchedDateKey = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) + if (matchedDateKey) { + return new Date( + Number(matchedDateKey[1]), + Number(matchedDateKey[2]) - 1, + Number(matchedDateKey[3]), + ) + } + + return new Date(text) +} + +function normalizeLogRetentionDays(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return DEFAULT_LOG_RETENTION_DAYS + } + + return Math.max(1, Math.floor(parsed)) +} + function shouldDeleteLogFileByDate(fileName: unknown, cutoffDateKey: string): boolean { if (LEGACY_LOG_FILES.has(String(fileName || '').trim())) { return true diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4b1db197..4d584741 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,7 +1,14 @@ +x-json-log-rotation: &json-log-rotation + driver: json-file + options: + max-size: "20m" + max-file: "5" + services: postgres: image: docker.m.daocloud.io/library/postgres:16-bookworm restart: unless-stopped + logging: *json-log-rotation environment: TZ: ${TZ:-Asia/Shanghai} POSTGRES_DB: ${POSTGRES_DB:-order_site} @@ -47,9 +54,11 @@ services: DATABASE_SSL: ${DATABASE_SSL:-false} DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10} DATA_ROOT: /app/data + LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} + logging: *json-log-rotation volumes: - ./apps/backend:/app - backend_node_modules:/app/node_modules @@ -59,6 +68,7 @@ services: image: docker.m.daocloud.io/library/node:22-bookworm-slim working_dir: /app restart: unless-stopped + logging: *json-log-rotation command: >- sh -lc "[ -f node_modules/.install_done ] || (npm install --no-fund --no-audit && touch node_modules/.install_done); @@ -76,6 +86,7 @@ services: web: image: docker.m.daocloud.io/library/caddy:2-alpine restart: unless-stopped + logging: *json-log-rotation depends_on: backend: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index a3c0f5ab..06ef2dc6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,14 @@ +x-json-log-rotation: &json-log-rotation + driver: json-file + options: + max-size: "20m" + max-file: "5" + services: postgres: image: docker.m.daocloud.io/library/postgres:16-bookworm restart: unless-stopped + logging: *json-log-rotation environment: TZ: ${TZ:-Asia/Shanghai} POSTGRES_DB: ${POSTGRES_DB:-order_site} @@ -43,9 +50,11 @@ services: DATABASE_SSL: ${DATABASE_SSL:-false} DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-20} DATA_ROOT: /app/data + LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} + logging: *json-log-rotation volumes: - ./apps/backend/data:/app/data @@ -56,6 +65,7 @@ services: args: APP_DOMAIN: ${APP_DOMAIN:-localhost} restart: unless-stopped + logging: *json-log-rotation depends_on: backend: condition: service_healthy