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", "$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"entryFile": "main.cjs",
"compilerOptions": { "compilerOptions": {
"webpack": true,
"webpackConfigPath": "webpack.config.cjs",
"deleteOutDir": true "deleteOutDir": true
} }
} }

View File

@ -12,7 +12,7 @@ import { join } from 'path'
return { return {
pinoHttp: { pinoHttp: {
level: isProd ? 'info' : 'debug', timestamp: () => `,"time":"${new Date().toISOString()}"`,
serializers: { serializers: {
req: (req: { req: (req: {
id: string id: string
@ -31,6 +31,9 @@ import { join } from 'path'
ip: req.headers['x-forwarded-for'] ?? req.socket?.remoteAddress, ip: req.headers['x-forwarded-for'] ?? req.socket?.remoteAddress,
userAgent: req.headers['user-agent'], userAgent: req.headers['user-agent'],
}), }),
res: (res: { statusCode: number }) => ({
statusCode: res.statusCode,
}),
}, },
transport: isProd transport: isProd
? { ? {
@ -42,7 +45,7 @@ import { join } from 'path'
file: join('logs', 'app'), file: join('logs', 'app'),
frequency: 'daily', frequency: 'daily',
dateFormat: 'yyyy-MM-dd', dateFormat: 'yyyy-MM-dd',
extension: '.log', extension: '.json',
limit: { count: 14 }, limit: { count: 14 },
mkdir: true, mkdir: true,
}, },
@ -54,11 +57,19 @@ import { join } from 'path'
file: join('logs', 'error'), file: join('logs', 'error'),
frequency: 'daily', frequency: 'daily',
dateFormat: 'yyyy-MM-dd', dateFormat: 'yyyy-MM-dd',
extension: '.log', extension: '.json',
limit: { count: 30 }, limit: { count: 30 },
mkdir: true, mkdir: true,
}, },
}, },
{
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
ignore: 'pid,hostname',
},
}
], ],
} }
: { : {
@ -75,4 +86,4 @@ import { join } from 'path'
}), }),
], ],
}) })
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 { BadRequestException } from '@nestjs/common';
import type { ValidationError } from 'class-validator' import { type ValidationError } from 'class-validator';
export function validationExceptionFactory(errors: ValidationError[]): BadRequestException { const ErrorMessageMap: Record<
const forbidden = errors.filter((e) => e.constraints?.whitelistValidation) 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) { min: (prop, raw) => {
const fields = forbidden.map((e) => e.property).join(', ') const num = raw.match(/-?\d+/)?.[0] ?? '指定';
return new BadRequestException(`不允許的欄位:${fields}`) 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} 個項目`;
},
};
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);
}
});
} }
const messages = errors.flatMap((e) => Object.values(e.constraints ?? {})) if (error.children && error.children.length > 0) {
return new BadRequestException(messages.join('; ')) 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,
}),
],
};
};