chore:init project template

This commit is contained in:
cherites
2026-04-14 11:49:30 +08:00
commit 067755c64b
27 changed files with 9042 additions and 0 deletions

21
.env.example Normal file
View File

@ -0,0 +1,21 @@
NODE_ENV=development
PORT=3000
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=nest_db
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USER=
REDIS_PASSWORD=
JWT_SECRET=change-me
JWT_EXPIRES_IN=7d
THROTTLE_TTL=60000
THROTTLE_LIMIT=60

61
.gitignore vendored Normal file
View File

@ -0,0 +1,61 @@
# compiled output
/dist
/node_modules
/build
# logs
/logs
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.claude
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

256
CLAUDE.md Normal file
View File

@ -0,0 +1,256 @@
# NestJS Template — 開發規範
## 技術棧
| 分類 | 套件 |
|---|---|
| Runtime | Node.jsESM`"type": "module"` |
| 框架 | NestJS 11 |
| 語言 | TypeScript 5`NodeNext` 模組解析) |
| 套件管理 | pnpm |
| 資料庫 | PostgreSQL`@nestjs/typeorm` + `typeorm` + `pg` |
| 快取/佇列 | Redis`ioredis` |
| 設定管理 | `@nestjs/config``.env` 載入) |
| 驗證 | `class-validator` + `class-transformer` |
| 日誌 | `nestjs-pino` + `pino-roll` |
| API 文件 | `@nestjs/swagger`(僅非 production路徑 `/api/docs` |
| 資安 | `helmet` + NestJS 內建 CORS |
| 流量控制 | `@nestjs/throttler` |
| 認證 | `@nestjs/jwt` |
## 目錄結構
```
src/
├── core/ # NestJS 生命週期類別 + 基礎設施模組
│ ├── db/
│ │ └── db.module.ts
│ ├── redis/
│ │ └── redis.module.ts
│ ├── logger/
│ │ └── logger.module.ts
│ ├── throttler/
│ │ └── throttler.module.ts
│ ├── filter/
│ │ └── http-exception.filter.ts
│ ├── interceptor/
│ │ └── transform.interceptor.ts
│ ├── pipe/
│ │ └── validation-exception.factory.ts
│ ├── guard/
│ │ └── jwt.guard.ts
│ └── <類型>/ # middleware …
├── common/ # 共用但不掛生命週期的內容
│ ├── token/
│ │ └── tokens.ts # 注入 token 常數REDIS_CLIENT 等)
│ ├── type/
│ │ ├── response.ts # ApiResponse<T> 統一回傳型別
│ │ └── jwt-payload.ts # JWT payload 型別
│ ├── decorator/
│ │ └── public.decorator.ts # @Public() 跳過 JWT 驗證
│ └── <類型>/ # util、constant …
├── module/ # 功能模組(每個領域一個子目錄)
│ └── <模組名>/
│ ├── dtos/
│ ├── entities/
│ ├── <模組名>.controller.ts
│ ├── <模組名>.service.ts
│ └── <模組名>.module.ts
├── app.module.ts
└── main.ts
```
### `core/` — 生命週期類別與基礎設施模組
**任何檔案都必須放在對應的子目錄中,不可直接置於 `core/` 根層。**
| 子目錄 | 內容 |
|---|---|
| `filter/` | 例外處理,`@Catch()` |
| `interceptor/` | 請求/回應攔截,`NestInterceptor` |
| `guard/` | 路由守衛,`CanActivate` |
| `pipe/` | 輸入轉換與驗證相關,`PipeTransform` 及 factory |
| `middleware/` | Express 中介層,`NestMiddleware` |
| `db/` | TypeORM / PostgreSQL 設定 |
| `redis/` | Redis 設定 |
| `logger/` | Pino logger 設定 |
| `throttler/` | Rate limiting 設定 |
### `common/` — 共用工具
不掛生命週期、跨模組共用的內容。**任何檔案都必須放在對應的子目錄中,不可直接置於 `common/` 根層。**
| 子目錄 | 內容 |
|---|---|
| `token/` | 注入 token 常數(`REDIS_CLIENT` 等) |
| `type/` | 共用 TypeScript 型別interfaceenum |
| `decorator/` | 自訂裝飾器 |
| `util/` | 純函式工具 |
| `constant/` | 一般常數 |
### `module/` — 功能模組
每個業務領域一個子目錄,結構依複雜度選擇:
簡單模組(單一 controller / service
```
module/<模組名>/
├── dtos/
├── entities/
├── <模組名>.controller.ts
├── <模組名>.service.ts
└── <模組名>.module.ts
```
複雜模組(多個 controller / service
```
module/<模組名>/
├── dtos/
├── entities/
├── controllers/
│ ├── <子功能>.controller.ts
│ └── ...
├── services/
│ ├── <子功能>.service.ts
│ └── ...
└── <模組名>.module.ts
```
> 單一檔案與目錄切分可混用service 需要拆分但 controller 不需要時controller 維持單檔即可。
## 全域基礎設施
以下透過 `APP_*` token 在 `AppModule` 全域註冊,所有模組自動套用。執行順序如下:
1. `ThrottlerGuard` — rate limiting
2. `JwtAuthGuard` — JWT 驗證
3. `ValidationPipe` — 請求驗證
4. `TransformInterceptor` — 回應包裝
5. `HttpExceptionFilter` — 例外捕捉
### 統一回傳格式(`ApiResponse<T>`
所有端點一律回傳 `{ code, message, data }` 結構:
```typescript
// 成功
{ "code": 200, "message": "ok", "data": { ... } }
// 失敗
{ "code": 404, "message": "...", "data": null }
```
- `TransformInterceptor``core/interceptor/`)— 包裝成功回應
- `HttpExceptionFilter``core/filter/`)— 捕捉所有例外並統一格式
### ValidationPipe
| 選項 | 值 | 說明 |
|---|---|---|
| `whitelist` | `true` | 自動剔除 DTO 未宣告的屬性 |
| `forbidNonWhitelisted` | `true` | 傳入未宣告屬性時回傳 400 |
| `transform` | `true` | 自動將 payload 轉為 DTO 實例 |
| `exceptionFactory` | `validationExceptionFactory` | forbidden 欄位顯示 `不允許的欄位:<field>` |
### JWT 認證
- 全域啟用 `JwtAuthGuard`,預設所有路由需要有效 Bearer token
- 公開路由加上 `@Public()` decorator 跳過驗證
- Payload 型別定義於 `common/type/jwt-payload.ts``sub` 為 user ID
- 需要 Swagger 測試時在端點加上 `@ApiBearerAuth()`
```typescript
@Public() // 跳過 JWT
@SkipThrottle() // 跳過 rate limiting@nestjs/throttler 提供)
```
### Rate Limiting
透過 `THROTTLE_TTL`(毫秒)與 `THROTTLE_LIMIT`(次數)設定,預設 60 秒內最多 60 次請求。
## 日誌策略
使用 `nestjs-pino`,以 `NODE_ENV` 切換行為:
| 環境 | 輸出 |
|---|---|
| 非 production | `pino-pretty`colorize輸出至 stdout |
| production | `pino-roll` 寫入檔案 |
**production 檔案規則:**
| 類型 | 路徑 | 檔名格式 | 保留天數 |
|---|---|---|---|
| app log | `logs/app/` | `app.yyyy-MM-dd.log` | 14 天 |
| error log | `logs/error/` | `error.yyyy-MM-dd.log` | 30 天 |
**每筆 request 自動記錄:** `method``url``query``params``ip`(含 X-Forwarded-For`userAgent``statusCode``responseTime`
## 資安
### Helmet
全域套用 `helmet()` Express middleware設定標準安全 HTTP headersCSP、HSTS、X-Frame-Options 等)。
### CORS
透過 `CORS_ORIGINS` 環境變數控制允許的來源,多個來源以逗號分隔。預設空陣列(全封)。
| 設定 | 值 |
|---|---|
| `methods` | GET、POST、PUT、PATCH、DELETE |
| `credentials` | `true` |
## Swagger
- 僅在非 production 環境啟用,路徑為 `/api/docs`
- 預設已加入 `addBearerAuth()`,需要保護的端點加上 `@ApiBearerAuth()`
- DTO 屬性使用 `@ApiProperty()` 補充文件
## 環境變數
參考 `.env.example`
```
NODE_ENV=development
PORT=3000
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=nest_db
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USER= # 選填
REDIS_PASSWORD= # 選填
JWT_SECRET=change-me
JWT_EXPIRES_IN=7d
THROTTLE_TTL=60000 # 毫秒
THROTTLE_LIMIT=60 # 次數
```
## 編碼規範
- 所有相對路徑 import 必須加 `.js` 副檔名NodeNext 規定)
- 禁止 `any`,型別須明確宣告
- 無註解 — 程式碼透過命名與結構自我表達
- 正式程式碼不留 `console.log`(使用注入的 `Logger`
- 類別屬性在適用情況下使用 `readonly`
## 常用指令
```bash
pnpm start:dev # 開發模式watch
pnpm build # 編譯
pnpm start:prod # 執行編譯後的產物
pnpm test # 單元測試
pnpm test:cov # 測試覆蓋率
```

120
README.md Normal file
View File

@ -0,0 +1,120 @@
# NestJS Template
生產就緒的 NestJS 起始模板,內建常用基礎設施與規範,開新專案時直接 fork 使用。
## 技術棧
| 分類 | 套件 |
|---|---|
| 框架 | NestJS 11 |
| 語言 | TypeScript 5ESM + NodeNext |
| 資料庫 | PostgreSQL + TypeORM |
| 快取 | Redisioredis |
| 設定管理 | @nestjs/config |
| 驗證 | class-validator + class-transformer |
| 日誌 | nestjs-pino + pino-roll |
| 認證 | @nestjs/jwt |
| API 文件 | @nestjs/swagger |
| 流量控制 | @nestjs/throttler |
| 資安 | helmet + CORS |
## 內建功能
- **統一回傳格式** — 所有端點自動包裝為 `{ code, message, data }`
- **全域 JWT 驗證** — 預設所有路由需要 Bearer token公開路由加 `@Public()`
- **全域 Rate Limiting** — 透過環境變數設定 TTL 與上限次數
- **全域 ValidationPipe** — 自動過濾多餘欄位、型別轉換,自訂錯誤訊息
- **統一例外處理** — 所有例外回傳一致的錯誤格式
- **結構化日誌** — 開發環境 pretty printproduction 寫檔並自動輪替
- **Swagger UI** — 非 production 環境自動啟用,路徑 `/api/docs`
- **Graceful Shutdown** — 服務停止時正常關閉 DB / Redis 連線
## 快速開始
### 前置需求
- Node.js 22+
- pnpm
- PostgreSQL
- Redis
### 安裝
```bash
pnpm install
cp .env.example .env
```
編輯 `.env`填入資料庫、Redis、JWT 等設定值。
### 啟動
```bash
# 開發模式
pnpm start:dev
# 生產模式(需先 build
pnpm build
pnpm start:prod
```
## 環境變數
| 變數 | 說明 | 預設值 |
|---|---|---|
| `NODE_ENV` | 環境 | `development` |
| `PORT` | 監聽埠 | `3000` |
| `CORS_ORIGINS` | 允許的 CORS 來源,逗號分隔 | 空(全封) |
| `DB_HOST` | PostgreSQL 主機 | — |
| `DB_PORT` | PostgreSQL 埠 | `5432` |
| `DB_USER` | 使用者名稱 | — |
| `DB_PASSWORD` | 密碼 | — |
| `DB_NAME` | 資料庫名稱 | — |
| `REDIS_HOST` | Redis 主機 | — |
| `REDIS_PORT` | Redis 埠 | `6379` |
| `REDIS_USER` | Redis 使用者(選填) | — |
| `REDIS_PASSWORD` | Redis 密碼(選填) | — |
| `JWT_SECRET` | JWT 簽名金鑰 | — |
| `JWT_EXPIRES_IN` | Token 有效期 | `7d` |
| `THROTTLE_TTL` | Rate limit 時間窗口(毫秒) | — |
| `THROTTLE_LIMIT` | 時間窗口內最大請求數 | — |
## 目錄結構
```
src/
├── core/ # 生命週期類別 + 基礎設施模組
│ ├── db/ # TypeORM 設定
│ ├── redis/ # Redis 設定
│ ├── logger/ # Pino 日誌設定
│ ├── throttler/ # Rate limiting 設定
│ ├── filter/ # 全域例外 filter
│ ├── interceptor/ # 回應包裝 interceptor
│ ├── guard/ # JWT guard
│ └── pipe/ # Validation exception factory
├── common/ # 跨模組共用(不掛生命週期)
│ ├── decorator/ # @Public() 等自訂裝飾器
│ ├── token/ # 注入 token 常數
│ └── type/ # 共用型別ApiResponse、JwtPayload
└── module/ # 業務功能模組(每個領域一個子目錄)
```
新業務模組放在 `src/module/<模組名>/`,結構參考 `CLAUDE.md`
## 常用指令
```bash
pnpm start:dev # 開發模式watch
pnpm start:debug # 除錯模式
pnpm build # 編譯
pnpm typecheck # 型別檢查
pnpm lint # Lint 檢查
pnpm format # 格式化
pnpm test # 單元測試
pnpm test:cov # 測試覆蓋率
pnpm test:e2e # E2E 測試
```
## 開發規範
詳見 [CLAUDE.md](./CLAUDE.md)。

34
eslint.config.mjs Normal file
View File

@ -0,0 +1,34 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

101
package.json Normal file
View File

@ -0,0 +1,101 @@
{
"name": "nest-template",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"build": "nest build",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"lint": "eslint \"{src,test}/**/*.ts\"",
"format": "prettier --write \"{src,test}/**/*.ts\"",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main.js",
"_jest": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests",
"test": "pnpm _jest",
"test:watch": "pnpm _jest --watch",
"test:cov": "pnpm _jest --coverage",
"test:e2e": "pnpm _jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.7",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"helmet": "^8.1.0",
"ioredis": "^5.10.1",
"nestjs-pino": "^4.6.1",
"pg": "^8.20.0",
"pino-roll": "^4.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/pg": "^8.20.0",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^29.7.0",
"pino-pretty": "^13.1.3",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"extensionsToTreatAsEsm": [
".ts"
],
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": [
"ts-jest",
{
"useESM": true,
"diagnostics": { "ignoreCodes": [151002] }
}
]
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

8063
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

11
src/app.controller.ts Normal file
View File

@ -0,0 +1,11 @@
import { Controller, Get } from '@nestjs/common'
import { Public } from './common/decorator/public.decorator.js'
@Controller()
export class AppController {
@Public()
@Get('health')
health(): { status: string } {
return { status: 'ok' }
}
}

49
src/app.module.ts Normal file
View File

@ -0,0 +1,49 @@
import { Module, ValidationPipe } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'
import { JwtModule } from '@nestjs/jwt'
import { ThrottlerGuard } from '@nestjs/throttler'
import { AppController } from './app.controller.js'
import { DbModule } from './core/db/db.module.js'
import { HttpExceptionFilter } from './core/filter/http-exception.filter.js'
import { JwtAuthGuard } from './core/guard/jwt.guard.js'
import { TransformInterceptor } from './core/interceptor/transform.interceptor.js'
import { LoggerModule } from './core/logger/logger.module.js'
import { validationExceptionFactory } from './core/pipe/validation-exception.factory.js'
import { RedisModule } from './core/redis/redis.module.js'
import { ThrottlerModule } from './core/throttler/throttler.module.js'
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
LoggerModule,
DbModule,
RedisModule,
ThrottlerModule,
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: config.get<string>('JWT_EXPIRES_IN', '7d') as `${number}${'s' | 'm' | 'h' | 'd' | 'w' | 'y'}` },
}),
}),
],
controllers: [AppController],
providers: [
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
exceptionFactory: validationExceptionFactory,
}),
},
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
})
export class AppModule { }

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common'
export const IS_PUBLIC_KEY = 'isPublic'
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)

View File

@ -0,0 +1 @@
export const REDIS_CLIENT = 'REDIS_CLIENT';

View File

@ -0,0 +1,5 @@
export interface JwtPayload {
sub: number
iat?: number
exp?: number
}

View File

@ -0,0 +1,5 @@
export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T | null;
}

22
src/core/db/db.module.ts Normal file
View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.getOrThrow<string>('DB_HOST'),
port: config.getOrThrow<number>('DB_PORT'),
username: config.getOrThrow<string>('DB_USER'),
password: config.getOrThrow<string>('DB_PASSWORD'),
database: config.getOrThrow<string>('DB_NAME'),
autoLoadEntities: true,
synchronize: config.get<string>('NODE_ENV') !== 'production',
}),
}),
],
})
export class DbModule {}

View File

@ -0,0 +1,25 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { ApiResponse } from '../../common/type/response.js';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const isHttp = exception instanceof HttpException;
const status = isHttp ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (isHttp) {
const body = exception.getResponse();
message = typeof body === 'string' ? body : ((body as { message?: string }).message ?? exception.message);
}
const body: ApiResponse<null> = { code: status, message, data: null };
response.status(status).json(body);
}
}

View File

@ -0,0 +1,41 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { JwtService } from '@nestjs/jwt'
import type { Request } from 'express'
import { IS_PUBLIC_KEY } from '../../common/decorator/public.decorator.js'
import type { JwtPayload } from '../../common/type/jwt-payload.js'
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly reflector: Reflector,
) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
])
if (isPublic) return true
const request = context.switchToHttp().getRequest<Request & { user: JwtPayload }>()
const token = this.extractToken(request)
if (!token) throw new UnauthorizedException()
try {
request.user = this.jwtService.verify<JwtPayload>(token)
} catch {
throw new UnauthorizedException()
}
return true
}
private extractToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? []
return type === 'Bearer' ? token : undefined
}
}

View File

@ -0,0 +1,17 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import type { ApiResponse } from '../../common/type/response.js';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(_context: ExecutionContext, next: CallHandler<T>): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
code: 200,
message: 'ok',
data: data ?? null,
})),
);
}
}

View File

@ -0,0 +1,78 @@
import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino'
import { join } from 'path'
@Module({
imports: [
PinoLoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const isProd = config.get<string>('NODE_ENV') === 'production'
return {
pinoHttp: {
level: isProd ? 'info' : 'debug',
serializers: {
req: (req: {
id: string
method: string
url: string
query: Record<string, unknown>
params: Record<string, unknown>
headers: Record<string, string>
socket: { remoteAddress?: string }
}) => ({
id: req.id,
method: req.method,
url: req.url,
query: req.query,
params: req.params,
ip: req.headers['x-forwarded-for'] ?? req.socket?.remoteAddress,
userAgent: req.headers['user-agent'],
}),
},
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,
},
},
{
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',
},
},
},
}
},
}),
],
})
export class LoggerModule {}

View File

@ -0,0 +1,14 @@
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)
if (forbidden.length > 0) {
const fields = forbidden.map((e) => e.property).join(', ')
return new BadRequestException(`不允許的欄位:${fields}`)
}
const messages = errors.flatMap((e) => Object.values(e.constraints ?? {}))
return new BadRequestException(messages.join('; '))
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Redis } from 'ioredis'
import { REDIS_CLIENT } from '../../common/token/tokens.js'
@Module({
providers: [
{
provide: REDIS_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new Redis({
host: config.getOrThrow<string>('REDIS_HOST'),
port: config.getOrThrow<number>('REDIS_PORT'),
username: config.get<string>('REDIS_USER'),
password: config.get<string>('REDIS_PASSWORD'),
}),
},
],
exports: [REDIS_CLIENT],
})
export class RedisModule { }

View File

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { ThrottlerModule as NestThrottlerModule } from '@nestjs/throttler'
@Module({
imports: [
NestThrottlerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
throttlers: [
{
ttl: config.getOrThrow<number>('THROTTLE_TTL'),
limit: config.getOrThrow<number>('THROTTLE_LIMIT'),
},
],
}),
}),
],
})
export class ThrottlerModule {}

34
src/main.ts Normal file
View File

@ -0,0 +1,34 @@
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
import { NestFactory } from '@nestjs/core'
import helmet from 'helmet'
import { Logger } from 'nestjs-pino'
import { AppModule } from './app.module.js'
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true })
app.useLogger(app.get(Logger))
app.enableShutdownHooks()
app.setGlobalPrefix('api')
app.use(helmet())
app.enableCors({
origin: process.env.CORS_ORIGINS?.split(',').map((o) => o.trim()) ?? [],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
credentials: true,
})
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('API')
.setDescription('API 文件')
.setVersion('1.0')
.addBearerAuth()
.build()
SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, config))
}
await app.listen(process.env.PORT ?? 3000)
}
bootstrap()

0
src/module/.gitkeep Normal file
View File

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"types": ["node","jest"],
"rootDir": "src",
"outDir": "./dist",
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}