261 lines
6.5 KiB
TypeScript
261 lines
6.5 KiB
TypeScript
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<unknown>,
|
|
{ successMessage = 'ok', errorMessage, scope }: RouteHandlerOptions = {},
|
|
) {
|
|
return async (req: Request, res: Response): Promise<void> => {
|
|
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<string>,
|
|
{ errorMessage, scope }: RouteFileHandlerOptions = {},
|
|
) {
|
|
return async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const filePath = await action(req, res)
|
|
res.sendFile(filePath)
|
|
} catch (error) {
|
|
sendRouteError(res, error, errorMessage, scope)
|
|
}
|
|
}
|
|
}
|