diff --git a/nest-cli.json b/nest-cli.json index f9aa683..40ff178 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -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 } } diff --git a/src/core/logger/logger.module.ts b/src/core/logger/logger.module.ts index 431c9e1..ccfad1c 100644 --- a/src/core/logger/logger.module.ts +++ b/src/core/logger/logger.module.ts @@ -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 { } diff --git a/src/core/pipe/uuid-validation.pipe.ts b/src/core/pipe/uuid-validation.pipe.ts new file mode 100644 index 0000000..959f82c --- /dev/null +++ b/src/core/pipe/uuid-validation.pipe.ts @@ -0,0 +1,9 @@ +import { ParseUUIDPipe, BadRequestException } from '@nestjs/common'; + +export class UUIDValidationPipe extends ParseUUIDPipe { + constructor() { + super({ + exceptionFactory: () => new BadRequestException('傳入參數非 UUID'), + }); + } +} diff --git a/src/core/pipe/validation-exception.factory.ts b/src/core/pipe/validation-exception.factory.ts index f793d0e..035a6ed 100644 --- a/src/core/pipe/validation-exception.factory.ts +++ b/src/core/pipe/validation-exception.factory.ts @@ -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', + }); } diff --git a/src/module/.gitkeep b/src/module/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/webpack.config.cjs b/webpack.config.cjs new file mode 100644 index 0000000..7022a61 --- /dev/null +++ b/webpack.config.cjs @@ -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, + }), + ], + }; +};