diff --git a/.env.development.example b/.env.development.example index d7f6a569..1194953f 100644 --- a/.env.development.example +++ b/.env.development.example @@ -11,6 +11,7 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/order_site DATABASE_SSL=false DATABASE_MAX_CONNECTIONS=10 DATA_ROOT=/app/data +LOG_LEVEL=info OCR_PROJECT_ROOT=/app/subservices/ocr-worker TENCENT_REDEEM_PROOF_MODE=basic TENCENT_BROWSER_HEADLESS=true diff --git a/.env.production.example b/.env.production.example index ae23b720..039b992c 100644 --- a/.env.production.example +++ b/.env.production.example @@ -8,6 +8,7 @@ DATABASE_URL=postgres://postgres:replace-with-strong-password@postgres:5432/orde DATABASE_SSL=false DATABASE_MAX_CONNECTIONS=20 DATA_ROOT=/app/data +LOG_LEVEL=info OCR_PROJECT_ROOT=/app/subservices/ocr-worker TENCENT_REDEEM_PROOF_MODE=basic TENCENT_BROWSER_HEADLESS=true diff --git a/.env.服务端 b/.env.服务端 index dcc719d2..156edfdc 100644 --- a/.env.服务端 +++ b/.env.服务端 @@ -8,6 +8,7 @@ DATABASE_URL=postgres://postgres:FACCqheAzy51op0RFdbv@postgres:5432/order_site DATABASE_SSL=false DATABASE_MAX_CONNECTIONS=20 DATA_ROOT=/app/data +LOG_LEVEL=info OCR_PROJECT_ROOT=/app/subservices/ocr-worker TENCENT_REDEEM_PROOF_MODE=basic TENCENT_BROWSER_HEADLESS=true diff --git a/apps/backend/README.md b/apps/backend/README.md index 7a7e529f..deddb9ff 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -63,6 +63,7 @@ npm run typecheck 当前最常改的配置有: - 服务端口 +- 日志级别 `LOG_LEVEL=debug|info|warn|error` - 浏览器是否无头、是否预热、是否常驻、slowMo - OCR 子服务目录 - 会话调试开关 @@ -143,8 +144,18 @@ npm run typecheck 运行日志默认保存在: -- `data/logs/app.log` -- `data/logs/webhook.log` +- `data/logs/app-YYYY-MM-DD.log` +- `data/logs/webhook-YYYY-MM-DD.log` + +日志按天切分,默认自动清理 7 天前的旧日志。 +成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。 + +`LOG_LEVEL` 默认是 `info`: + +- `debug`:输出最详细的调试日志 +- `info`:输出启动、请求、业务成功/失败等常规日志 +- `warn`:只保留告警和错误 +- `error`:只保留错误 OCR 子服务已经简化为: diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs index b165617d..b839445d 100644 --- a/apps/backend/config/default.cjs +++ b/apps/backend/config/default.cjs @@ -26,6 +26,10 @@ module.exports = { root: path.resolve(__dirname, '../data'), }, + logging: { + level: 'info', + }, + database: { url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site', ssl: false, diff --git a/apps/backend/data/agiso-shops.json b/apps/backend/data/agiso-shops.json index 9162ebe4..9b0a7196 100644 --- a/apps/backend/data/agiso-shops.json +++ b/apps/backend/data/agiso-shops.json @@ -1,6 +1,6 @@ { "defaults": { - "messageTemplate": "您的订单 {platformOrderId} 已创建领取链接,请尽快打开并完成领取:{claimUrl}", + "messageTemplate": "您的订单 {platformOrderId} 已创建领取链接,请您复制链接到 浏览器 自动兑换, 请尽快打开并完成领取:\\n\\n{claimUrl}", "autoDeliveryMessageTemplate": "亲亲,您购买的兑换码已自行兑换成功,请在游戏中邮件领取,感谢您的支持" }, "shops": { diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index 7d917b96..faa1402b 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -135,6 +135,11 @@ function applyEnvOverrides(baseConfig) { nextConfig.data.root = path.resolve(dataRoot) } + const logLevel = String(process.env.LOG_LEVEL || '').trim() + if (logLevel) { + nextConfig.logging.level = logLevel + } + const databaseUrl = String(process.env.DATABASE_URL || '').trim() if (databaseUrl) { nextConfig.database.url = databaseUrl diff --git a/apps/backend/src/index.js b/apps/backend/src/index.js index e2f9b899..3029e691 100644 --- a/apps/backend/src/index.js +++ b/apps/backend/src/index.js @@ -11,7 +11,7 @@ import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfi import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/session/ocr.js' import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js' import { buildSuccessPayload } from './utils/http.js' -import { logError, logInfo, logWarn } from './utils/logger.js' +import { createRequestId, logError, logInfo, logWarn } from './utils/logger.js' import tencentRouter from './routes/tencent.js' const CORE_BOOT_RETRY_DELAY_MS = 5_000 @@ -21,6 +21,38 @@ const port = Number(runtimeConfig.server.port || 3000) const host = '0.0.0.0' const startupState = createStartupState() +app.use((req, res, next) => { + const startedAt = Date.now() + const requestId = resolveRequestId(req) + + req.requestId = requestId + res.setHeader('X-Request-Id', requestId) + + res.on('finish', () => { + if (shouldSkipAccessLog(req.originalUrl, res.statusCode)) { + return + } + + logInfo('[http/access]', 'request completed', { + requestId, + method: req.method, + originalUrl: req.originalUrl, + statusCode: res.statusCode, + durationMs: Date.now() - startedAt, + ip: req.ip, + forwardedFor: String(req.headers['x-forwarded-for'] || ''), + userAgent: String(req.headers['user-agent'] || ''), + referer: String(req.headers.referer || ''), + contentLength: Number(res.getHeader('content-length') || 0), + actorUserId: req.adminSession?.userId || '', + actorUsername: req.adminSession?.username || '', + actorRole: req.adminSession?.role || '', + }) + }) + + next() +}) + app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') @@ -303,6 +335,16 @@ function sleep(ms) { }) } +function resolveRequestId(req) { + const fromHeader = String(req.headers['x-request-id'] || '').trim() + return fromHeader || createRequestId('req') +} + +function shouldSkipAccessLog(originalUrl, statusCode) { + const pathname = String(originalUrl || '').split('?')[0] + return ['/health', '/health/live', '/health/ready'].includes(pathname) && Number(statusCode) < 400 +} + let shutdownStarted = false async function shutdown(signal) { diff --git a/apps/backend/src/repositories/webhook-event-repo.js b/apps/backend/src/repositories/webhook-event-repo.js index 27dec964..9463363f 100644 --- a/apps/backend/src/repositories/webhook-event-repo.js +++ b/apps/backend/src/repositories/webhook-event-repo.js @@ -92,6 +92,7 @@ export async function listWebhookEvents({ platform = '', platformOrderId = '', processed = '', + visibility = 'important', relatedOrderId = '', dateFrom = '', dateTo = '', @@ -120,6 +121,12 @@ export async function listWebhookEvents({ filters.push(`processed = $${params.length}`) } + if (visibility === 'ignored') { + filters.push(`process_error LIKE 'ignored_%'`) + } else if (visibility === 'important') { + filters.push(`(process_error = '' OR process_error NOT LIKE 'ignored_%')`) + } + if (relatedOrderId) { params.push(Number(relatedOrderId)) filters.push(`related_order_id = $${params.length}`) diff --git a/apps/backend/src/services/admin/admin-read-service.js b/apps/backend/src/services/admin/admin-read-service.js index b253c842..513f5d14 100644 --- a/apps/backend/src/services/admin/admin-read-service.js +++ b/apps/backend/src/services/admin/admin-read-service.js @@ -382,6 +382,7 @@ export async function getAdminWebhookEvents(query = /** @type {AdminWebhookEvent platform: String(query.platform || '').trim(), platformOrderId: String(query.platformOrderId || '').trim(), processed: String(query.processed || '').trim(), + visibility: String(query.visibility || '').trim() || 'important', relatedOrderId: String(query.relatedOrderId || '').trim(), dateFrom: normalizeDateQuery(query.dateFrom), dateTo: normalizeDateQuery(query.dateTo, true), diff --git a/apps/backend/src/services/admin/admin-webhook-read-helpers.js b/apps/backend/src/services/admin/admin-webhook-read-helpers.js index 72c21f25..33482b26 100644 --- a/apps/backend/src/services/admin/admin-webhook-read-helpers.js +++ b/apps/backend/src/services/admin/admin-webhook-read-helpers.js @@ -76,6 +76,7 @@ export async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) { signatureValid: Boolean(item.signature_valid), processed: Boolean(item.processed), processError: item.process_error, + visibilityLevel: resolveWebhookVisibilityLevel(item.process_error), relatedOrderId: item.related_order_id, createdAt: item.created_at, platformOrderId: resolveAgisoTradePlatformOrderId(payload), @@ -166,6 +167,16 @@ function extractWebhookItemSources(payload) { return [] } +export function resolveWebhookVisibilityLevel(processError) { + const normalized = String(processError || '').trim() + + if (normalized.startsWith('ignored_')) { + return 'ignored' + } + + return 'important' +} + function normalizeRecord(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {} } diff --git a/apps/backend/src/services/admin/admin-webhook-read-helpers.test.js b/apps/backend/src/services/admin/admin-webhook-read-helpers.test.js new file mode 100644 index 00000000..496d098c --- /dev/null +++ b/apps/backend/src/services/admin/admin-webhook-read-helpers.test.js @@ -0,0 +1,14 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { resolveWebhookVisibilityLevel } from './admin-webhook-read-helpers.js' + +test('resolveWebhookVisibilityLevel marks ignored process errors as ignored', () => { + assert.equal(resolveWebhookVisibilityLevel('ignored_unconfigured_product'), 'ignored') + assert.equal(resolveWebhookVisibilityLevel('ignored_duplicate_event'), 'ignored') +}) + +test('resolveWebhookVisibilityLevel keeps normal and failed events as important', () => { + assert.equal(resolveWebhookVisibilityLevel(''), 'important') + assert.equal(resolveWebhookVisibilityLevel('invalid_signature'), 'important') +}) diff --git a/apps/backend/src/types/admin-read-inputs.js b/apps/backend/src/types/admin-read-inputs.js index 67ad8221..ce466a04 100644 --- a/apps/backend/src/types/admin-read-inputs.js +++ b/apps/backend/src/types/admin-read-inputs.js @@ -63,6 +63,7 @@ export {} * platform?: string * platformOrderId?: string * processed?: string + * visibility?: string * relatedOrderId?: string * dateFrom?: string * dateTo?: string diff --git a/apps/backend/src/types/admin-read-models.js b/apps/backend/src/types/admin-read-models.js index 643e6c45..77ba465f 100644 --- a/apps/backend/src/types/admin-read-models.js +++ b/apps/backend/src/types/admin-read-models.js @@ -126,6 +126,7 @@ export {} * signatureValid: boolean * processed: boolean * processError: string + * visibilityLevel: string * relatedOrderId: number | null * platformOrderId: string * buyerId: string diff --git a/apps/backend/src/types/repository-inputs.js b/apps/backend/src/types/repository-inputs.js index 27295a96..d707a26f 100644 --- a/apps/backend/src/types/repository-inputs.js +++ b/apps/backend/src/types/repository-inputs.js @@ -179,6 +179,7 @@ export {} * platform?: string * platformOrderId?: string * processed?: string + * visibility?: string * relatedOrderId?: string * dateFrom?: string * dateTo?: string diff --git a/apps/backend/src/types/runtime-config.js b/apps/backend/src/types/runtime-config.js index 1b65551c..db07c07d 100644 --- a/apps/backend/src/types/runtime-config.js +++ b/apps/backend/src/types/runtime-config.js @@ -50,6 +50,9 @@ export {} * data: { * root: string * } + * logging: { + * level: string + * } * database: { * url: string * ssl: boolean diff --git a/apps/backend/src/utils/logger.js b/apps/backend/src/utils/logger.js index 157f26ad..dadace09 100644 --- a/apps/backend/src/utils/logger.js +++ b/apps/backend/src/utils/logger.js @@ -1,15 +1,43 @@ import fs from 'node:fs/promises' import path from 'node:path' import process from 'node:process' +import util from 'node:util' import { PROJECT_ROOT, runtimeConfig } from '../config/runtime.js' +const LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40, +} + +const LEVEL_LABELS = { + debug: 'DEBUG', + info: 'INFO ', + warn: 'WARN ', + error: 'ERROR', +} + +const ANSI = { + reset: '\u001B[0m', + dim: '\u001B[2m', + bold: '\u001B[1m', + gray: '\u001B[90m', + cyan: '\u001B[36m', + blue: '\u001B[34m', + yellow: '\u001B[33m', + red: '\u001B[31m', +} + const DATA_ROOT = resolveDataRoot() const LOG_DIR = path.join(DATA_ROOT, 'logs') -const APP_LOG_FILE = path.join(LOG_DIR, 'app.log') -const WEBHOOK_LOG_FILE = path.join(LOG_DIR, 'webhook.log') +const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level) +const LOG_RETENTION_DAYS = 7 +const LEGACY_LOG_FILES = new Set(['app.log', 'webhook.log']) let writeQueue = Promise.resolve() +let lastCleanupDateKey = '' export function createRequestId(prefix = 'req') { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` @@ -36,9 +64,13 @@ export function logWebhook(scope, message, detail, { level = 'info' } = {}) { } function writeLog(level, scope, message, detail, { channel = 'app' } = {}) { + if (!shouldWriteLog(level, ACTIVE_LOG_LEVEL)) { + return null + } + const entry = { time: new Date().toISOString(), - level, + level: normalizeLogLevel(level), scope: String(scope || 'app').trim() || 'app', message: String(message || '').trim() || '-', pid: process.pid, @@ -46,17 +78,19 @@ function writeLog(level, scope, message, detail, { channel = 'app' } = {}) { } writeConsole(entry) - enqueueFileWrite(entry, channel === 'webhook' ? WEBHOOK_LOG_FILE : APP_LOG_FILE) + enqueueFileWrite(entry, resolveLogFilePath(channel, entry.time)) return entry } function enqueueFileWrite(entry, filePath) { - const line = JSON.stringify(entry) + const line = formatLogEntry(entry, { color: false }) + const dateKey = extractLogDateKey(entry.time) writeQueue = writeQueue .then(async () => { await fs.mkdir(LOG_DIR, { recursive: true }) await fs.appendFile(filePath, `${line}\n`, 'utf8') + await cleanupExpiredLogsIfNeeded(dateKey) }) .catch((error) => { const reason = error instanceof Error ? error.message : String(error || '未知错误') @@ -65,29 +99,99 @@ function enqueueFileWrite(entry, filePath) { } function writeConsole(entry) { - const prefix = `[${entry.time}] [${entry.level}] [${entry.scope}] ${entry.message}` const logger = resolveConsoleMethod(entry.level) - - if (typeof entry.detail === 'undefined') { - logger(prefix) - return - } - - logger(prefix, entry.detail) + logger(formatLogEntry(entry, { color: supportsAnsiColor() })) } function resolveConsoleMethod(level) { - if (level === 'error') { + const normalized = normalizeLogLevel(level) + + if (normalized === 'error') { return console.error } - if (level === 'warn') { + if (normalized === 'warn') { return console.warn } return console.log } +export function normalizeLogLevel(level) { + const normalized = String(level || '').trim().toLowerCase() + return LOG_LEVEL_PRIORITY[normalized] != null ? normalized : 'info' +} + +export function shouldWriteLog(level, configuredLevel = ACTIVE_LOG_LEVEL) { + const normalizedLevel = normalizeLogLevel(level) + const normalizedConfigured = normalizeLogLevel(configuredLevel) + + return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured] +} + +export function formatLogEntry(entry, { color = false } = {}) { + const normalizedEntry = { + time: String(entry?.time || new Date().toISOString()), + level: normalizeLogLevel(entry?.level), + scope: String(entry?.scope || 'app').trim() || 'app', + message: String(entry?.message || '').trim() || '-', + pid: Number(entry?.pid || process.pid), + detail: entry?.detail, + } + const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color) + const level = colorize(LEVEL_LABELS[normalizedEntry.level], resolveLevelColor(normalizedEntry.level), color) + const scope = colorize(normalizedEntry.scope, ANSI.cyan, color) + const message = colorize(normalizedEntry.message, ANSI.bold, color) + const separator = colorize('|', ANSI.dim, color) + const { inline, block } = splitDetailPayload(normalizedEntry.detail) + const inlineFields = formatInlineFields({ + pid: normalizedEntry.pid, + ...inline, + }) + const firstLine = [timestamp, level, scope, message, separator, inlineFields].filter(Boolean).join(' ') + + if (!block) { + return firstLine + } + + const inspected = util.inspect(block, { + depth: 8, + colors: color, + compact: false, + breakLength: 120, + maxArrayLength: 100, + }) + + return `${firstLine}\n${indentBlock(inspected, ' ')}` +} + +export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()) { + const normalizedChannel = String(channel || '').trim() === 'webhook' ? 'webhook' : 'app' + return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`) +} + +export function resolveExpiredLogFilenames( + fileNames = [], + referenceTime = new Date().toISOString(), + retentionDays = LOG_RETENTION_DAYS, +) { + const normalizedRetentionDays = Math.max(1, Number(retentionDays) || LOG_RETENTION_DAYS) + const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1))) + + return (Array.isArray(fileNames) ? fileNames : []) + .filter((fileName) => shouldDeleteLogFileByDate(fileName, cutoffDateKey)) +} + +export function formatLogTimestamp(value) { + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return String(value || '').trim() || new Date().toISOString() + } + + return `${formatLocalDate(date)} ${formatLocalTime(date)}.${String(date.getMilliseconds()).padStart(3, '0')} ${formatTimezoneOffset(date)}` +} + function resolveDataRoot() { const configured = String(runtimeConfig.data?.root || '').trim() return configured ? path.resolve(configured) : path.resolve(PROJECT_ROOT, 'data') @@ -122,3 +226,175 @@ function normalizeLogValue(value) { return String(value) } } + +function splitDetailPayload(detail) { + if (typeof detail === 'undefined') { + return { + inline: {}, + block: null, + } + } + + if (isScalarLogValue(detail)) { + return { + inline: { detail }, + block: null, + } + } + + if (Array.isArray(detail)) { + return { + inline: {}, + block: detail, + } + } + + if (detail && typeof detail === 'object') { + const inline = {} + const block = {} + + for (const [key, value] of Object.entries(detail)) { + if (isScalarLogValue(value)) { + inline[key] = value + continue + } + + block[key] = value + } + + return { + inline, + block: Object.keys(block).length > 0 ? block : null, + } + } + + return { + inline: { detail: String(detail) }, + block: null, + } +} + +function formatInlineFields(fields) { + return Object.entries(fields) + .filter(([, value]) => typeof value !== 'undefined' && value !== '') + .map(([key, value]) => `${key}=${formatInlineValue(value)}`) + .join(' ') +} + +function formatInlineValue(value) { + if (value === null) { + return 'null' + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + + const text = String(value || '') + return /^[A-Za-z0-9_./:@-]+$/.test(text) ? text : JSON.stringify(text) +} + +function isScalarLogValue(value) { + return value === null || ['string', 'number', 'boolean'].includes(typeof value) +} + +function indentBlock(text, indent) { + return String(text || '') + .split('\n') + .map((line) => `${indent}${line}`) + .join('\n') +} + +function supportsAnsiColor() { + return process.env.NO_COLOR !== '1' && Boolean(process.stdout.isTTY || process.stderr.isTTY) +} + +function resolveLevelColor(level) { + switch (normalizeLogLevel(level)) { + case 'debug': + return ANSI.blue + case 'warn': + return ANSI.yellow + case 'error': + return ANSI.red + default: + return ANSI.cyan + } +} + +function colorize(text, ansiCode, enabled) { + if (!enabled || !ansiCode) { + return text + } + + return `${ansiCode}${text}${ANSI.reset}` +} + +function formatLocalDate(date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +function formatLocalTime(date) { + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}` +} + +function formatTimezoneOffset(date) { + const totalMinutes = -date.getTimezoneOffset() + const sign = totalMinutes >= 0 ? '+' : '-' + const absoluteMinutes = Math.abs(totalMinutes) + const hours = String(Math.floor(absoluteMinutes / 60)).padStart(2, '0') + const minutes = String(absoluteMinutes % 60).padStart(2, '0') + return `${sign}${hours}:${minutes}` +} + +async function cleanupExpiredLogsIfNeeded(dateKey) { + if (!dateKey || dateKey === lastCleanupDateKey) { + return + } + + lastCleanupDateKey = dateKey + + try { + const fileNames = await fs.readdir(LOG_DIR) + const expired = resolveExpiredLogFilenames(fileNames, `${dateKey}T00:00:00.000Z`) + 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 || '未知错误') + console.error('[logger] failed to cleanup old log files:', reason) + } +} + +function extractLogDateKey(time) { + const normalized = String(time || '').trim() + return /^\d{4}-\d{2}-\d{2}/.test(normalized) ? normalized.slice(0, 10) : toDateKey(normalized) +} + +function toDateKey(value) { + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return new Date().toISOString().slice(0, 10) + } + + return date.toISOString().slice(0, 10) +} + +function offsetDate(value, offsetDays) { + const date = new Date(value) + date.setUTCDate(date.getUTCDate() + Number(offsetDays || 0)) + return date +} + +function shouldDeleteLogFileByDate(fileName, cutoffDateKey) { + if (LEGACY_LOG_FILES.has(String(fileName || '').trim())) { + return true + } + + const matched = /^(app|webhook)-(\d{4}-\d{2}-\d{2})\.log$/.exec(String(fileName || '').trim()) + + if (!matched) { + return false + } + + return matched[2] < cutoffDateKey +} diff --git a/apps/backend/src/utils/logger.test.js b/apps/backend/src/utils/logger.test.js new file mode 100644 index 00000000..a9a07d3a --- /dev/null +++ b/apps/backend/src/utils/logger.test.js @@ -0,0 +1,104 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + formatLogEntry, + formatLogTimestamp, + normalizeLogLevel, + resolveExpiredLogFilenames, + resolveLogFilePath, + shouldWriteLog, +} from './logger.js' + +test('normalizeLogLevel falls back to info for unsupported values', () => { + assert.equal(normalizeLogLevel('debug'), 'debug') + assert.equal(normalizeLogLevel('WARN'), 'warn') + assert.equal(normalizeLogLevel('verbose'), 'info') + assert.equal(normalizeLogLevel(''), 'info') +}) + +test('shouldWriteLog respects configured minimum log level', () => { + assert.equal(shouldWriteLog('debug', 'debug'), true) + assert.equal(shouldWriteLog('info', 'debug'), true) + assert.equal(shouldWriteLog('debug', 'info'), false) + assert.equal(shouldWriteLog('info', 'warn'), false) + assert.equal(shouldWriteLog('warn', 'warn'), true) + assert.equal(shouldWriteLog('error', 'warn'), true) +}) + +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('webhook', '2026-04-14T10:00:00.000Z'), /webhook-2026-04-14\.log$/) +}) + +test('resolveExpiredLogFilenames keeps latest seven days 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', + 'webhook-2026-04-06.log', + 'app.log', + 'webhook.log', + 'random.txt', + ], '2026-04-14T12:00:00.000Z', 7) + + assert.deepEqual(expired, [ + 'app-2026-04-07.log', + 'webhook-2026-04-06.log', + 'app.log', + 'webhook.log', + ]) +}) + +test('formatLogTimestamp renders readable local timestamp with timezone offset', () => { + const rendered = formatLogTimestamp('2026-04-14T10:36:24.974Z') + assert.match(rendered, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2}$/) +}) + +test('formatLogEntry prints readable one-line output for flat details', () => { + const text = formatLogEntry({ + time: '2026-04-14T10:36:24.974Z', + level: 'info', + scope: '[http/access]', + message: 'request completed', + pid: 4671, + detail: { + requestId: 'req-123', + method: 'GET', + statusCode: 401, + }, + }, { color: false }) + + assert.match(text, /INFO\s+\[http\/access\] request completed/) + assert.match(text, /pid=4671/) + assert.match(text, /requestId=req-123/) + assert.match(text, /method=GET/) + assert.match(text, /statusCode=401/) + assert.doesNotMatch(text, /^\{/) +}) + +test('formatLogEntry prints nested detail blocks without ansi colors in file mode', () => { + const text = formatLogEntry({ + time: '2026-04-14T10:36:24.974Z', + level: 'warn', + scope: '[admin/auth]', + message: '后台鉴权失败', + pid: 4671, + detail: { + statusCode: 401, + errorCode: 'admin_auth_required', + error: { + message: '未登录或登录已失效', + stack: 'Error: test', + }, + }, + }, { color: false }) + + assert.match(text, /WARN\s+\[admin\/auth\] 后台鉴权失败/) + assert.match(text, /statusCode=401/) + assert.match(text, /errorCode=admin_auth_required/) + assert.match(text, /\n \{/) + assert.match(text, /未登录或登录已失效/) + assert.doesNotMatch(text, /\u001B\[/) +}) diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index 9788e3fa..5dc85368 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -486,6 +486,7 @@ export interface AdminWebhookEventListItem { signatureValid: boolean processed: boolean processError: string + visibilityLevel: string relatedOrderId: number | null platformOrderId: string buyerId: string diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index df96a829..1295b3d1 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -54,6 +54,12 @@ export const adminWebhookProcessedOptions = [ { label: '处理失败', value: '0' }, ] +export const adminWebhookVisibilityOptions = [ + { label: '仅重要', value: 'important' }, + { label: '仅忽略', value: 'ignored' }, + { label: '全部', value: 'all' }, +] + export const adminUserRoleOptions = [ { label: '全部', value: '' }, { label: '管理员', value: 'admin' }, diff --git a/apps/frontend/src/views/admin/AdminWebhookEventsView.vue b/apps/frontend/src/views/admin/AdminWebhookEventsView.vue index 6c845659..aa749887 100644 --- a/apps/frontend/src/views/admin/AdminWebhookEventsView.vue +++ b/apps/frontend/src/views/admin/AdminWebhookEventsView.vue @@ -9,7 +9,7 @@ import type { AdminPagination, AdminWebhookEventListItem } from '@/types/admin' import { hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' import { formatAdminWebhookEventType } from '@/utils/admin-webhook' -import { adminWebhookProcessedOptions } from '@/utils/admin-options' +import { adminWebhookProcessedOptions, adminWebhookVisibilityOptions } from '@/utils/admin-options' const loading = ref(true) const actionLoadingId = ref(null) @@ -19,6 +19,7 @@ const provider = ref('') const platform = ref('') const platformOrderId = ref('') const processed = ref('') +const visibility = ref('important') const relatedOrderId = ref('') const dateFrom = ref('') const dateTo = ref('') @@ -40,6 +41,7 @@ async function loadEvents(page = pagination.value.page) { platform: platform.value.trim(), platformOrderId: platformOrderId.value.trim(), processed: processed.value.trim(), + visibility: visibility.value.trim(), relatedOrderId: relatedOrderId.value.trim(), dateFrom: dateFrom.value.trim(), dateTo: dateTo.value.trim(), @@ -116,6 +118,11 @@ onMounted(loadEvents) {{ option.label }} +