import type { Request, Response } from 'express' import { logError, logWarn } from './logger.js' const RESPONSE_TIME_ZONE = 'Asia/Shanghai' export type HttpErrorLike = { statusCode?: number errorCode?: string cause?: unknown context?: unknown [key: string]: unknown } type CreateHttpErrorOptions = { statusCode?: number errorCode?: string cause?: unknown context?: unknown } export function buildSuccessPayload(data: unknown, msg: string = 'ok') { return { code: 0, msg, time: Math.floor(Date.now() / 1000), data: normalizeResponseDateTime(data), } } export function buildErrorPayload(error: unknown, fallbackMessage: string) { const classification = classifyRouteError(error) const message = classification.statusCode >= 500 ? fallbackMessage || '服务内部错误' : error instanceof Error ? error.message : fallbackMessage return { code: 1, msg: message, errorCode: classification.errorCode, time: Math.floor(Date.now() / 1000), data: null as null, } } export function sendRouteError( res: Response, error: unknown, fallbackMessage = '服务内部错误', scope = '[route]', ): void { const classification = classifyRouteError(error) const logger = classification.statusCode >= 500 ? logError : logWarn logger(scope, fallbackMessage, { statusCode: classification.statusCode, errorCode: classification.errorCode, // 4xx 属预期失败(鉴权过期、参数错误等),带上完整错误对象只会刷十几行堆栈;仅 5xx 保留排查所需详情。 ...(classification.statusCode >= 500 ? { error } : {}), }) res.status(classification.statusCode).json(buildErrorPayload(error, fallbackMessage)) } export function buildNotFoundPayload(req: Request) { return { code: 1, msg: `未实现接口: ${req.method} ${req.originalUrl}`, time: Math.floor(Date.now() / 1000), data: null as null, } } export function createHttpError( message: string, options: CreateHttpErrorOptions = {}, ): Error & HttpErrorLike { const { statusCode = 500, errorCode = '', cause, context } = options const error = new Error(message) as Error & HttpErrorLike error.statusCode = statusCode error.errorCode = errorCode || defaultErrorCodeForStatus(statusCode) if (typeof cause !== 'undefined') { error.cause = cause } if (typeof context !== 'undefined') { error.context = context } return error } function classifyRouteError(error: unknown): { statusCode: number errorCode: string } { if (error && typeof error === 'object') { const currentError = error as HttpErrorLike const statusCode = Number(currentError.statusCode) const errorCode = String(currentError.errorCode || '').trim() if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode < 600) { return { statusCode, errorCode: errorCode || defaultErrorCodeForStatus(statusCode), } } } const message = error instanceof Error ? error.message : String(error || '') if (message.includes('缺少') || message.includes('无效') || message.includes('验签失败')) { return { statusCode: 400, errorCode: 'invalid_request' } } if (message.includes('不存在')) { return { statusCode: 404, errorCode: 'not_found' } } if (message.includes('已关闭')) { return { statusCode: 410, errorCode: 'gone' } } if (message.includes('不能') || message.includes('冲突')) { return { statusCode: 409, errorCode: 'conflict' } } return { statusCode: 500, errorCode: 'internal_error' } } function defaultErrorCodeForStatus(statusCode: number): string { if (statusCode === 400) { return 'invalid_request' } if (statusCode === 401) { return 'unauthorized' } if (statusCode === 403) { return 'forbidden' } if (statusCode === 404) { return 'not_found' } if (statusCode === 409) { return 'conflict' } if (statusCode === 410) { return 'gone' } if (statusCode === 503) { return 'dependency_unavailable' } return 'internal_error' } function normalizeResponseDateTime(value: unknown): unknown { if (value instanceof Date) { return formatResponseDateTime(value.toISOString()) } if (Array.isArray(value)) { return value.map((item) => normalizeResponseDateTime(item)) } if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([key, currentValue]) => [ key, normalizeResponseDateTime(currentValue), ]), ) } if (isIsoDateTimeString(value)) { return formatResponseDateTime(value as string) } return value } function isIsoDateTimeString(value: unknown): boolean { return ( typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(value.trim()) ) } function formatResponseDateTime(value: string): string { const date = new Date(value) if (Number.isNaN(date.getTime())) { return String(value || '') .replace('T', ' ') .replace('Z', '') } const parts = new Intl.DateTimeFormat('zh-CN', { timeZone: RESPONSE_TIME_ZONE, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }).formatToParts(date) const tokens = Object.fromEntries( parts.filter((item) => item.type !== 'literal').map((item) => [item.type, item.value]), ) return `${tokens.year}-${tokens.month}-${tokens.day} ${tokens.hour}:${tokens.minute}:${tokens.second}` } type RouteHandlerOptions = { successMessage?: string errorMessage?: string scope?: string } type RouteFileHandlerOptions = { errorMessage?: string scope?: string } export function createRouteHandler( action: (req: Request, res: Response) => unknown | Promise, { successMessage = 'ok', errorMessage, scope }: RouteHandlerOptions = {}, ) { return async (req: Request, res: Response): Promise => { try { const data = await action(req, res) res.json(buildSuccessPayload(data, successMessage)) } catch (error) { sendRouteError(res, error, errorMessage, scope) } } } export function createRouteFileHandler( action: (req: Request, res: Response) => string | Promise, { errorMessage, scope }: RouteFileHandlerOptions = {}, ) { return async (req: Request, res: Response): Promise => { try { const filePath = await action(req, res) res.sendFile(filePath) } catch (error) { sendRouteError(res, error, errorMessage, scope) } } }