diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts b/apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts index 2e194558..6b3397e4 100644 --- a/apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts +++ b/apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts @@ -2,10 +2,7 @@ import crypto from 'node:crypto' import test from 'node:test' import assert from 'node:assert/strict' -import { - isKuaishouFeifeiSuccessResponse, - signKuaishouFeifeiPayload, -} from './http-client.js' +import { isKuaishouFeifeiSuccessResponse, signKuaishouFeifeiPayload } from './http-client.js' test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC SHA256', () => { 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', () => { assert.equal(isKuaishouFeifeiSuccessResponse({ code: 0, 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: 1, message: 'success' }), false) }) diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts b/apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts index f876ca9c..9ba0741f 100644 --- a/apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts +++ b/apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto' import { createHttpError } from '../../../utils/http.js' +import { createRequestId, logExternalHttpPacket } from '../../../utils/logger.js' import { assertKuaishouFeifeiConfig } from './config.js' type JsonObject = Record @@ -9,6 +10,9 @@ export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObjec const config = assertKuaishouFeifeiConfig() const body = JSON.stringify(payload) const timestamp = String(Math.floor(Date.now() / 1000)) + const packetId = createRequestId('ff') + const startedAt = Date.now() + let upstreamPacketLogged = false const sign = signKuaishouFeifeiPayload({ appKey: config.appKey, appSecret: config.appSecret, @@ -17,23 +21,62 @@ export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObjec }) const controller = new AbortController() 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 { - const response = await fetch(`${config.baseUrl}${pathname}`, { + const response = await fetch(url, { method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-app-key': config.appKey, - 'x-timestamp': timestamp, - 'x-sign': sign, - }, + headers: requestHeaders, body, signal: controller.signal, }) const text = await response.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) { + upstreamPacketLogged = true + logExternalHttpPacket('[kuaishou-feifei/http]', 'HTTP 响应失败', responsePacket, { + level: 'warn', + }) throw createHttpError(`kuaishou-feifei 请求失败: HTTP ${response.status}`, { statusCode: 502, errorCode: 'kuaishou_feifei_http_failed', @@ -45,6 +88,10 @@ export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObjec } if (!isKuaishouFeifeiSuccessResponse(json)) { + upstreamPacketLogged = true + logExternalHttpPacket('[kuaishou-feifei/http]', '业务响应失败', responsePacket, { + level: 'warn', + }) throw createHttpError(String(json.message || 'kuaishou-feifei 业务失败'), { statusCode: 502, 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 } catch (error) { 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 请求超时', { statusCode: 504, 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 } finally { clearTimeout(timeout) @@ -87,9 +179,11 @@ export function signKuaishouFeifeiPayload({ export function isKuaishouFeifeiSuccessResponse(json: JsonObject) { 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 { @@ -100,3 +194,37 @@ function parseJsonObject(text: string): JsonObject { return {} } } + +function isKuaishouFeifeiSuccessMessage(message: string) { + return message === 'success' || message === 'ok' || message.endsWith('成功') +} + +function normalizeFetchHeaders(headers: Headers) { + const result: Record = {} + 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, + } +} diff --git a/apps/backend/src/utils/logger.test.ts b/apps/backend/src/utils/logger.test.ts index 0280c63a..0f98d2de 100644 --- a/apps/backend/src/utils/logger.test.ts +++ b/apps/backend/src/utils/logger.test.ts @@ -28,7 +28,10 @@ test('shouldWriteLog respects configured minimum log level', () => { 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('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', () => { @@ -36,15 +39,18 @@ test('resolveLogFilePath uses local date for utc timestamps', () => { }) test('resolveExpiredLogFilenames keeps latest thirty days by default and ignores unknown files', () => { - const expired = resolveExpiredLogFilenames([ - '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-05-30') + const expired = resolveExpiredLogFilenames( + [ + '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-05-30', + ) assert.deepEqual(expired, [ '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', () => { - const expired = resolveExpiredLogFilenames([ - 'app-2026-04-14.log', - 'app-2026-04-08.log', - 'app-2026-04-07.log', - ], '2026-04-14', 7) + 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', - ]) + assert.deepEqual(expired, ['app-2026-04-07.log']) }) 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', () => { - 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, + 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 }) + { color: false }, + ) assert.match(text, /INFO\s+\[http\/access\] request completed/) 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', () => { - 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', + 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 }) + { color: false }, + ) assert.match(text, /WARN\s+\[admin\/auth\] 后台鉴权失败/) 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', () => { - const text = formatLogEntry({ - time: '2026-04-14T10:36:24.974Z', - level: 'info', - scope: '[security]', - message: 'masked', - pid: 4671, - detail: { - token: 'abcdef1234567890', - authorization: 'Bearer abcdef1234567890', - nested: { - cookie: 'sessionid=abcdef1234567890', - note: 'password=super-secret-value', + const text = formatLogEntry( + { + time: '2026-04-14T10:36:24.974Z', + level: 'info', + scope: '[security]', + message: 'masked', + pid: 4671, + detail: { + token: 'abcdef1234567890', + authorization: 'Bearer abcdef1234567890', + sign: '0123456789abcdef', + 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, /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, /Bearer\*\*\*\*567890/) assert.match(text, /password=super-\*\*\*\*-value/) diff --git a/apps/backend/src/utils/logger.ts b/apps/backend/src/utils/logger.ts index f85f9a3a..b0ceb911 100644 --- a/apps/backend/src/utils/logger.ts +++ b/apps/backend/src/utils/logger.ts @@ -11,6 +11,7 @@ type LogLevel = 'debug' | 'info' | 'warn' | 'error' type LogChannel = 'app' | 'integration' type LogDetail = unknown type InlineFields = Record +type ExternalHttpPacketDetail = Record type LogEntry = { time: string level: LogLevel @@ -51,7 +52,12 @@ const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level) 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 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 let writeQueue = Promise.resolve() @@ -86,6 +92,15 @@ export function logIntegration( 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( level: LogLevel, scope: unknown, @@ -147,18 +162,26 @@ function resolveConsoleMethod(level: unknown) { } export function normalizeLogLevel(level: unknown): LogLevel { - const normalized = String(level || '').trim().toLowerCase() + const normalized = String(level || '') + .trim() + .toLowerCase() 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 normalizedConfigured = normalizeLogLevel(configuredLevel) return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured] } -export function formatLogEntry(entry: Partial, { color = false }: { color?: boolean } = {}): string { +export function formatLogEntry( + entry: Partial, + { color = false }: { color?: boolean } = {}, +): string { const normalizedEntry = { time: String(entry?.time || new Date().toISOString()), level: normalizeLogLevel(entry?.level), @@ -168,7 +191,11 @@ export function formatLogEntry(entry: Partial, { color = false }: { co detail: sanitizeLogValue(entry?.detail), } 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 message = colorize(normalizedEntry.message, ANSI.bold, color) const separator = colorize('|', ANSI.dim, color) @@ -177,7 +204,9 @@ export function formatLogEntry(entry: Partial, { color = false }: { co pid: normalizedEntry.pid, ...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) { return firstLine @@ -207,8 +236,9 @@ export function resolveExpiredLogFilenames( const normalizedRetentionDays = normalizeLogRetentionDays(retentionDays) const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1))) - return (Array.isArray(fileNames) ? fileNames : []) - .filter((fileName) => shouldDeleteLogFileByDate(fileName, cutoffDateKey)) + return (Array.isArray(fileNames) ? fileNames : []).filter((fileName) => + shouldDeleteLogFileByDate(fileName, cutoffDateKey), + ) } export function formatLogTimestamp(value: unknown): string { @@ -232,27 +262,29 @@ function normalizeLogValue(value: unknown): unknown { } try { - return sanitizeLogValue(JSON.parse( - JSON.stringify(value, (_key, current) => { - if (current instanceof Error) { - const currentError = current as Error & HttpErrorLike - return { - name: current.name, - message: sanitizeLogString(current.message), - stack: sanitizeLogString(current.stack), - statusCode: currentError.statusCode, - errorCode: currentError.errorCode, - context: sanitizeLogValue(currentError.context), + return sanitizeLogValue( + JSON.parse( + JSON.stringify(value, (_key, current) => { + if (current instanceof Error) { + const currentError = current as Error & HttpErrorLike + return { + name: current.name, + message: sanitizeLogString(current.message), + stack: sanitizeLogString(current.stack), + statusCode: currentError.statusCode, + errorCode: currentError.errorCode, + context: sanitizeLogValue(currentError.context), + } } - } - if (typeof current === 'bigint') { - return String(current) - } + if (typeof current === 'bigint') { + return String(current) + } - return current - }), - )) + return current + }), + ), + ) } catch { return sanitizeLogString(String(value)) } @@ -305,7 +337,18 @@ function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown { function sanitizeLogString(value: unknown): string { 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( /\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi, (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, @@ -316,7 +359,61 @@ function isSensitiveLogKey(key: unknown): boolean { 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') { return { inline: {}, @@ -446,7 +543,9 @@ async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise { try { const fileNames = await fs.readdir(LOG_DIR) 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) { const reason = error instanceof Error ? error.message : String(error || '未知错误') console.error('[logger] failed to cleanup old log files:', reason)