feat:改進webpack打包機制、logger可讀性、欄位驗證回傳訊息可讀性

This commit is contained in:
cherites
2026-07-15 14:53:55 +08:00
parent 067755c64b
commit 9e830b22fd
6 changed files with 188 additions and 44 deletions

View File

@ -2,7 +2,10 @@
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"entryFile": "main.cjs",
"compilerOptions": {
"webpack": true,
"webpackConfigPath": "webpack.config.cjs",
"deleteOutDir": true
}
}

View File

@ -12,7 +12,7 @@ import { join } from 'path'
return {
pinoHttp: {
level: isProd ? 'info' : 'debug',
timestamp: () => `,"time":"${new Date().toISOString()}"`,
serializers: {
req: (req: {
id: string
@ -31,48 +31,59 @@ import { join } from 'path'
ip: req.headers['x-forwarded-for'] ?? req.socket?.remoteAddress,
userAgent: req.headers['user-agent'],
}),
res: (res: { statusCode: number }) => ({
statusCode: res.statusCode,
}),
},
transport: isProd
? {
targets: [
{
target: 'pino-roll',
level: 'info',
options: {
file: join('logs', 'app'),
frequency: 'daily',
dateFormat: 'yyyy-MM-dd',
extension: '.log',
limit: { count: 14 },
mkdir: true,
},
targets: [
{
target: 'pino-roll',
level: 'info',
options: {
file: join('logs', 'app'),
frequency: 'daily',
dateFormat: 'yyyy-MM-dd',
extension: '.json',
limit: { count: 14 },
mkdir: true,
},
{
target: 'pino-roll',
level: 'error',
options: {
file: join('logs', 'error'),
frequency: 'daily',
dateFormat: 'yyyy-MM-dd',
extension: '.log',
limit: { count: 30 },
mkdir: true,
},
},
],
}
: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
ignore: 'pid,hostname',
},
{
target: 'pino-roll',
level: 'error',
options: {
file: join('logs', 'error'),
frequency: 'daily',
dateFormat: 'yyyy-MM-dd',
extension: '.json',
limit: { count: 30 },
mkdir: true,
},
},
{
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
ignore: 'pid,hostname',
},
}
],
}
: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
ignore: 'pid,hostname',
},
},
},
}
},
}),
],
})
export class LoggerModule {}
export class LoggerModule { }

View File

@ -0,0 +1,9 @@
import { ParseUUIDPipe, BadRequestException } from '@nestjs/common';
export class UUIDValidationPipe extends ParseUUIDPipe {
constructor() {
super({
exceptionFactory: () => new BadRequestException('傳入參數非 UUID'),
});
}
}

View File

@ -1,14 +1,104 @@
import { BadRequestException } from '@nestjs/common'
import type { ValidationError } from 'class-validator'
import { BadRequestException } from '@nestjs/common';
import { type ValidationError } from 'class-validator';
export function validationExceptionFactory(errors: ValidationError[]): BadRequestException {
const forbidden = errors.filter((e) => e.constraints?.whitelistValidation)
const ErrorMessageMap: Record<
string,
(property: string, rawMessage: string) => string
> = {
isNotEmpty: (prop) => `欄位 [${prop}] 不能為空`,
isInt: (prop) => `欄位 [${prop}] 必須是整數`,
isUuid: (prop) => `欄位 [${prop}] 必須是有效的UUID`,
isObject: (prop) => `欄位 [${prop}] 必須是物件`,
isNumber: (prop) => `欄位 [${prop}] 必須是數字`,
isString: (prop) => `欄位 [${prop}] 必須是字串`,
isEmail: (prop) => `欄位 [${prop}] 的格式不正確`,
isEnum: (prop) => `欄位 [${prop}] 的值不在允許範圍內`,
isBoolean: (prop) => `欄位 [${prop}] 必須是布林值`,
isDate: (prop) => `欄位 [${prop}] 必須是有效的日期`,
isArray: (prop) => `欄位 [${prop}] 必須是有陣列`,
isUrl: (prop) => `欄位 [${prop}] 必須是有效的網址`,
arrayNotEmpty: (prop) => `欄位 [${prop}] 陣列不能為空`,
whitelistValidation: (prop) => `欄位 [${prop}] 不在允許的名單中 (多餘欄位)`,
if (forbidden.length > 0) {
const fields = forbidden.map((e) => e.property).join(', ')
return new BadRequestException(`不允許的欄位:${fields}`)
}
min: (prop, raw) => {
const num = raw.match(/-?\d+/)?.[0] ?? '指定';
return `欄位 [${prop}] 最小不能低於 ${num}`;
},
max: (prop, raw) => {
const num = raw.match(/-?\d+/)?.[0] ?? '指定';
return `欄位 [${prop}] 最大不能超過 ${num}`;
},
minLength: (prop, raw) => {
const num = raw.match(/\d+/)?.[0] ?? '指定';
return `欄位 [${prop}] 長度至少需 ${num} 個字`;
},
maxLength: (prop, raw) => {
const num = raw.match(/\d+/)?.[0] ?? '指定';
return `欄位 [${prop}] 長度不能超過 ${num} 個字`;
},
isLength: (prop, raw) => {
const nums = raw.match(/\d+/g);
return nums && nums.length >= 2
? `欄位 [${prop}] 長度需介於 ${nums[0]}${nums[1]} 個字之間`
: `欄位 [${prop}] 長度不符規範`;
},
arrayMinSize: (prop, raw) => {
const num = raw.match(/\d+/)?.[0] ?? '指定';
return `欄位 [${prop}] 至少需包含 ${num} 個項目`;
},
arrayMaxSize: (prop, raw) => {
const num = raw.match(/\d+/)?.[0] ?? '指定';
return `欄位 [${prop}] 最多只能包含 ${num} 個項目`;
},
};
const messages = errors.flatMap((e) => Object.values(e.constraints ?? {}))
return new BadRequestException(messages.join('; '))
export function validationExceptionFactory(
errors: ValidationError[],
): BadRequestException {
const formatErrors = (
errorList: ValidationError[],
parentProp = '',
): string[] => {
const messages: string[] = [];
for (const error of errorList) {
const fullPropPath = parentProp
? `${parentProp}.${error.property}`
: error.property;
if (error.constraints) {
Object.keys(error.constraints).forEach((key) => {
const translator = ErrorMessageMap[key];
const rawMessage = error.constraints![key];
const isDefaultMessage =
rawMessage.includes(error.property) ||
rawMessage.includes('should not') ||
rawMessage.includes('must be');
if (translator && isDefaultMessage) {
messages.push(translator(fullPropPath, rawMessage));
} else {
messages.push(rawMessage);
}
});
}
if (error.children && error.children.length > 0) {
messages.push(...formatErrors(error.children, fullPropPath));
}
}
return messages;
};
const allMessages = formatErrors(errors);
const uniqueMessages = Array.from(new Set(allMessages));
return new BadRequestException({
statusCode: 400,
message: uniqueMessages.length > 0 ? uniqueMessages[0] : '資料驗證失敗',
errors: uniqueMessages,
error: 'Bad Request',
});
}

View File

31
webpack.config.cjs Normal file
View File

@ -0,0 +1,31 @@
const webpack = require('webpack');
const nodeExternals = require('webpack-node-externals');
module.exports = function (options) {
return {
...options,
entry: ['./src/main.ts'],
output: {
...options.output,
filename: 'main.cjs',
},
target: 'node',
externals: [nodeExternals()],
optimization: {
splitChunks: false,
runtimeChunk: false,
},
resolve: {
...options.resolve,
extensionAlias: {
'.js': ['.ts', '.js'],
},
},
plugins: [
...options.plugins,
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
],
};
};