增加feifei 日志
This commit is contained in:
@@ -2,10 +2,7 @@ import crypto from 'node:crypto'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
import {
|
import { isKuaishouFeifeiSuccessResponse, signKuaishouFeifeiPayload } from './http-client.js'
|
||||||
isKuaishouFeifeiSuccessResponse,
|
|
||||||
signKuaishouFeifeiPayload,
|
|
||||||
} from './http-client.js'
|
|
||||||
|
|
||||||
test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC SHA256', () => {
|
test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC SHA256', () => {
|
||||||
const input = {
|
const input = {
|
||||||
@@ -26,6 +23,8 @@ test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC
|
|||||||
test('isKuaishouFeifeiSuccessResponse accepts documented and actual success codes', () => {
|
test('isKuaishouFeifeiSuccessResponse accepts documented and actual success codes', () => {
|
||||||
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 0, message: 'success' }), true)
|
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 0, message: 'success' }), true)
|
||||||
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: 'success' }), true)
|
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: 'success' }), true)
|
||||||
|
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: '下单成功' }), true)
|
||||||
|
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: '查询成功' }), true)
|
||||||
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: '商品不存在' }), false)
|
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: '商品不存在' }), false)
|
||||||
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 1, message: 'success' }), false)
|
assert.equal(isKuaishouFeifeiSuccessResponse({ code: 1, message: 'success' }), false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
import { createHttpError } from '../../../utils/http.js'
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
|
import { createRequestId, logExternalHttpPacket } from '../../../utils/logger.js'
|
||||||
import { assertKuaishouFeifeiConfig } from './config.js'
|
import { assertKuaishouFeifeiConfig } from './config.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
@@ -9,6 +10,9 @@ export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObjec
|
|||||||
const config = assertKuaishouFeifeiConfig()
|
const config = assertKuaishouFeifeiConfig()
|
||||||
const body = JSON.stringify(payload)
|
const body = JSON.stringify(payload)
|
||||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||||
|
const packetId = createRequestId('ff')
|
||||||
|
const startedAt = Date.now()
|
||||||
|
let upstreamPacketLogged = false
|
||||||
const sign = signKuaishouFeifeiPayload({
|
const sign = signKuaishouFeifeiPayload({
|
||||||
appKey: config.appKey,
|
appKey: config.appKey,
|
||||||
appSecret: config.appSecret,
|
appSecret: config.appSecret,
|
||||||
@@ -17,23 +21,62 @@ export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObjec
|
|||||||
})
|
})
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs)
|
const timeout = setTimeout(() => controller.abort(), config.timeoutMs)
|
||||||
|
const url = `${config.baseUrl}${pathname}`
|
||||||
|
const requestHeaders = {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-app-key': config.appKey,
|
||||||
|
'x-timestamp': timestamp,
|
||||||
|
'x-sign': sign,
|
||||||
|
}
|
||||||
|
|
||||||
|
logExternalHttpPacket('[kuaishou-feifei/http]', '发送请求', {
|
||||||
|
packetId,
|
||||||
|
method: 'POST',
|
||||||
|
pathname,
|
||||||
|
url,
|
||||||
|
timeoutMs: config.timeoutMs,
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: payload,
|
||||||
|
rawBody: body,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${config.baseUrl}${pathname}`, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: requestHeaders,
|
||||||
'content-type': 'application/json',
|
|
||||||
'x-app-key': config.appKey,
|
|
||||||
'x-timestamp': timestamp,
|
|
||||||
'x-sign': sign,
|
|
||||||
},
|
|
||||||
body,
|
body,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
const json = parseJsonObject(text)
|
const json = parseJsonObject(text)
|
||||||
|
const durationMs = Date.now() - startedAt
|
||||||
|
const responsePacket = {
|
||||||
|
packetId,
|
||||||
|
method: 'POST',
|
||||||
|
pathname,
|
||||||
|
url,
|
||||||
|
status: response.status,
|
||||||
|
durationMs,
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: payload,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
ok: response.ok,
|
||||||
|
headers: normalizeFetchHeaders(response.headers),
|
||||||
|
summary: summarizeKuaishouFeifeiResponse(json),
|
||||||
|
body: json,
|
||||||
|
rawText: text,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
upstreamPacketLogged = true
|
||||||
|
logExternalHttpPacket('[kuaishou-feifei/http]', 'HTTP 响应失败', responsePacket, {
|
||||||
|
level: 'warn',
|
||||||
|
})
|
||||||
throw createHttpError(`kuaishou-feifei 请求失败: HTTP ${response.status}`, {
|
throw createHttpError(`kuaishou-feifei 请求失败: HTTP ${response.status}`, {
|
||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
errorCode: 'kuaishou_feifei_http_failed',
|
errorCode: 'kuaishou_feifei_http_failed',
|
||||||
@@ -45,6 +88,10 @@ export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObjec
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isKuaishouFeifeiSuccessResponse(json)) {
|
if (!isKuaishouFeifeiSuccessResponse(json)) {
|
||||||
|
upstreamPacketLogged = true
|
||||||
|
logExternalHttpPacket('[kuaishou-feifei/http]', '业务响应失败', responsePacket, {
|
||||||
|
level: 'warn',
|
||||||
|
})
|
||||||
throw createHttpError(String(json.message || 'kuaishou-feifei 业务失败'), {
|
throw createHttpError(String(json.message || 'kuaishou-feifei 业务失败'), {
|
||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
errorCode: 'kuaishou_feifei_business_failed',
|
errorCode: 'kuaishou_feifei_business_failed',
|
||||||
@@ -52,15 +99,60 @@ export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObjec
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upstreamPacketLogged = true
|
||||||
|
logExternalHttpPacket('[kuaishou-feifei/http]', '请求完成', responsePacket)
|
||||||
|
|
||||||
return json
|
return json
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as Error)?.name === 'AbortError') {
|
if ((error as Error)?.name === 'AbortError') {
|
||||||
|
logExternalHttpPacket(
|
||||||
|
'[kuaishou-feifei/http]',
|
||||||
|
'请求超时',
|
||||||
|
{
|
||||||
|
packetId,
|
||||||
|
method: 'POST',
|
||||||
|
pathname,
|
||||||
|
url,
|
||||||
|
timeoutMs: config.timeoutMs,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: payload,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 'warn',
|
||||||
|
},
|
||||||
|
)
|
||||||
throw createHttpError('kuaishou-feifei 请求超时', {
|
throw createHttpError('kuaishou-feifei 请求超时', {
|
||||||
statusCode: 504,
|
statusCode: 504,
|
||||||
errorCode: 'kuaishou_feifei_timeout',
|
errorCode: 'kuaishou_feifei_timeout',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!upstreamPacketLogged) {
|
||||||
|
logExternalHttpPacket(
|
||||||
|
'[kuaishou-feifei/http]',
|
||||||
|
'请求异常',
|
||||||
|
{
|
||||||
|
packetId,
|
||||||
|
method: 'POST',
|
||||||
|
pathname,
|
||||||
|
url,
|
||||||
|
timeoutMs: config.timeoutMs,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: payload,
|
||||||
|
},
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 'error',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
@@ -87,9 +179,11 @@ export function signKuaishouFeifeiPayload({
|
|||||||
|
|
||||||
export function isKuaishouFeifeiSuccessResponse(json: JsonObject) {
|
export function isKuaishouFeifeiSuccessResponse(json: JsonObject) {
|
||||||
const code = Number(json.code ?? 0)
|
const code = Number(json.code ?? 0)
|
||||||
const message = String(json.message || json.msg || '').trim().toLowerCase()
|
const message = String(json.message || json.msg || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
return code === 0 || (code === 10000 && message === 'success')
|
return code === 0 || (code === 10000 && isKuaishouFeifeiSuccessMessage(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonObject(text: string): JsonObject {
|
function parseJsonObject(text: string): JsonObject {
|
||||||
@@ -100,3 +194,37 @@ function parseJsonObject(text: string): JsonObject {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isKuaishouFeifeiSuccessMessage(message: string) {
|
||||||
|
return message === 'success' || message === 'ok' || message.endsWith('成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFetchHeaders(headers: Headers) {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
result[key] = value
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeKuaishouFeifeiResponse(json: JsonObject) {
|
||||||
|
const data = json.data && typeof json.data === 'object' ? (json.data as JsonObject) : {}
|
||||||
|
const order = data.order && typeof data.order === 'object' ? (data.order as JsonObject) : null
|
||||||
|
const h5 = order?.h5 && typeof order.h5 === 'object' ? (order.h5 as JsonObject) : {}
|
||||||
|
const list = Array.isArray(data.list) ? data.list : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: json.code,
|
||||||
|
message: String(json.message || json.msg || '').trim(),
|
||||||
|
hasOrder: Boolean(order),
|
||||||
|
orderNo: String(order?.order_no || '').trim(),
|
||||||
|
platformOrderNo: String(order?.platform_order_no || '').trim(),
|
||||||
|
productCode: String(order?.product_code || '').trim(),
|
||||||
|
rechargeStatus: Number(order?.recharge_status ?? order?.status ?? 0) || 0,
|
||||||
|
rechargeStatusLabel: String(order?.recharge_status_label || order?.status_label || '').trim(),
|
||||||
|
hasH5EntryUrl: Boolean(String(h5.entry_url || '').trim()),
|
||||||
|
hasH5RechargeUrl: Boolean(String(h5.recharge_url || '').trim()),
|
||||||
|
listCount: list.length,
|
||||||
|
total: Number(data.total || list.length || 0) || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ test('shouldWriteLog respects configured minimum log level', () => {
|
|||||||
|
|
||||||
test('resolveLogFilePath uses daily log file names by channel', () => {
|
test('resolveLogFilePath uses daily log file names by channel', () => {
|
||||||
assert.match(resolveLogFilePath('app', '2026-05-28T00:00:19.194+08:00'), /app-2026-05-28\.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$/)
|
assert.match(
|
||||||
|
resolveLogFilePath('integration', '2026-05-28T00:00:19.194+08:00'),
|
||||||
|
/integration-2026-05-28\.log$/,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('resolveLogFilePath uses local date for utc timestamps', () => {
|
test('resolveLogFilePath uses local date for utc timestamps', () => {
|
||||||
@@ -36,15 +39,18 @@ test('resolveLogFilePath uses local date for utc timestamps', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('resolveExpiredLogFilenames keeps latest thirty days by default and ignores unknown files', () => {
|
test('resolveExpiredLogFilenames keeps latest thirty days by default and ignores unknown files', () => {
|
||||||
const expired = resolveExpiredLogFilenames([
|
const expired = resolveExpiredLogFilenames(
|
||||||
'app-2026-05-30.log',
|
[
|
||||||
'app-2026-05-01.log',
|
'app-2026-05-30.log',
|
||||||
'app-2026-04-30.log',
|
'app-2026-05-01.log',
|
||||||
'integration-2026-04-29.log',
|
'app-2026-04-30.log',
|
||||||
'app.log',
|
'integration-2026-04-29.log',
|
||||||
'integration.log',
|
'app.log',
|
||||||
'random.txt',
|
'integration.log',
|
||||||
], '2026-05-30')
|
'random.txt',
|
||||||
|
],
|
||||||
|
'2026-05-30',
|
||||||
|
)
|
||||||
|
|
||||||
assert.deepEqual(expired, [
|
assert.deepEqual(expired, [
|
||||||
'app-2026-04-30.log',
|
'app-2026-04-30.log',
|
||||||
@@ -55,15 +61,13 @@ test('resolveExpiredLogFilenames keeps latest thirty days by default and ignores
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('resolveExpiredLogFilenames supports explicit retention days', () => {
|
test('resolveExpiredLogFilenames supports explicit retention days', () => {
|
||||||
const expired = resolveExpiredLogFilenames([
|
const expired = resolveExpiredLogFilenames(
|
||||||
'app-2026-04-14.log',
|
['app-2026-04-14.log', 'app-2026-04-08.log', 'app-2026-04-07.log'],
|
||||||
'app-2026-04-08.log',
|
'2026-04-14',
|
||||||
'app-2026-04-07.log',
|
7,
|
||||||
], '2026-04-14', 7)
|
)
|
||||||
|
|
||||||
assert.deepEqual(expired, [
|
assert.deepEqual(expired, ['app-2026-04-07.log'])
|
||||||
'app-2026-04-07.log',
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('formatLogTimestamp renders readable local timestamp with timezone offset', () => {
|
test('formatLogTimestamp renders readable local timestamp with timezone offset', () => {
|
||||||
@@ -72,18 +76,21 @@ test('formatLogTimestamp renders readable local timestamp with timezone offset',
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('formatLogEntry prints readable one-line output for flat details', () => {
|
test('formatLogEntry prints readable one-line output for flat details', () => {
|
||||||
const text = formatLogEntry({
|
const text = formatLogEntry(
|
||||||
time: '2026-04-14T10:36:24.974Z',
|
{
|
||||||
level: 'info',
|
time: '2026-04-14T10:36:24.974Z',
|
||||||
scope: '[http/access]',
|
level: 'info',
|
||||||
message: 'request completed',
|
scope: '[http/access]',
|
||||||
pid: 4671,
|
message: 'request completed',
|
||||||
detail: {
|
pid: 4671,
|
||||||
requestId: 'req-123',
|
detail: {
|
||||||
method: 'GET',
|
requestId: 'req-123',
|
||||||
statusCode: 401,
|
method: 'GET',
|
||||||
|
statusCode: 401,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}, { color: false })
|
{ color: false },
|
||||||
|
)
|
||||||
|
|
||||||
assert.match(text, /INFO\s+\[http\/access\] request completed/)
|
assert.match(text, /INFO\s+\[http\/access\] request completed/)
|
||||||
assert.match(text, /pid=4671/)
|
assert.match(text, /pid=4671/)
|
||||||
@@ -94,21 +101,24 @@ test('formatLogEntry prints readable one-line output for flat details', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('formatLogEntry prints nested detail blocks without ansi colors in file mode', () => {
|
test('formatLogEntry prints nested detail blocks without ansi colors in file mode', () => {
|
||||||
const text = formatLogEntry({
|
const text = formatLogEntry(
|
||||||
time: '2026-04-14T10:36:24.974Z',
|
{
|
||||||
level: 'warn',
|
time: '2026-04-14T10:36:24.974Z',
|
||||||
scope: '[admin/auth]',
|
level: 'warn',
|
||||||
message: '后台鉴权失败',
|
scope: '[admin/auth]',
|
||||||
pid: 4671,
|
message: '后台鉴权失败',
|
||||||
detail: {
|
pid: 4671,
|
||||||
statusCode: 401,
|
detail: {
|
||||||
errorCode: 'admin_auth_required',
|
statusCode: 401,
|
||||||
error: {
|
errorCode: 'admin_auth_required',
|
||||||
message: '未登录或登录已失效',
|
error: {
|
||||||
stack: 'Error: test',
|
message: '未登录或登录已失效',
|
||||||
|
stack: 'Error: test',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, { color: false })
|
{ color: false },
|
||||||
|
)
|
||||||
|
|
||||||
assert.match(text, /WARN\s+\[admin\/auth\] 后台鉴权失败/)
|
assert.match(text, /WARN\s+\[admin\/auth\] 后台鉴权失败/)
|
||||||
assert.match(text, /statusCode=401/)
|
assert.match(text, /statusCode=401/)
|
||||||
@@ -119,24 +129,38 @@ test('formatLogEntry prints nested detail blocks without ansi colors in file mod
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('formatLogEntry masks sensitive fields in inline and nested details', () => {
|
test('formatLogEntry masks sensitive fields in inline and nested details', () => {
|
||||||
const text = formatLogEntry({
|
const text = formatLogEntry(
|
||||||
time: '2026-04-14T10:36:24.974Z',
|
{
|
||||||
level: 'info',
|
time: '2026-04-14T10:36:24.974Z',
|
||||||
scope: '[security]',
|
level: 'info',
|
||||||
message: 'masked',
|
scope: '[security]',
|
||||||
pid: 4671,
|
message: 'masked',
|
||||||
detail: {
|
pid: 4671,
|
||||||
token: 'abcdef1234567890',
|
detail: {
|
||||||
authorization: 'Bearer abcdef1234567890',
|
token: 'abcdef1234567890',
|
||||||
nested: {
|
authorization: 'Bearer abcdef1234567890',
|
||||||
cookie: 'sessionid=abcdef1234567890',
|
sign: '0123456789abcdef',
|
||||||
note: 'password=super-secret-value',
|
jumpLink:
|
||||||
|
'https://feifei.example.com/h5/bind?code=very-secret-code&token=very-secret-token',
|
||||||
|
rawText:
|
||||||
|
'{"token":"raw-json-token","sign":"raw-json-sign","h5":{"recharge_url":"https://feifei.example.com/h5/bind?code=raw-secret-code"}}',
|
||||||
|
nested: {
|
||||||
|
cookie: 'sessionid=abcdef1234567890',
|
||||||
|
note: 'password=super-secret-value',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, { color: false })
|
{ color: false },
|
||||||
|
)
|
||||||
|
|
||||||
assert.doesNotMatch(text, /abcdef1234567890/)
|
assert.doesNotMatch(text, /abcdef1234567890/)
|
||||||
assert.doesNotMatch(text, /super-secret-value/)
|
assert.doesNotMatch(text, /super-secret-value/)
|
||||||
|
assert.doesNotMatch(text, /0123456789abcdef/)
|
||||||
|
assert.doesNotMatch(text, /very-secret-code/)
|
||||||
|
assert.doesNotMatch(text, /very-secret-token/)
|
||||||
|
assert.doesNotMatch(text, /raw-json-token/)
|
||||||
|
assert.doesNotMatch(text, /raw-json-sign/)
|
||||||
|
assert.doesNotMatch(text, /raw-secret-code/)
|
||||||
assert.match(text, /abcdef\*\*\*\*567890/)
|
assert.match(text, /abcdef\*\*\*\*567890/)
|
||||||
assert.match(text, /Bearer\*\*\*\*567890/)
|
assert.match(text, /Bearer\*\*\*\*567890/)
|
||||||
assert.match(text, /password=super-\*\*\*\*-value/)
|
assert.match(text, /password=super-\*\*\*\*-value/)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
|||||||
type LogChannel = 'app' | 'integration'
|
type LogChannel = 'app' | 'integration'
|
||||||
type LogDetail = unknown
|
type LogDetail = unknown
|
||||||
type InlineFields = Record<string, string | number | boolean | null | undefined>
|
type InlineFields = Record<string, string | number | boolean | null | undefined>
|
||||||
|
type ExternalHttpPacketDetail = Record<string, unknown>
|
||||||
type LogEntry = {
|
type LogEntry = {
|
||||||
time: string
|
time: string
|
||||||
level: LogLevel
|
level: LogLevel
|
||||||
@@ -51,7 +52,12 @@ const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level)
|
|||||||
const DEFAULT_LOG_RETENTION_DAYS = 30
|
const DEFAULT_LOG_RETENTION_DAYS = 30
|
||||||
const LOG_RETENTION_DAYS = normalizeLogRetentionDays(runtimeConfig.logging?.retentionDays)
|
const LOG_RETENTION_DAYS = normalizeLogRetentionDays(runtimeConfig.logging?.retentionDays)
|
||||||
const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log'])
|
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 SENSITIVE_KEY_PATTERN =
|
||||||
|
/token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session|x-sign|(?:^|[_-])sign(?:ature)?(?:[_-]|$)/i
|
||||||
|
const SENSITIVE_URL_PARAM_PATTERN =
|
||||||
|
/([?&](?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key|sign|signature|code)=)([^&\s,;"']+)/gi
|
||||||
|
const SENSITIVE_JSON_FIELD_PATTERN =
|
||||||
|
/("(?:token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardNo|cardPwd|apiKey|api_key|deviceKey|device_key|session|x-sign|sign|signature)"\s*:\s*")([^"]*)(")/gi
|
||||||
const MAX_SANITIZE_DEPTH = 8
|
const MAX_SANITIZE_DEPTH = 8
|
||||||
|
|
||||||
let writeQueue = Promise.resolve()
|
let writeQueue = Promise.resolve()
|
||||||
@@ -86,6 +92,15 @@ export function logIntegration(
|
|||||||
return writeLog(level, scope, message, detail, { channel: 'integration' })
|
return writeLog(level, scope, message, detail, { channel: 'integration' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logExternalHttpPacket(
|
||||||
|
scope: unknown,
|
||||||
|
message: unknown,
|
||||||
|
detail: ExternalHttpPacketDetail = {},
|
||||||
|
{ level = 'info' }: { level?: LogLevel } = {},
|
||||||
|
) {
|
||||||
|
return logIntegration(scope, message, normalizeExternalHttpPacketDetail(detail), { level })
|
||||||
|
}
|
||||||
|
|
||||||
function writeLog(
|
function writeLog(
|
||||||
level: LogLevel,
|
level: LogLevel,
|
||||||
scope: unknown,
|
scope: unknown,
|
||||||
@@ -147,18 +162,26 @@ function resolveConsoleMethod(level: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLogLevel(level: unknown): LogLevel {
|
export function normalizeLogLevel(level: unknown): LogLevel {
|
||||||
const normalized = String(level || '').trim().toLowerCase()
|
const normalized = String(level || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
return isLogLevel(normalized) ? normalized : 'info'
|
return isLogLevel(normalized) ? normalized : 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldWriteLog(level: unknown, configuredLevel: unknown = ACTIVE_LOG_LEVEL): boolean {
|
export function shouldWriteLog(
|
||||||
|
level: unknown,
|
||||||
|
configuredLevel: unknown = ACTIVE_LOG_LEVEL,
|
||||||
|
): boolean {
|
||||||
const normalizedLevel = normalizeLogLevel(level)
|
const normalizedLevel = normalizeLogLevel(level)
|
||||||
const normalizedConfigured = normalizeLogLevel(configuredLevel)
|
const normalizedConfigured = normalizeLogLevel(configuredLevel)
|
||||||
|
|
||||||
return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured]
|
return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLogEntry(entry: Partial<LogEntry>, { color = false }: { color?: boolean } = {}): string {
|
export function formatLogEntry(
|
||||||
|
entry: Partial<LogEntry>,
|
||||||
|
{ color = false }: { color?: boolean } = {},
|
||||||
|
): string {
|
||||||
const normalizedEntry = {
|
const normalizedEntry = {
|
||||||
time: String(entry?.time || new Date().toISOString()),
|
time: String(entry?.time || new Date().toISOString()),
|
||||||
level: normalizeLogLevel(entry?.level),
|
level: normalizeLogLevel(entry?.level),
|
||||||
@@ -168,7 +191,11 @@ export function formatLogEntry(entry: Partial<LogEntry>, { color = false }: { co
|
|||||||
detail: sanitizeLogValue(entry?.detail),
|
detail: sanitizeLogValue(entry?.detail),
|
||||||
}
|
}
|
||||||
const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color)
|
const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color)
|
||||||
const level = colorize(LEVEL_LABELS[normalizedEntry.level], resolveLevelColor(normalizedEntry.level), color)
|
const level = colorize(
|
||||||
|
LEVEL_LABELS[normalizedEntry.level],
|
||||||
|
resolveLevelColor(normalizedEntry.level),
|
||||||
|
color,
|
||||||
|
)
|
||||||
const scope = colorize(normalizedEntry.scope, ANSI.cyan, color)
|
const scope = colorize(normalizedEntry.scope, ANSI.cyan, color)
|
||||||
const message = colorize(normalizedEntry.message, ANSI.bold, color)
|
const message = colorize(normalizedEntry.message, ANSI.bold, color)
|
||||||
const separator = colorize('|', ANSI.dim, color)
|
const separator = colorize('|', ANSI.dim, color)
|
||||||
@@ -177,7 +204,9 @@ export function formatLogEntry(entry: Partial<LogEntry>, { color = false }: { co
|
|||||||
pid: normalizedEntry.pid,
|
pid: normalizedEntry.pid,
|
||||||
...inline,
|
...inline,
|
||||||
})
|
})
|
||||||
const firstLine = [timestamp, level, scope, message, separator, inlineFields].filter(Boolean).join(' ')
|
const firstLine = [timestamp, level, scope, message, separator, inlineFields]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
if (!block) {
|
if (!block) {
|
||||||
return firstLine
|
return firstLine
|
||||||
@@ -207,8 +236,9 @@ export function resolveExpiredLogFilenames(
|
|||||||
const normalizedRetentionDays = normalizeLogRetentionDays(retentionDays)
|
const normalizedRetentionDays = normalizeLogRetentionDays(retentionDays)
|
||||||
const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1)))
|
const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1)))
|
||||||
|
|
||||||
return (Array.isArray(fileNames) ? fileNames : [])
|
return (Array.isArray(fileNames) ? fileNames : []).filter((fileName) =>
|
||||||
.filter((fileName) => shouldDeleteLogFileByDate(fileName, cutoffDateKey))
|
shouldDeleteLogFileByDate(fileName, cutoffDateKey),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLogTimestamp(value: unknown): string {
|
export function formatLogTimestamp(value: unknown): string {
|
||||||
@@ -232,27 +262,29 @@ function normalizeLogValue(value: unknown): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return sanitizeLogValue(JSON.parse(
|
return sanitizeLogValue(
|
||||||
JSON.stringify(value, (_key, current) => {
|
JSON.parse(
|
||||||
if (current instanceof Error) {
|
JSON.stringify(value, (_key, current) => {
|
||||||
const currentError = current as Error & HttpErrorLike
|
if (current instanceof Error) {
|
||||||
return {
|
const currentError = current as Error & HttpErrorLike
|
||||||
name: current.name,
|
return {
|
||||||
message: sanitizeLogString(current.message),
|
name: current.name,
|
||||||
stack: sanitizeLogString(current.stack),
|
message: sanitizeLogString(current.message),
|
||||||
statusCode: currentError.statusCode,
|
stack: sanitizeLogString(current.stack),
|
||||||
errorCode: currentError.errorCode,
|
statusCode: currentError.statusCode,
|
||||||
context: sanitizeLogValue(currentError.context),
|
errorCode: currentError.errorCode,
|
||||||
|
context: sanitizeLogValue(currentError.context),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof current === 'bigint') {
|
if (typeof current === 'bigint') {
|
||||||
return String(current)
|
return String(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
return current
|
return current
|
||||||
}),
|
}),
|
||||||
))
|
),
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return sanitizeLogString(String(value))
|
return sanitizeLogString(String(value))
|
||||||
}
|
}
|
||||||
@@ -305,7 +337,18 @@ function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown {
|
|||||||
|
|
||||||
function sanitizeLogString(value: unknown): string {
|
function sanitizeLogString(value: unknown): string {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.replace(/(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi, (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`)
|
.replace(
|
||||||
|
/(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi,
|
||||||
|
(_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`,
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
SENSITIVE_URL_PARAM_PATTERN,
|
||||||
|
(_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`,
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
SENSITIVE_JSON_FIELD_PATTERN,
|
||||||
|
(_matched, prefix, secret, suffix) => `${prefix}${maskSecret(secret)}${suffix}`,
|
||||||
|
)
|
||||||
.replace(
|
.replace(
|
||||||
/\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi,
|
/\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi,
|
||||||
(_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`,
|
(_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`,
|
||||||
@@ -316,7 +359,61 @@ function isSensitiveLogKey(key: unknown): boolean {
|
|||||||
return SENSITIVE_KEY_PATTERN.test(String(key || '').trim())
|
return SENSITIVE_KEY_PATTERN.test(String(key || '').trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitDetailPayload(detail: unknown): { inline: InlineFields, block: unknown | null } {
|
function normalizeExternalHttpPacketDetail(
|
||||||
|
detail: ExternalHttpPacketDetail,
|
||||||
|
): ExternalHttpPacketDetail {
|
||||||
|
const source = detail && typeof detail === 'object' && !Array.isArray(detail) ? detail : {}
|
||||||
|
const normalized: ExternalHttpPacketDetail = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(source)) {
|
||||||
|
normalized[key] = normalizeExternalPacketValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalPacketValue(value: unknown): unknown {
|
||||||
|
if (typeof value === 'undefined' || value === null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Error) {
|
||||||
|
const currentError = value as Error & HttpErrorLike
|
||||||
|
return {
|
||||||
|
name: value.name,
|
||||||
|
message: value.message,
|
||||||
|
stack: value.stack,
|
||||||
|
statusCode: currentError.statusCode,
|
||||||
|
errorCode: currentError.errorCode,
|
||||||
|
context: normalizeExternalPacketValue(currentError.context),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => normalizeExternalPacketValue(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, currentValue]) => [
|
||||||
|
key,
|
||||||
|
normalizeExternalPacketValue(currentValue),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitDetailPayload(detail: unknown): { inline: InlineFields; block: unknown | null } {
|
||||||
if (typeof detail === 'undefined') {
|
if (typeof detail === 'undefined') {
|
||||||
return {
|
return {
|
||||||
inline: {},
|
inline: {},
|
||||||
@@ -446,7 +543,9 @@ async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const fileNames = await fs.readdir(LOG_DIR)
|
const fileNames = await fs.readdir(LOG_DIR)
|
||||||
const expired = resolveExpiredLogFilenames(fileNames, dateKey, LOG_RETENTION_DAYS)
|
const expired = resolveExpiredLogFilenames(fileNames, dateKey, LOG_RETENTION_DAYS)
|
||||||
await Promise.all(expired.map((fileName) => fs.rm(path.join(LOG_DIR, fileName), { force: true })))
|
await Promise.all(
|
||||||
|
expired.map((fileName) => fs.rm(path.join(LOG_DIR, fileName), { force: true })),
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : String(error || '未知错误')
|
const reason = error instanceof Error ? error.message : String(error || '未知错误')
|
||||||
console.error('[logger] failed to cleanup old log files:', reason)
|
console.error('[logger] failed to cleanup old log files:', reason)
|
||||||
|
|||||||
Reference in New Issue
Block a user