├── .env
├── .env.[mode].template
├── .gitignore
├── .vscode
├── i18n-ally-custom-framework.yml
└── settings.json
├── Dockerfile
├── LICENSE
├── README.en_US.md
├── README.md
├── eslint.config.mjs
├── nest-cli.json
├── package.json
├── pnpm-lock.yaml
├── prisma
├── migrations
│ ├── 20250309044600_init
│ │ └── migration.sql
│ └── migration_lock.toml
├── schema
│ ├── api.prisma
│ ├── enum.prisma
│ ├── menu.prisma
│ ├── role.prisma
│ ├── schema.prisma
│ └── user.prisma
└── seed.ts
├── src
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── common
│ └── dto
│ │ ├── delete-many.dto.ts
│ │ ├── index.ts
│ │ └── page.dto.ts
├── configs
│ ├── database.config.ts
│ ├── index.ts
│ ├── jwt.config.ts
│ ├── nodemailer.config.ts
│ ├── redis.config.ts
│ └── throttler.config.ts
├── constants
│ └── permissions.ts
├── decorators
│ ├── cache.decorator.ts
│ ├── index.ts
│ ├── is-public.decorator.ts
│ ├── is-refresh.decorator.ts
│ ├── permissions.decorator.ts
│ └── user-info.decorator.ts
├── filters
│ ├── all-exception.filter.ts
│ ├── http-exception.filter.ts
│ └── index.ts
├── guards
│ ├── auth.guard.ts
│ └── index.ts
├── interceptors
│ ├── cache.interceptor.ts
│ ├── format-response.interceptor.ts
│ ├── index.ts
│ └── invoke-record.interceptor.ts
├── locales
│ ├── en-US
│ │ ├── common.json
│ │ └── validation.json
│ └── zh-CN
│ │ ├── common.json
│ │ └── validation.json
├── main.ts
├── modules
│ ├── api
│ │ ├── api.controller.ts
│ │ ├── api.module.ts
│ │ ├── api.service.ts
│ │ └── dto
│ │ │ ├── create-api.dto.ts
│ │ │ └── update-api.dto.ts
│ ├── auth
│ │ ├── auth.module.ts
│ │ └── strategy
│ │ │ ├── jwt.strategy.ts
│ │ │ └── local.strategy.ts
│ ├── cache
│ │ ├── cache.module.ts
│ │ └── cache.service.ts
│ ├── menu
│ │ ├── dto
│ │ │ ├── create-menu.dto.ts
│ │ │ └── update-menu.dto.ts
│ │ ├── menu.controller.ts
│ │ ├── menu.module.ts
│ │ └── menu.service.ts
│ ├── nodemailer
│ │ ├── nodemailer.module.ts
│ │ └── nodemailer.service.ts
│ ├── prisma
│ │ ├── extended-client.ts
│ │ ├── extensions
│ │ │ ├── exists.extension.ts
│ │ │ └── find-many-count.extension.ts
│ │ ├── prisma.module.ts
│ │ └── prisma.service.ts
│ ├── role
│ │ ├── dto
│ │ │ ├── create-role.dto.ts
│ │ │ ├── role-list.dto.ts
│ │ │ └── update-role.dto.ts
│ │ ├── role.controller.ts
│ │ ├── role.module.ts
│ │ └── role.service.ts
│ └── user
│ │ ├── dto
│ │ ├── create-user.dto.ts
│ │ ├── login-user.dto.ts
│ │ ├── update-user-password.dto.ts
│ │ ├── update-user.dto.ts
│ │ └── user-list.dto.ts
│ │ ├── user.controller.ts
│ │ ├── user.module.ts
│ │ └── user.service.ts
├── pipes
│ ├── index.ts
│ └── update-validation.pipe.ts
├── types
│ └── index.ts
└── utils
│ └── index.ts
├── test
├── app.e2e-spec.ts
└── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json
/.env:
--------------------------------------------------------------------------------
1 | # nestjs 服务配置
2 | NEST_SERVER_PORT=3000
3 |
4 | # jwt 配置
5 | JWT_SECRET=pure-admin-nestjs
6 | JWT_ACCESS_EXPIRES=1d # 访问令牌过期时间
7 | JWT_REFRESH_EXPIRES=7d # 刷新令牌过期时间
8 |
9 | # throttler 配置
10 | THROTTLER_TTL=60000 # 节流时间
11 | THROTTLER_LIMIT=100 # 节流次数
12 |
13 | # 日志配置
14 | NEST_LOG_DIR=logs # 日志目录
15 |
--------------------------------------------------------------------------------
/.env.[mode].template:
--------------------------------------------------------------------------------
1 | # db 配置,格式:postgresql://用户名:密码@主机:端口/数据库名?schema=public,密码中的 @ 符号需要转义为 %40
2 | DATABASE_URL=postgresql://postgres:123456@localhost:5432/pure-admin?schema=public
3 |
4 | # redis 配置,格式:redis://:密码@主机:端口/数据库号,密码中的 @ 符号需要转义为 %40
5 | REDIS_URL=redis://localhost:6379/0
6 | REDIS_TYPE=single
7 |
8 | # nodemailer 相关配置
9 | NODEMAILER_HOST=smtp.qq.com
10 | NODEMAILER_PORT=587
11 | NODEMAILER_USER=123@abc.com
12 | NODEMAILER_PASSWORD=123456
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Environment
2 | .env.local
3 | .env.*.local
4 | .env.development
5 | .env.test
6 | .env.production
7 |
8 | # trash
9 | /trashed
10 |
11 | # compiled output
12 | /dist
13 | /node_modules
14 |
15 | # Logs
16 | log
17 | logs
18 | *.log
19 | npm-debug.log*
20 | pnpm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 | lerna-debug.log*
24 |
25 | # OS
26 | .DS_Store
27 |
28 | # Tests
29 | /coverage
30 | /.nyc_output
31 |
32 | # IDEs and editors
33 | .idea
34 | .project
35 | .classpath
36 | .c9/
37 | *.launch
38 | .settings/
39 |
40 | # IDE - VSCode
41 | .vscode/*
42 | !.vscode/extensions.json
43 | !.vscode/settings.json
44 | !.vscode/i18n-ally-custom-framework.yml
45 |
46 | # cursor
47 | .cursorrules
48 |
--------------------------------------------------------------------------------
/.vscode/i18n-ally-custom-framework.yml:
--------------------------------------------------------------------------------
1 | # .vscode/i18n-ally-custom-framework.yml
2 |
3 | # An array of strings which contain Language Ids defined by VS Code
4 | # You can check available language ids here: https://code.visualstudio.com/docs/languages/identifiers
5 | languageIds:
6 | - javascript
7 | - typescript
8 |
9 | # An array of RegExes to find the key usage. **The key should be captured in the first match group**.
10 | # You should unescape RegEx strings in order to fit in the YAML file
11 | # To help with this, you can use https://www.freeformatter.com/json-escape.html
12 | usageMatchRegex:
13 | # 匹配 .t('your.i18n.keys')
14 | - "[^\\w\\d]i18n\\.t\\(['\"`]({key})['\"`]"
15 | # 匹配 translate.('your.i18n.keys')
16 | - "[^\\w\\d].translate\\(['\"`]({key})['\"`]"
17 |
18 | # A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys
19 | # and works like how the i18next framework identifies the namespace scope from the
20 | # useTranslation() hook.
21 | # You should unescape RegEx strings in order to fit in the YAML file
22 | # To help with this, you can use https://www.freeformatter.com/json-escape.html
23 | scopeRangeRegex: ""
24 |
25 | # An array of strings containing refactor templates.
26 | # The "$1" will be replaced by the keypath specified.
27 | # Optional: uncomment the following two lines to use
28 | refactorTemplates:
29 | - .t('$1')
30 | - .translate('$1')
31 |
32 | # If set to true, only enables this custom framework (will disable all built-in frameworks)
33 | monopoly: true
34 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "i18n-ally.sourceLanguage": "zh-CN",
3 | "i18n-ally.displayLanguage": "zh-CN",
4 | "i18n-ally.keystyle": "nested",
5 | "i18n-ally.localesPaths": [
6 | "src/locales"
7 | ],
8 | "i18n-ally.namespace": true,
9 | "i18n-ally.pathMatcher": "{locale}/{namespaces}.json",
10 | }
11 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # 基础阶段 - 配置共享设置
2 | FROM node:lts-alpine AS base
3 | ENV PNPM_HOME="/pnpm"
4 | ENV PATH="$PNPM_HOME:$PATH"
5 | RUN corepack enable
6 |
7 | # 安装阶段 - 安装依赖
8 | FROM base AS deps
9 | WORKDIR /app
10 | COPY package.json pnpm-lock.yaml* ./
11 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --no-frozen-lockfile
12 |
13 | # 构建阶段 - 构建应用
14 | FROM base AS build
15 | WORKDIR /app
16 | COPY --from=deps /app/node_modules ./node_modules
17 | COPY . .
18 | RUN pnpm run prisma:generate
19 | RUN pnpm run build
20 |
21 | # 生产依赖阶段 - 安装生产依赖
22 | FROM base AS prod-deps
23 | WORKDIR /app
24 | COPY package.json pnpm-lock.yaml* ./
25 | COPY prisma ./prisma
26 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install
27 | # 生成Prisma客户端
28 | RUN pnpm run prisma:generate
29 | # 删除开发依赖
30 | RUN pnpm prune --prod
31 |
32 | # 运行阶段 - 创建最终生产镜像
33 | FROM base AS runner
34 | WORKDIR /app
35 |
36 | # 复制生产依赖
37 | COPY --from=prod-deps /app/node_modules ./node_modules
38 |
39 | # 复制构建产物
40 | COPY --from=build /app/dist ./dist
41 |
42 | # 复制其他文件
43 | COPY package.json ./
44 | COPY prisma ./prisma
45 | COPY .env .env.production ./
46 |
47 | # 创建日志目录并设置权限
48 | RUN mkdir -p logs && chown -R node:node /app
49 |
50 | # 设置运行用户
51 | USER node
52 |
53 | # 暴露应用端口
54 | EXPOSE 3000
55 |
56 | # 设置环境变量
57 | ENV NODE_ENV=production
58 |
59 | # 启动应用
60 | CMD ["node", "dist/src/main"]
61 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 sunhaoxiang
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.en_US.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Pure Admin NestJS
4 |
5 |
English | 中文
6 |
7 |
8 |
9 | ---
10 |
11 | [](./LICENSE)
12 |
13 | ## Introduction
14 |
15 | `Pure Admin` is a concise, elegant, powerful and user experience focused background management system. The frontend supports both `React 19` / `Vue 3` dual versions, allowing you to flexibly choose your development technology stack, while the backend is developed using `NestJS 11`. Click [documentation](https://pure-admin-docs.sunhaoxiang.me) to learn more.
16 |
17 | **This project is the frontend `React 19` version implementation.**
18 |
19 | ## Features
20 |
21 | - Uses the latest technology stack (React 19, Vue 3, NestJS 11, Prisma 6, etc.).
22 |
23 | - Developed using TypeScript, supporting strict type checking to improve code maintainability.
24 |
25 | - Built-in rich business components, including layouts, charts, CRUD operations for lists, etc., improving development efficiency.
26 |
27 | - Beautiful UI design, ultimate user experience and attention to detail.
28 |
29 | - File-based routing system for the frontend.
30 |
31 | - Decorator-based backend caching system, simple and easy to use.
32 |
33 | - Built-in internationalization solutions for both frontend and backend, enabling multi-language support.
34 |
35 | - Frontend and backend integrated permission system.
36 |
37 | ## Versions
38 |
39 | - **Backend `NestJS` Version:**
40 |
41 | - [Documentation](https://pure-admin-docs.sunhaoxiang.me/pure-admin-nestjs/intro.html)
42 |
43 | - [Github Repository](https://github.com/sunhaoxiang/pure-admin-nestjs)
44 |
45 | - **Frontend `React` Version:**
46 |
47 | - [Documentation](https://pure-admin-docs.sunhaoxiang.me/pure-admin-react/intro.html)
48 |
49 | - [Preview](https://pure-admin-react.sunhaoxiang.me)
50 |
51 | - [Github Repository](https://github.com/sunhaoxiang/pure-admin-react)
52 |
53 | - **Frontend `Vue` Version:**
54 |
55 | - [Documentation](https://pure-admin-docs.sunhaoxiang.me/pure-admin-vue/intro.html)
56 |
57 | - [Preview](https://pure-admin-vue.sunhaoxiang.me)
58 |
59 | - [Github Repository](https://github.com/sunhaoxiang/pure-admin-vue)
60 |
61 | ## Technology Stack
62 |
63 | - **Pure Admin React**: React 19, Vite, Ant Design, @tanstack/react-query, Unocss, etc.
64 |
65 | - **Pure Admin Vue**: Vue 3, Vite, Ant Design Vue, @tanstack/vue-query, Unocss, etc.
66 |
67 | - **Pure Admin NestJS**: NestJS 11, Prisma 6, PostgreSQL, Redis, etc.
68 |
69 | ## Example Screenshots
70 |
71 | 
72 |
73 | 
74 |
75 | 
76 |
77 | 
78 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
Pure Admin NestJS
3 |
中文 | English
4 |
5 |
6 | ---
7 |
8 | [](./LICENSE)
9 |
10 | ## 简介
11 |
12 | `Pure Admin` 是一款简洁优雅、功能强大且专注于用户体验的后台管理系统。 前端同时支持 `React 19` / `Vue 3` 双版本,让您可灵活选择开发技术栈,后端使用 `NestJS 11` 开发。点击 [文档](https://pure-admin-docs.sunhaoxiang.me) 了解更多相关内容。
13 |
14 | **本项目为后端 `NestJS 11` 版本实现。**
15 |
16 | ## 特点
17 |
18 | - 使用最新技术栈(React 19、Vue 3、NestJS 11、Prisma 6 等)。
19 | - 使用 TypeScript 进行开发,支持严格的类型检查,提高代码的可维护性。
20 | - 内置丰富业务组件,包括布局、图表、列表增删改查等,提高开发效率。
21 | - 漂亮的 UI 设计、极致的用户体验和细节处理。
22 | - 前端基于文件的路由系统。
23 | - 基于装饰器的后端缓存系统,简单好用。
24 | - 前后端均内置国际化方案,实现多语言支持。
25 | - 前后端打通的权限系统。
26 |
27 | ## 版本
28 |
29 | - **后端 `NestJS` 版本:**
30 |
31 | - [文档地址](https://pure-admin-docs.sunhaoxiang.me/pure-admin-nestjs/intro.html)
32 | - [Github 仓库](https://github.com/sunhaoxiang/pure-admin-nestjs)
33 |
34 | - **前端 `React` 版本:**
35 |
36 | - [文档地址](https://pure-admin-docs.sunhaoxiang.me/pure-admin-react/intro.html)
37 | - [预览地址](https://pure-admin-react.sunhaoxiang.me)
38 | - [Github 仓库](https://github.com/sunhaoxiang/pure-admin-react)
39 |
40 | - **前端 `Vue` 版本:**
41 |
42 | - [文档地址](https://pure-admin-docs.sunhaoxiang.me/pure-admin-vue/intro.html)
43 | - [预览地址](https://pure-admin-vue.sunhaoxiang.me)
44 | - [Github 仓库](https://github.com/sunhaoxiang/pure-admin-vue)
45 |
46 | ## 技术栈
47 | - **Pure Admin React**:React 19、Vite、Ant Design、@tanstack/react-query、Unocss 等。
48 | - **Pure Admin Vue**:Vue 3、Vite、Ant Design Vue、@tanstack/vue-query、Unocss 等。
49 | - **Pure Admin NestJS**:NestJS 11、Prisma 6、PostgreSQL、Redis 等。
50 |
51 | ## 示例图片
52 |
53 | 
54 | 
55 | 
56 | 
57 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import antfu from '@antfu/eslint-config'
2 | import perfectionist from 'eslint-plugin-perfectionist'
3 |
4 | export default antfu(
5 | {
6 | typescript: true,
7 | stylistic: true,
8 | },
9 | {
10 | files: ['**/*.{ts,js}'],
11 | languageOptions: {
12 | ecmaVersion: 'latest', // 使用最新的 ECMAScript 版本
13 | },
14 | rules: {
15 | 'ts/consistent-type-imports': 'off', // 关闭类型导入一致性检查
16 | 'no-console': 'off', // 允许使用 console
17 | 'no-unused-vars': 'off', // 关闭未使用变量检查
18 | '@typescript-eslint/no-unused-vars': 'off', // 关闭 TS 未使用变量检查
19 | 'ts/no-use-before-define': 'off', // 允许在定义前使用变量
20 | 'ts/strict-boolean-expressions': 'off', // 关闭布尔表达式严格检查
21 | 'ts/no-unsafe-member-access': 'off', // 允许不安全的成员访问
22 | 'ts/no-unsafe-call': 'off', // 允许不安全的函数调用
23 | 'ts/no-unsafe-assignment': 'off', // 允许不安全的赋值
24 | 'ts/no-unsafe-return': 'off', // 允许不安全的返回值
25 | 'ts/no-unsafe-argument': 'off', // 允许不安全的参数传递
26 | 'ts/no-misused-promises': 'off', // 允许 Promise 的非标准使用
27 | 'ts/no-floating-promises': 'off', // 允许未处理的 Promise
28 | 'node/prefer-global/process': 'off', // 允许直接使用 process
29 | 'node/prefer-global/buffer': 'off', // 允许直接使用 buffer
30 | 'import/no-named-default': 'off', // 允许导入命名的默认导出
31 | 'jsdoc/check-param-names': 'off', // 关闭 JSDoc 参数名检查
32 | },
33 | },
34 | {
35 | name: 'perfectionist',
36 | rules: {
37 | 'import/order': 'off', // 关闭默认的导入排序规则
38 | 'sort-imports': 'off', // 关闭默认的导入排序
39 | 'perfectionist/sort-imports': [
40 | 'error',
41 | {
42 | type: 'natural', // 使用自然排序
43 | order: 'asc', // 升序排序
44 | ignoreCase: true, // 忽略大小写
45 | specialCharacters: 'keep', // 保留特殊字符的原始位置
46 | internalPattern: ['^@/.+'], // 将 @/ 开头的导入视为内部导入
47 | partitionByComment: false, // 不使用注释分隔导入组
48 | partitionByNewLine: false, // 不使用空行分隔导入组
49 | newlinesBetween: 'always', // 导入组之间始终添加空行
50 | maxLineLength: undefined, // 不限制导入语句长度
51 | groups: [
52 | 'type', // 类型导入
53 | ['builtin', 'external'], // 内置模块和外部模块
54 | 'internal-type', // 内部类型导入
55 | 'internal', // 内部模块导入
56 | ['parent-type', 'sibling-type', 'index-type'], // 父级、同级和索引类型导入
57 | ['parent', 'sibling', 'index'], // 父级、同级和索引模块导入
58 | 'object', // 对象导入
59 | 'unknown', // 未知类型导入
60 | ],
61 | },
62 | ],
63 | 'perfectionist/sort-exports':
64 | perfectionist.configs['recommended-natural'].rules['perfectionist/sort-exports'], // 使用推荐的自然排序规则排序导出
65 | 'perfectionist/sort-named-imports':
66 | perfectionist.configs['recommended-natural'].rules['perfectionist/sort-named-imports'], // 使用推荐的自然排序规则排序命名导入
67 | 'perfectionist/sort-named-exports':
68 | perfectionist.configs['recommended-natural'].rules['perfectionist/sort-named-exports'], // 使用推荐的自然排序规则排序命名导出
69 | },
70 | },
71 | )
72 |
--------------------------------------------------------------------------------
/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "generateOptions": {
5 | "spec": false
6 | },
7 | "sourceRoot": "src",
8 | "compilerOptions": {
9 | "deleteOutDir": true,
10 | "watchAssets": true,
11 | "assets": [
12 | { "include": "locales/**/*", "watchAssets": true, "outDir": "dist/src/" }
13 | ]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pure-admin-nestjs",
3 | "version": "1.1.0",
4 | "private": true,
5 | "description": "A concise, elegant, powerful and user experience focused background management system",
6 | "author": "sunhaoxiang",
7 | "license": "MIT",
8 | "scripts": {
9 | "dev": "cross-env NODE_ENV=development nest start --watch",
10 | "build": "nest build",
11 | "prisma:generate": "prisma generate",
12 | "prisma:migrate:dev": "dotenv -e .env.development -- prisma migrate dev",
13 | "prisma:reset:dev": "dotenv -e .env.development -- prisma migrate reset",
14 | "prisma:seed:dev": "dotenv -e .env.development -- prisma db seed",
15 | "prisma:push:dev": "dotenv -e .env.development -- prisma db push",
16 | "prisma:pull:dev": "dotenv -e .env.development -- prisma db pull",
17 | "start:debug": "nest start --debug --watch",
18 | "eslint": "eslint .",
19 | "eslint:fix": "eslint . --fix",
20 | "eslint:inspect": "pnpm dlx @eslint/config-inspector@latest",
21 | "test": "jest",
22 | "test:watch": "jest --watch",
23 | "test:cov": "jest --coverage",
24 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
25 | "test:e2e": "jest --config ./test/jest-e2e.json"
26 | },
27 | "dependencies": {
28 | "@fastify/helmet": "^13.0.1",
29 | "@nestjs-modules/ioredis": "^2.0.2",
30 | "@nestjs/axios": "^4.0.0",
31 | "@nestjs/common": "^11.1.0",
32 | "@nestjs/config": "^4.0.2",
33 | "@nestjs/core": "^11.1.0",
34 | "@nestjs/jwt": "^11.0.0",
35 | "@nestjs/mapped-types": "^2.1.0",
36 | "@nestjs/passport": "^11.0.5",
37 | "@nestjs/platform-fastify": "^11.1.0",
38 | "@nestjs/schedule": "^6.0.0",
39 | "@nestjs/swagger": "^11.2.0",
40 | "@nestjs/throttler": "^6.4.0",
41 | "@prisma/client": "^6.7.0",
42 | "axios": "^1.9.0",
43 | "class-transformer": "^0.5.1",
44 | "class-validator": "^0.14.2",
45 | "csv-parser": "^3.2.0",
46 | "dayjs": "^1.11.13",
47 | "exceljs": "^4.4.0",
48 | "ioredis": "^5.6.1",
49 | "jsonwebtoken": "^9.0.2",
50 | "nest-winston": "^1.10.2",
51 | "nestjs-i18n": "^10.5.1",
52 | "nodemailer": "^7.0.3",
53 | "passport": "^0.7.0",
54 | "passport-jwt": "^4.0.1",
55 | "passport-local": "^1.0.0",
56 | "reflect-metadata": "^0.2.2",
57 | "rxjs": "^7.8.2",
58 | "split2": "^4.2.0",
59 | "winston": "^3.17.0",
60 | "winston-daily-rotate-file": "^5.0.0"
61 | },
62 | "devDependencies": {
63 | "@antfu/eslint-config": "^4.13.0",
64 | "@nestjs/cli": "^11.0.7",
65 | "@nestjs/schematics": "^11.0.5",
66 | "@nestjs/testing": "^11.1.0",
67 | "@types/jest": "^29.5.14",
68 | "@types/node": "^22.15.16",
69 | "@types/nodemailer": "^6.4.17",
70 | "@types/passport-jwt": "^4.0.1",
71 | "@types/passport-local": "^1.0.38",
72 | "@types/split2": "^4.2.3",
73 | "@types/supertest": "^6.0.3",
74 | "cross-env": "^7.0.3",
75 | "dotenv-cli": "^8.0.0",
76 | "eslint": "^9.26.0",
77 | "eslint-plugin-jest": "^28.11.0",
78 | "eslint-plugin-perfectionist": "^4.12.3",
79 | "fastify": "^5.3.2",
80 | "jest": "^29.7.0",
81 | "prisma": "^6.7.0",
82 | "source-map-support": "^0.5.21",
83 | "supertest": "^7.1.0",
84 | "ts-jest": "^29.3.2",
85 | "ts-loader": "^9.5.2",
86 | "ts-node": "^10.9.2",
87 | "tsconfig-paths": "^4.2.0",
88 | "typescript": "^5.8.3"
89 | },
90 | "prisma": {
91 | "schema": "./prisma/schema/",
92 | "seed": "ts-node prisma/seed.ts"
93 | },
94 | "jest": {
95 | "moduleFileExtensions": [
96 | "js",
97 | "json",
98 | "ts"
99 | ],
100 | "rootDir": "src",
101 | "testRegex": ".*\\.spec\\.ts$",
102 | "transform": {
103 | "^.+\\.(t|j)s$": "ts-jest"
104 | },
105 | "collectCoverageFrom": [
106 | "**/*.(t|j)s"
107 | ],
108 | "coverageDirectory": "../coverage",
109 | "testEnvironment": "node"
110 | },
111 | "pnpm": {
112 | "onlyBuiltDependencies": [
113 | "@nestjs/core",
114 | "@prisma/client",
115 | "@prisma/engines",
116 | "esbuild",
117 | "prisma"
118 | ]
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/prisma/migrations/20250309044600_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "MenuType" AS ENUM ('DIRECTORY', 'MENU', 'FEATURE');
3 |
4 | -- CreateEnum
5 | CREATE TYPE "ApiType" AS ENUM ('DIRECTORY', 'API');
6 |
7 | -- CreateEnum
8 | CREATE TYPE "ApiMethod" AS ENUM ('GET', 'POST', 'PUT', 'PATCH', 'DELETE');
9 |
10 | -- CreateTable
11 | CREATE TABLE "Api" (
12 | "id" SERIAL NOT NULL,
13 | "parentId" INTEGER,
14 | "type" "ApiType" NOT NULL,
15 | "title" VARCHAR(50) NOT NULL,
16 | "code" VARCHAR(50),
17 | "method" "ApiMethod",
18 | "path" VARCHAR(100),
19 | "description" VARCHAR(100),
20 | "sort" INTEGER NOT NULL DEFAULT 0,
21 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
22 | "updatedAt" TIMESTAMP(3) NOT NULL,
23 |
24 | CONSTRAINT "Api_pkey" PRIMARY KEY ("id")
25 | );
26 |
27 | -- CreateTable
28 | CREATE TABLE "Menu" (
29 | "id" SERIAL NOT NULL,
30 | "parentId" INTEGER,
31 | "type" "MenuType" NOT NULL,
32 | "title" VARCHAR(50) NOT NULL,
33 | "icon" VARCHAR(50),
34 | "code" VARCHAR(50),
35 | "path" VARCHAR(100),
36 | "description" VARCHAR(100),
37 | "i18nKey" VARCHAR(50),
38 | "sort" INTEGER NOT NULL DEFAULT 0,
39 | "isShow" BOOLEAN DEFAULT true,
40 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
41 | "updatedAt" TIMESTAMP(3) NOT NULL,
42 |
43 | CONSTRAINT "Menu_pkey" PRIMARY KEY ("id")
44 | );
45 |
46 | -- CreateTable
47 | CREATE TABLE "Role" (
48 | "id" SERIAL NOT NULL,
49 | "code" VARCHAR(20) NOT NULL,
50 | "name" VARCHAR(20) NOT NULL,
51 | "description" VARCHAR(100),
52 | "menuPermissions" TEXT[],
53 | "featurePermissions" TEXT[],
54 | "apiPermissions" TEXT[],
55 | "createTime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
56 | "updateTime" TIMESTAMP(3) NOT NULL,
57 |
58 | CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
59 | );
60 |
61 | -- CreateTable
62 | CREATE TABLE "User" (
63 | "id" SERIAL NOT NULL,
64 | "username" VARCHAR(50) NOT NULL,
65 | "password" VARCHAR(200) NOT NULL,
66 | "nickName" VARCHAR(50),
67 | "email" VARCHAR(50),
68 | "headPic" VARCHAR(100),
69 | "phone" VARCHAR(20),
70 | "isFrozen" BOOLEAN NOT NULL DEFAULT false,
71 | "isSuperAdmin" BOOLEAN NOT NULL DEFAULT false,
72 | "createTime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
73 | "updateTime" TIMESTAMP(3) NOT NULL,
74 |
75 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
76 | );
77 |
78 | -- CreateTable
79 | CREATE TABLE "UserRole" (
80 | "id" SERIAL NOT NULL,
81 | "userId" INTEGER NOT NULL,
82 | "roleId" INTEGER NOT NULL,
83 | "createTime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
84 |
85 | CONSTRAINT "UserRole_pkey" PRIMARY KEY ("id")
86 | );
87 |
88 | -- CreateIndex
89 | CREATE UNIQUE INDEX "Api_code_key" ON "Api"("code");
90 |
91 | -- CreateIndex
92 | CREATE UNIQUE INDEX "Menu_code_key" ON "Menu"("code");
93 |
94 | -- CreateIndex
95 | CREATE UNIQUE INDEX "Role_code_key" ON "Role"("code");
96 |
97 | -- CreateIndex
98 | CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
99 |
100 | -- CreateIndex
101 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
102 |
103 | -- CreateIndex
104 | CREATE INDEX "UserRole_roleId_idx" ON "UserRole"("roleId");
105 |
106 | -- CreateIndex
107 | CREATE INDEX "UserRole_userId_idx" ON "UserRole"("userId");
108 |
109 | -- CreateIndex
110 | CREATE UNIQUE INDEX "UserRole_userId_roleId_key" ON "UserRole"("userId", "roleId");
111 |
112 | -- AddForeignKey
113 | ALTER TABLE "Api" ADD CONSTRAINT "Api_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Api"("id") ON DELETE SET NULL ON UPDATE CASCADE;
114 |
115 | -- AddForeignKey
116 | ALTER TABLE "Menu" ADD CONSTRAINT "Menu_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Menu"("id") ON DELETE SET NULL ON UPDATE CASCADE;
117 |
118 | -- AddForeignKey
119 | ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
120 |
121 | -- AddForeignKey
122 | ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
123 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/prisma/schema/api.prisma:
--------------------------------------------------------------------------------
1 | model Api {
2 | id Int @id @default(autoincrement())
3 | parentId Int?
4 | type ApiType
5 | title String @db.VarChar(50)
6 | code String? @unique @db.VarChar(50)
7 | method ApiMethod?
8 | path String? @db.VarChar(100)
9 | description String? @db.VarChar(100)
10 | sort Int @default(0)
11 | createdAt DateTime @default(now())
12 | updatedAt DateTime @updatedAt
13 | parent Api? @relation("ApiHierarchy", fields: [parentId], references: [id])
14 | children Api[] @relation("ApiHierarchy")
15 | }
16 |
--------------------------------------------------------------------------------
/prisma/schema/enum.prisma:
--------------------------------------------------------------------------------
1 | enum MenuType {
2 | DIRECTORY // 目录类型资源
3 | MENU // 菜单类型资源
4 | FEATURE // 功能类型资源
5 | }
6 |
7 | enum ApiType {
8 | DIRECTORY // 目录类型资源
9 | API // 接口端点类型资源
10 | }
11 |
12 | enum ApiMethod {
13 | GET
14 | POST
15 | PUT
16 | PATCH
17 | DELETE
18 | }
19 |
--------------------------------------------------------------------------------
/prisma/schema/menu.prisma:
--------------------------------------------------------------------------------
1 | model Menu {
2 | id Int @id @default(autoincrement())
3 | parentId Int?
4 | type MenuType
5 | title String @db.VarChar(50)
6 | icon String? @db.VarChar(50)
7 | code String? @unique @db.VarChar(50)
8 | path String? @db.VarChar(100)
9 | description String? @db.VarChar(100)
10 | i18nKey String? @db.VarChar(50)
11 | sort Int @default(0)
12 | isShow Boolean? @default(true)
13 | createdAt DateTime @default(now())
14 | updatedAt DateTime @updatedAt
15 | parent Menu? @relation("MenuHierarchy", fields: [parentId], references: [id])
16 | children Menu[] @relation("MenuHierarchy")
17 | }
18 |
--------------------------------------------------------------------------------
/prisma/schema/role.prisma:
--------------------------------------------------------------------------------
1 | model Role {
2 | id Int @id @default(autoincrement())
3 | code String @unique @db.VarChar(20)
4 | name String @unique @db.VarChar(20)
5 | description String? @db.VarChar(100)
6 | menuPermissions String[]
7 | featurePermissions String[]
8 | apiPermissions String[]
9 | createTime DateTime @default(now())
10 | updateTime DateTime @updatedAt
11 | users UserRole[]
12 | }
13 |
--------------------------------------------------------------------------------
/prisma/schema/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("DATABASE_URL")
8 | }
9 |
--------------------------------------------------------------------------------
/prisma/schema/user.prisma:
--------------------------------------------------------------------------------
1 | model User {
2 | id Int @id @default(autoincrement())
3 | username String @unique @db.VarChar(50)
4 | password String @db.VarChar(200)
5 | nickName String? @db.VarChar(50)
6 | email String? @db.VarChar(50)
7 | headPic String? @db.VarChar(100)
8 | phone String? @db.VarChar(20)
9 | isFrozen Boolean @default(false)
10 | isSuperAdmin Boolean @default(false)
11 | createTime DateTime @default(now())
12 | updateTime DateTime @updatedAt
13 | roles UserRole[]
14 | }
15 |
16 | model UserRole {
17 | id Int @id @default(autoincrement())
18 | userId Int
19 | roleId Int
20 | createTime DateTime @default(now())
21 | role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
22 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
23 |
24 | @@unique([userId, roleId])
25 | @@index([roleId])
26 | @@index([userId])
27 | }
28 |
--------------------------------------------------------------------------------
/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import { MenuType, Prisma, PrismaClient } from '@prisma/client'
2 | import crypto from 'node:crypto'
3 | import { promisify } from 'node:util'
4 |
5 | const SUPER_ADMIN_USERNAME = 'pure-admin'
6 | const SUPER_ADMIN_PASSWORD = '123456'
7 | const SUPER_ADMIN_EMAIL = 'admin@pure-admin.com'
8 | const SUPER_ADMIN_NICKNAME = 'admin'
9 | const SUPER_ADMIN_PHONE = '13333333333'
10 |
11 | const scryptAsync = promisify(crypto.scrypt)
12 |
13 | async function hashPassword(password: string): Promise {
14 | const salt = crypto.randomBytes(16).toString('hex')
15 | const derivedKey = await scryptAsync(password, salt, 64) as Buffer
16 | return `${salt}:${derivedKey.toString('hex')}`
17 | }
18 |
19 | const prisma = new PrismaClient()
20 |
21 | async function main() {
22 | // 可以将不需要创建的内容注释掉
23 |
24 | // 创建超级管理员账号
25 | await createSuperAdmin()
26 |
27 | // 创建首页菜单数据
28 | await createHomeMenu()
29 | // 创建组件示例菜单数据
30 | await createExampleMenu()
31 | // 创建异常页菜单数据
32 | await createExceptionMenu()
33 | // 创建多级菜单数据
34 | await createMultiMenu()
35 | // 创建系统菜单数据
36 | await createSystemMenu()
37 | // 创建关于菜单数据
38 | await createAboutMenu()
39 | }
40 |
41 | async function createSuperAdmin() {
42 | const password = await hashPassword(SUPER_ADMIN_PASSWORD)
43 |
44 | const newUser: Prisma.UserCreateInput = {
45 | username: SUPER_ADMIN_USERNAME,
46 | password,
47 | email: SUPER_ADMIN_EMAIL,
48 | nickName: SUPER_ADMIN_NICKNAME,
49 | phone: SUPER_ADMIN_PHONE,
50 | isSuperAdmin: true,
51 | }
52 |
53 | await prisma.user.create({
54 | data: newUser,
55 | })
56 | }
57 |
58 | async function createHomeMenu() {
59 | await prisma.menu.create({
60 | data: {
61 | title: '首页',
62 | type: MenuType.MENU,
63 | icon: 'icon-park-outline:computer',
64 | code: 'home:read',
65 | path: '/',
66 | i18nKey: 'menu.home',
67 | sort: 0,
68 | isShow: true,
69 | description: '系统首页',
70 | },
71 | })
72 | }
73 |
74 | async function createExceptionMenu() {
75 | const exception = await prisma.menu.create({
76 | data: {
77 | title: '异常页',
78 | type: MenuType.DIRECTORY,
79 | icon: 'icon-park-outline:abnormal',
80 | i18nKey: 'menu.exception',
81 | sort: 3,
82 | isShow: true,
83 | description: '异常页',
84 | },
85 | })
86 |
87 | await prisma.menu.createMany({
88 | data: [
89 | {
90 | parentId: exception.id,
91 | title: '403',
92 | type: MenuType.MENU,
93 | icon: 'icon-park-outline:termination-file',
94 | path: '/exception/403',
95 | i18nKey: 'menu.exception403',
96 | sort: 0,
97 | isShow: true,
98 | },
99 | {
100 | parentId: exception.id,
101 | title: '404',
102 | type: MenuType.MENU,
103 | icon: 'icon-park-outline:file-search-one',
104 | path: '/exception/404',
105 | i18nKey: 'menu.exception404',
106 | sort: 1,
107 | isShow: true,
108 | },
109 | {
110 | parentId: exception.id,
111 | title: '500',
112 | type: MenuType.MENU,
113 | icon: 'icon-park-outline:file-failed-one',
114 | path: '/exception/500',
115 | i18nKey: 'menu.exception500',
116 | sort: 2,
117 | isShow: true,
118 | },
119 | ],
120 | })
121 | }
122 |
123 | async function createMultiMenu() {
124 | const multiMenu = await prisma.menu.create({
125 | data: {
126 | title: '多级菜单',
127 | type: MenuType.DIRECTORY,
128 | icon: 'icon-park-outline:hamburger-button',
129 | i18nKey: 'menu.multiMenu',
130 | isShow: true,
131 | sort: 4,
132 | },
133 | })
134 |
135 | await prisma.menu.create({
136 | data: {
137 | parentId: multiMenu.id,
138 | title: '菜单1',
139 | type: MenuType.MENU,
140 | icon: 'icon-park-outline:hamburger-button',
141 | path: '/multi-menu/first-child',
142 | i18nKey: 'menu.multiMenu1',
143 | isShow: true,
144 | sort: 0,
145 | },
146 | })
147 |
148 | const menu2 = await prisma.menu.create({
149 | data: {
150 | parentId: multiMenu.id,
151 | title: '菜单2',
152 | type: MenuType.DIRECTORY,
153 | icon: 'icon-park-outline:hamburger-button',
154 | i18nKey: 'menu.multiMenu2',
155 | isShow: true,
156 | sort: 1,
157 | },
158 | })
159 |
160 | await prisma.menu.create({
161 | data: {
162 | parentId: menu2.id,
163 | title: '菜单2-1',
164 | type: MenuType.MENU,
165 | icon: 'icon-park-outline:hamburger-button',
166 | path: '/multi-menu/second-child',
167 | i18nKey: 'menu.multiMenu2_1',
168 | isShow: true,
169 | sort: 0,
170 | },
171 | })
172 |
173 | const menu2_2 = await prisma.menu.create({
174 | data: {
175 | parentId: menu2.id,
176 | title: '菜单2-2',
177 | type: MenuType.DIRECTORY,
178 | icon: 'icon-park-outline:hamburger-button',
179 | i18nKey: 'menu.multiMenu2_2',
180 | isShow: true,
181 | sort: 1,
182 | },
183 | })
184 |
185 | await prisma.menu.create({
186 | data: {
187 | parentId: menu2_2.id,
188 | title: '菜单2-2-1',
189 | type: MenuType.MENU,
190 | icon: 'icon-park-outline:hamburger-button',
191 | path: '/multi-menu/third-child',
192 | i18nKey: 'menu.multiMenu2_2_1',
193 | isShow: true,
194 | sort: 0,
195 | },
196 | })
197 | }
198 |
199 | async function createSystemMenu() {
200 | // 创建根菜单:系统设置
201 | const systemSettings = await prisma.menu.create({
202 | data: {
203 | title: '系统设置',
204 | type: MenuType.DIRECTORY,
205 | icon: 'icon-park-outline:config',
206 | i18nKey: 'menu.system',
207 | sort: 5,
208 | isShow: true,
209 | description: '系统设置',
210 | },
211 | })
212 |
213 | // 创建子菜单:用户管理
214 | const userManagement = await prisma.menu.create({
215 | data: {
216 | parentId: systemSettings.id,
217 | title: '用户管理',
218 | type: MenuType.MENU,
219 | icon: 'icon-park-outline:user',
220 | code: 'system:user:read',
221 | path: '/system/user',
222 | i18nKey: 'menu.systemUser',
223 | sort: 0,
224 | isShow: true,
225 | },
226 | })
227 |
228 | // 创建用户管理菜单的功能权限
229 | await prisma.menu.createMany({
230 | data: [
231 | {
232 | parentId: userManagement.id,
233 | title: '新增用户',
234 | type: MenuType.FEATURE,
235 | code: 'system:user:create',
236 | },
237 | {
238 | parentId: userManagement.id,
239 | title: '编辑用户',
240 | type: MenuType.FEATURE,
241 | code: 'system:user:update',
242 | },
243 | {
244 | parentId: userManagement.id,
245 | title: '删除用户',
246 | type: MenuType.FEATURE,
247 | code: 'system:user:delete',
248 | },
249 | ],
250 | })
251 |
252 | // 创建子菜单:角色管理
253 | const roleManagement = await prisma.menu.create({
254 | data: {
255 | parentId: systemSettings.id,
256 | title: '角色管理',
257 | type: MenuType.MENU,
258 | icon: 'icon-park-outline:every-user',
259 | code: 'system:role:read',
260 | path: '/system/role',
261 | i18nKey: 'menu.systemRole',
262 | sort: 1,
263 | isShow: true,
264 | },
265 | })
266 |
267 | // 创建角色管理菜单的功能权限
268 | await prisma.menu.createMany({
269 | data: [
270 | {
271 | parentId: roleManagement.id,
272 | title: '新增角色',
273 | type: MenuType.FEATURE,
274 | code: 'system:role:create',
275 | },
276 | {
277 | parentId: roleManagement.id,
278 | title: '编辑角色',
279 | type: MenuType.FEATURE,
280 | code: 'system:role:update',
281 | },
282 | {
283 | parentId: roleManagement.id,
284 | title: '删除角色',
285 | type: MenuType.FEATURE,
286 | code: 'system:role:delete',
287 | },
288 | ],
289 | })
290 |
291 | // 创建子菜单:菜单管理
292 | const menuManagement = await prisma.menu.create({
293 | data: {
294 | parentId: systemSettings.id,
295 | title: '菜单管理',
296 | type: MenuType.MENU,
297 | icon: 'icon-park-outline:hamburger-button',
298 | code: 'system:menu:read',
299 | path: '/system/menu',
300 | i18nKey: 'menu.systemMenu',
301 | sort: 2,
302 | isShow: true,
303 | },
304 | })
305 |
306 | // 创建菜单管理菜单的功能权限
307 | await prisma.menu.createMany({
308 | data: [
309 | {
310 | parentId: menuManagement.id,
311 | title: '新增菜单',
312 | type: MenuType.FEATURE,
313 | code: 'system:menu:create',
314 | },
315 | {
316 | parentId: menuManagement.id,
317 | title: '编辑菜单',
318 | type: MenuType.FEATURE,
319 | code: 'system:menu:update',
320 | },
321 | {
322 | parentId: menuManagement.id,
323 | title: '删除菜单',
324 | type: MenuType.FEATURE,
325 | code: 'system:menu:delete',
326 | },
327 | ],
328 | })
329 |
330 | // 创建子菜单:API管理
331 | const apiManagement = await prisma.menu.create({
332 | data: {
333 | parentId: systemSettings.id,
334 | title: 'API管理',
335 | type: MenuType.MENU,
336 | icon: 'icon-park-outline:api',
337 | code: 'system:api:read',
338 | path: '/system/api',
339 | i18nKey: 'menu.systemApi',
340 | sort: 3,
341 | isShow: true,
342 | },
343 | })
344 |
345 | // 创建API管理菜单的功能权限
346 | await prisma.menu.createMany({
347 | data: [
348 | {
349 | parentId: apiManagement.id,
350 | title: '新增API',
351 | type: MenuType.FEATURE,
352 | code: 'system:api:create',
353 | },
354 | {
355 | parentId: apiManagement.id,
356 | title: '编辑API',
357 | type: MenuType.FEATURE,
358 | code: 'system:api:update',
359 | },
360 | {
361 | parentId: apiManagement.id,
362 | title: '删除API',
363 | type: MenuType.FEATURE,
364 | code: 'system:api:delete',
365 | },
366 | ],
367 | })
368 | }
369 |
370 | async function createExampleMenu() {
371 | // 创建根菜单:组件示例
372 | const example = await prisma.menu.create({
373 | data: {
374 | title: '组件示例',
375 | type: MenuType.DIRECTORY,
376 | icon: 'icon-park-outline:components',
377 | i18nKey: 'menu.example',
378 | sort: 2,
379 | isShow: true,
380 | },
381 | })
382 |
383 | await prisma.menu.createMany({
384 | data: [
385 | {
386 | parentId: example.id,
387 | title: '数据表格',
388 | type: MenuType.MENU,
389 | icon: 'icon-park-outline:table',
390 | path: '/example/data-table',
391 | i18nKey: 'menu.dataTableExample',
392 | sort: 0,
393 | isShow: true,
394 | },
395 | {
396 | parentId: example.id,
397 | title: '表格高度自适应',
398 | type: MenuType.MENU,
399 | icon: 'icon-park-outline:auto-height-one',
400 | path: '/example/table-auto-height',
401 | i18nKey: 'menu.tableAutoHeightExample',
402 | sort: 1,
403 | isShow: true,
404 | },
405 | {
406 | parentId: example.id,
407 | title: '跨页面数据传输',
408 | type: MenuType.MENU,
409 | icon: 'icon-park-outline:transfer-data',
410 | path: '/example/page-transfer',
411 | i18nKey: 'menu.pageTransferExample',
412 | sort: 2,
413 | isShow: true,
414 | },
415 | {
416 | parentId: example.id,
417 | title: '图表',
418 | type: MenuType.MENU,
419 | icon: 'icon-park-outline:chart-histogram-two',
420 | path: '/example/chart',
421 | i18nKey: 'menu.chartExample',
422 | sort: 3,
423 | isShow: true,
424 | },
425 | {
426 | parentId: example.id,
427 | title: '图标',
428 | type: MenuType.MENU,
429 | icon: 'icon-park-outline:bydesign',
430 | path: '/example/icon',
431 | i18nKey: 'menu.iconExample',
432 | sort: 4,
433 | isShow: true,
434 | },
435 | {
436 | parentId: example.id,
437 | title: '数字滚动',
438 | type: MenuType.MENU,
439 | icon: 'icon-park-outline:data-screen',
440 | path: '/example/count-to',
441 | i18nKey: 'menu.countToExample',
442 | sort: 5,
443 | isShow: true,
444 | },
445 | {
446 | parentId: example.id,
447 | title: '剪切板',
448 | type: MenuType.MENU,
449 | icon: 'icon-park-outline:copy',
450 | path: '/example/clipboard',
451 | i18nKey: 'menu.clipboardExample',
452 | sort: 6,
453 | isShow: true,
454 | },
455 | {
456 | parentId: example.id,
457 | title: '富文本编辑器',
458 | type: MenuType.MENU,
459 | icon: 'icon-park-outline:editor',
460 | path: '/example/rich-text',
461 | i18nKey: 'menu.richTextExample',
462 | sort: 7,
463 | isShow: true,
464 | },
465 | {
466 | parentId: example.id,
467 | title: '主题切换动画',
468 | type: MenuType.MENU,
469 | icon: 'icon-park-outline:switch-themes',
470 | path: '/example/theme-switch',
471 | i18nKey: 'menu.themeSwitchExample',
472 | sort: 8,
473 | isShow: true,
474 | },
475 | ],
476 | })
477 | }
478 |
479 | async function createAboutMenu() {
480 | await prisma.menu.create({
481 | data: {
482 | title: '关于',
483 | type: MenuType.MENU,
484 | icon: 'icon-park-outline:info',
485 | path: '/about',
486 | i18nKey: 'menu.about',
487 | sort: 99,
488 | isShow: true,
489 | },
490 | })
491 | }
492 |
493 | main()
494 | .catch((e) => {
495 | console.error(e)
496 | process.exit(1)
497 | })
498 | .finally(async () => {
499 | await prisma.$disconnect()
500 | })
501 |
--------------------------------------------------------------------------------
/src/app.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing'
2 |
3 | import { AppController } from './app.controller'
4 | import { AppService } from './app.service'
5 |
6 | describe('appController', () => {
7 | let appController: AppController
8 |
9 | beforeEach(async () => {
10 | const app: TestingModule = await Test.createTestingModule({
11 | controllers: [AppController],
12 | providers: [AppService],
13 | }).compile()
14 |
15 | appController = app.get(AppController)
16 | })
17 |
18 | describe('root', () => {
19 | it('should return "Hello World!"', () => {
20 | expect(appController.getHello()).toBe('Hello World!')
21 | })
22 | })
23 | })
24 |
--------------------------------------------------------------------------------
/src/app.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from '@nestjs/common'
2 |
3 | import { AppService } from './app.service'
4 |
5 | @Controller()
6 | export class AppController {
7 | constructor(private readonly appService: AppService) {}
8 |
9 | @Get()
10 | getHello(): string {
11 | return this.appService.getHello()
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common'
2 | import { ConfigModule, ConfigService } from '@nestjs/config'
3 | import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'
4 | import { JwtModule } from '@nestjs/jwt'
5 | import { ScheduleModule } from '@nestjs/schedule'
6 | import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'
7 | import dayjs from 'dayjs'
8 | import timezone from 'dayjs/plugin/timezone'
9 | import utc from 'dayjs/plugin/utc'
10 | import 'winston-daily-rotate-file'
11 | import { WinstonModule } from 'nest-winston'
12 | import { HeaderResolver, I18nModule, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n'
13 | import path from 'node:path'
14 | import winston from 'winston'
15 |
16 | import config from '@/configs'
17 | import { AllExceptionsFilter, HttpExceptionFilter } from '@/filters'
18 | import { AuthGuard } from '@/guards'
19 | import { FormatResponseInterceptor, InvokeRecordInterceptor } from '@/interceptors'
20 | import { ApiModule } from '@/modules/api/api.module'
21 | import { AuthModule } from '@/modules/auth/auth.module'
22 | import { CacheModule } from '@/modules/cache/cache.module'
23 | import { MenuModule } from '@/modules/menu/menu.module'
24 | import { NodemailerModule } from '@/modules/nodemailer/nodemailer.module'
25 | import { PrismaModule } from '@/modules/prisma/prisma.module'
26 | import { RoleModule } from '@/modules/role/role.module'
27 | import { UserModule } from '@/modules/user/user.module'
28 | import { createLoggerOptions, defaultLogFormat, getEnvPath } from '@/utils'
29 |
30 | import { AppController } from './app.controller'
31 | import { AppService } from './app.service'
32 |
33 | // 设置默认时区为东八区
34 | dayjs.extend(utc)
35 | dayjs.extend(timezone)
36 | dayjs.tz.setDefault('Asia/Shanghai')
37 |
38 | const envFilePath = getEnvPath(__dirname)
39 |
40 | @Module({
41 | imports: [
42 | ConfigModule.forRoot({
43 | envFilePath: [envFilePath, '.env'],
44 | isGlobal: true,
45 | load: [...config],
46 | }),
47 | ScheduleModule.forRoot(),
48 | PrismaModule.forRootAsync({
49 | imports: [ConfigModule],
50 | inject: [ConfigService],
51 | useFactory: async (configService: ConfigService) => ({
52 | ...(await configService.get('database')),
53 | }),
54 | }),
55 | CacheModule.forRootAsync({
56 | imports: [ConfigModule],
57 | inject: [ConfigService],
58 | useFactory: async (configService: ConfigService) => ({
59 | ...(await configService.get('redis')),
60 | }),
61 | }),
62 | JwtModule.registerAsync({
63 | imports: [ConfigModule],
64 | inject: [ConfigService],
65 | global: true,
66 | useFactory: async (configService: ConfigService) => ({
67 | ...(await configService.get('jwt')),
68 | }),
69 | }),
70 | ThrottlerModule.forRootAsync({
71 | imports: [ConfigModule],
72 | inject: [ConfigService],
73 | useFactory: async (configService: ConfigService) => ([
74 | ...(await configService.get('throttler')),
75 | ]),
76 | }),
77 | I18nModule.forRoot({
78 | fallbackLanguage: 'en-US',
79 | loaderOptions: {
80 | path: path.join(__dirname, '/locales/'),
81 | watch: true,
82 | },
83 | resolvers: [new HeaderResolver(['x-lang'])],
84 | typesOutputPath: path.join(__dirname, '/generated/i18n.generated.ts'),
85 | }),
86 | NodemailerModule.forRootAsync({
87 | imports: [ConfigModule],
88 | inject: [ConfigService],
89 | useFactory: async (configService: ConfigService) => ({
90 | ...(await configService.get('nodemailer')),
91 | }),
92 | }),
93 | // WinstonModule.forRoot({
94 | // level: 'http',
95 | // transports: [
96 | // new winston.transports.Console({
97 | // format: defaultLogFormat(),
98 | // }),
99 | // new winston.transports.DailyRotateFile({
100 | // ...createLoggerOptions('http', logDir),
101 | // format: defaultLogFormat(true, 'http'),
102 | // }),
103 | // new winston.transports.DailyRotateFile({
104 | // ...createLoggerOptions('info', logDir),
105 | // format: defaultLogFormat(true, 'info'),
106 | // }),
107 | // new winston.transports.DailyRotateFile({
108 | // ...createLoggerOptions('error', logDir),
109 | // format: defaultLogFormat(true, 'error'),
110 | // }),
111 | // ],
112 | // exceptionHandlers: [
113 | // new winston.transports.DailyRotateFile({
114 | // ...createLoggerOptions('exception', logDir),
115 | // format: defaultLogFormat(),
116 | // }),
117 | // ],
118 | // }),
119 | WinstonModule.forRootAsync({
120 | imports: [ConfigModule],
121 | inject: [ConfigService],
122 | useFactory: async (configService: ConfigService) => {
123 | const logDir = configService.get('NEST_LOG_DIR', 'log')
124 |
125 | return {
126 | level: 'http',
127 | transports: [
128 | new winston.transports.Console({
129 | format: defaultLogFormat(),
130 | }),
131 | new winston.transports.DailyRotateFile({
132 | ...createLoggerOptions('http', logDir),
133 | format: defaultLogFormat(true, 'http'),
134 | }),
135 | new winston.transports.DailyRotateFile({
136 | ...createLoggerOptions('info', logDir),
137 | format: defaultLogFormat(true, 'info'),
138 | }),
139 | new winston.transports.DailyRotateFile({
140 | ...createLoggerOptions('error', logDir),
141 | format: defaultLogFormat(true, 'error'),
142 | }),
143 | ],
144 | exceptionHandlers: [
145 | new winston.transports.DailyRotateFile({
146 | ...createLoggerOptions('exception', logDir),
147 | format: defaultLogFormat(),
148 | }),
149 | ],
150 | }
151 | },
152 | }),
153 | AuthModule,
154 | UserModule,
155 | RoleModule,
156 | MenuModule,
157 | ApiModule,
158 | ],
159 | controllers: [AppController],
160 | providers: [
161 | AppService,
162 | ConfigService,
163 |
164 | // -------------------- 请求处理流程 --------------------
165 | // 请求
166 | // ↓
167 | // 中间件 (Middlewares) [自上而下按顺序执行]
168 | // ↓
169 | // 守卫 (Guards) [自上而下按顺序执行]
170 | // ↓
171 | // 拦截器 (Interceptors) [自上而下按顺序执行] (请求阶段)
172 | // ↓
173 | // 管道 (Pipes) [自上而下按顺序执行]
174 | // ↓
175 | // 路由处理器 (Route Handler)
176 | // ↓
177 | // 拦截器 (Interceptors) [自下而上按顺序执行] (响应阶段)
178 | // ↓
179 | // 异常过滤器 (Exception Filters) [自下而上按顺序执行]
180 | // ↓
181 | // 响应
182 |
183 | // -------------------- Guards --------------------
184 | {
185 | provide: APP_GUARD,
186 | useClass: ThrottlerGuard, // 限流守卫
187 | },
188 | {
189 | provide: APP_GUARD,
190 | useClass: AuthGuard, // 权限守卫
191 | },
192 |
193 | // -------------------- Interceptors --------------------
194 | {
195 | provide: APP_INTERCEPTOR,
196 | useClass: InvokeRecordInterceptor, // 调用记录拦截器
197 | },
198 | {
199 | provide: APP_INTERCEPTOR,
200 | useClass: FormatResponseInterceptor, // 格式化响应拦截器
201 | },
202 |
203 | // -------------------- Pipes --------------------
204 | {
205 | provide: APP_PIPE,
206 | useValue: new I18nValidationPipe({
207 | transform: true,
208 | whitelist: true,
209 | validateCustomDecorators: true,
210 | skipMissingProperties: false,
211 | stopAtFirstError: true,
212 | disableErrorMessages: false,
213 | }), // 数据验证管道
214 | },
215 |
216 | // -------------------- Exceptions Filters --------------------
217 | {
218 | provide: APP_FILTER,
219 | useClass: AllExceptionsFilter, // 所有异常过滤器, 用于捕获除 HttpException 之外的异常
220 | },
221 | {
222 | provide: APP_FILTER,
223 | useClass: HttpExceptionFilter, // Http 异常过滤器
224 | },
225 | {
226 | provide: APP_FILTER,
227 | useFactory: () => new I18nValidationExceptionFilter({ // 国际化异常过滤器
228 | detailedErrors: false,
229 | }),
230 | },
231 | ],
232 | })
233 | export class AppModule {}
234 |
--------------------------------------------------------------------------------
/src/app.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common'
2 |
3 | @Injectable()
4 | export class AppService {
5 | getHello(): string {
6 | return 'hello world!'
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/common/dto/delete-many.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsArray, IsNotEmpty, IsNumber } from 'class-validator'
2 |
3 | export class DeleteManyDto {
4 | @IsArray()
5 | @IsNotEmpty()
6 | @IsNumber({}, { each: true })
7 | ids: number[]
8 | }
9 |
--------------------------------------------------------------------------------
/src/common/dto/index.ts:
--------------------------------------------------------------------------------
1 | export * from './delete-many.dto'
2 | export * from './page.dto'
3 |
--------------------------------------------------------------------------------
/src/common/dto/page.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger'
2 | import { Type } from 'class-transformer'
3 | import { IsNumber, IsOptional, Min } from 'class-validator'
4 | import { i18nValidationMessage } from 'nestjs-i18n'
5 |
6 | export class PageDto {
7 | @ApiProperty({ required: false })
8 | @IsOptional()
9 | @Type(() => Number)
10 | @IsNumber({}, { message: i18nValidationMessage('validation.invalid', { field: 'page' }) })
11 | @Min(1, { message: i18nValidationMessage('validation.min', { field: 'page', min: 1 }) })
12 | page?: number = 1
13 |
14 | @ApiProperty({ required: false })
15 | @IsOptional()
16 | @Type(() => Number)
17 | @IsNumber({}, { message: i18nValidationMessage('validation.invalid', { field: 'pageSize' }) })
18 | @Min(1, { message: i18nValidationMessage('validation.min', { field: 'pageSize', min: 1 }) })
19 | pageSize?: number = 10
20 | }
21 |
--------------------------------------------------------------------------------
/src/configs/database.config.ts:
--------------------------------------------------------------------------------
1 | import { registerAs } from '@nestjs/config'
2 |
3 | export default registerAs('database', () => ({
4 | url: process.env.DATABASE_URL,
5 | }))
6 |
--------------------------------------------------------------------------------
/src/configs/index.ts:
--------------------------------------------------------------------------------
1 | import databaseConfig from './database.config'
2 | import jwtConfig from './jwt.config'
3 | import nodemailerConfig from './nodemailer.config'
4 | import redisConfig from './redis.config'
5 | import throttlerConfig from './throttler.config'
6 |
7 | export default [
8 | databaseConfig,
9 | jwtConfig,
10 | nodemailerConfig,
11 | redisConfig,
12 | throttlerConfig,
13 | ]
14 |
--------------------------------------------------------------------------------
/src/configs/jwt.config.ts:
--------------------------------------------------------------------------------
1 | import { registerAs } from '@nestjs/config'
2 |
3 | export default registerAs('jwt', () => ({
4 | secret: process.env.JWT_SECRET,
5 | signOptions: {
6 | expiresIn: '30m', // 默认 30 分钟
7 | },
8 | }))
9 |
--------------------------------------------------------------------------------
/src/configs/nodemailer.config.ts:
--------------------------------------------------------------------------------
1 | import { registerAs } from '@nestjs/config'
2 |
3 | export default registerAs('nodemailer', () => ({
4 | host: process.env.NODEMAILER_HOST,
5 | port: process.env.NODEMAILER_PORT,
6 | secure: false,
7 | auth: {
8 | user: process.env.NODEMAILER_USER,
9 | pass: process.env.NODEMAILER_PASSWORD,
10 | },
11 | }))
12 |
--------------------------------------------------------------------------------
/src/configs/redis.config.ts:
--------------------------------------------------------------------------------
1 | import { registerAs } from '@nestjs/config'
2 |
3 | export default registerAs('redis', () => ({
4 | type: process.env.REDIS_TYPE,
5 | url: process.env.REDIS_URL,
6 | }))
7 |
--------------------------------------------------------------------------------
/src/configs/throttler.config.ts:
--------------------------------------------------------------------------------
1 | import { registerAs } from '@nestjs/config'
2 |
3 | export default registerAs('throttler', () => [
4 | {
5 | ttl: Number.parseInt(process.env.THROTTLER_TTL, 10),
6 | limit: Number.parseInt(process.env.THROTTLER_LIMIT, 10),
7 | },
8 | ])
9 |
--------------------------------------------------------------------------------
/src/constants/permissions.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 用户管理
3 | */
4 | export enum USER {
5 | CREATE = 'system:user:create',
6 | READ = 'system:user:read',
7 | UPDATE = 'system:user:update',
8 | DELETE = 'system:user:delete',
9 | }
10 |
11 | /**
12 | * 角色管理
13 | */
14 | export enum ROLE {
15 | CREATE = 'system:role:create',
16 | READ = 'system:role:read',
17 | UPDATE = 'system:role:update',
18 | DELETE = 'system:role:delete',
19 | }
20 |
21 | /**
22 | * 菜单管理
23 | */
24 | export enum MENU {
25 | CREATE = 'system:menu:create',
26 | READ = 'system:menu:read',
27 | UPDATE = 'system:menu:update',
28 | DELETE = 'system:menu:delete',
29 | }
30 |
31 | /**
32 | * 接口管理
33 | */
34 | export enum API {
35 | CREATE = 'system:api:create',
36 | READ = 'system:api:read',
37 | UPDATE = 'system:api:update',
38 | DELETE = 'system:api:delete',
39 | }
40 |
--------------------------------------------------------------------------------
/src/decorators/cache.decorator.ts:
--------------------------------------------------------------------------------
1 | import { SetMetadata } from '@nestjs/common'
2 |
3 | export const CACHE_KEY = 'CACHE'
4 | export const CACHE_TTL_KEY = 'CACHE_TTL'
5 | export const CACHE_USER_KEY = 'CACHE_USER'
6 | export const CACHE_INVALIDATE_KEY = 'CACHE_INVALIDATE'
7 | export const CACHE_INVALIDATE_USER_KEY = 'CACHE_INVALIDATE_USER'
8 |
9 | // 用于设置缓存前缀
10 | export const CacheKey = (prefix: string) => SetMetadata(CACHE_KEY, prefix)
11 |
12 | // 用于设置用户缓存前缀
13 | export const CacheUserKey = (prefix: string) => SetMetadata(CACHE_USER_KEY, prefix)
14 |
15 | // 用于设置缓存过期时间
16 | export const CacheTTL = (ttl: number) => SetMetadata(CACHE_TTL_KEY, ttl)
17 |
18 | // 用于清除普通缓存,可接收单个前缀或前缀数组
19 | export function CacheInvalidate(prefixes: string | string[]) {
20 | return SetMetadata(CACHE_INVALIDATE_KEY, Array.isArray(prefixes) ? prefixes : [prefixes])
21 | }
22 |
23 | // 用于清除用户相关的缓存,可接收单个前缀或前缀数组
24 | // export function CacheInvalidateUser(prefixes: string | string[]) {
25 | // return SetMetadata(CACHE_INVALIDATE_USER_KEY, Array.isArray(prefixes) ? prefixes : [prefixes])
26 | // }
27 | export function CacheInvalidateUser(
28 | prefixes: string | string[],
29 | userIdSelector?: (req: any) => number | number[],
30 | ) {
31 | return SetMetadata(CACHE_INVALIDATE_USER_KEY, {
32 | prefixes: Array.isArray(prefixes) ? prefixes : [prefixes],
33 | userIdSelector,
34 | })
35 | }
36 |
--------------------------------------------------------------------------------
/src/decorators/index.ts:
--------------------------------------------------------------------------------
1 | export * from './cache.decorator'
2 | export * from './is-public.decorator'
3 | export * from './is-refresh.decorator'
4 | export * from './permissions.decorator'
5 | export * from './user-info.decorator'
6 |
--------------------------------------------------------------------------------
/src/decorators/is-public.decorator.ts:
--------------------------------------------------------------------------------
1 | import { SetMetadata } from '@nestjs/common'
2 |
3 | export const IS_PUBLIC_KEY = 'IS_PUBLIC'
4 |
5 | export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)
6 |
--------------------------------------------------------------------------------
/src/decorators/is-refresh.decorator.ts:
--------------------------------------------------------------------------------
1 | import { SetMetadata } from '@nestjs/common'
2 |
3 | export const IS_REFRESH_KEY = 'IS_REFRESH'
4 |
5 | export const Refresh = () => SetMetadata(IS_REFRESH_KEY, true)
6 |
--------------------------------------------------------------------------------
/src/decorators/permissions.decorator.ts:
--------------------------------------------------------------------------------
1 | import { SetMetadata } from '@nestjs/common'
2 |
3 | export const PERMISSIONS_KEY = 'PERMISSIONS'
4 |
5 | export function Permissions(...permissions: string[]) {
6 | return SetMetadata(PERMISSIONS_KEY, permissions)
7 | }
8 |
--------------------------------------------------------------------------------
/src/decorators/user-info.decorator.ts:
--------------------------------------------------------------------------------
1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'
2 | import { FastifyRequest } from 'fastify'
3 |
4 | export const UserInfo = createParamDecorator((propertyKey: string, ctx: ExecutionContext) => {
5 | const request = ctx.switchToHttp().getRequest()
6 |
7 | if (!request.user) {
8 | return null
9 | }
10 |
11 | return propertyKey ? request.user[propertyKey] : request.user
12 | })
13 |
--------------------------------------------------------------------------------
/src/filters/all-exception.filter.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ArgumentsHost,
3 | Catch,
4 | ExceptionFilter,
5 | HttpException,
6 | HttpStatus,
7 | Inject,
8 | Injectable,
9 | } from '@nestjs/common'
10 | import { FastifyReply, FastifyRequest } from 'fastify'
11 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'
12 | import { Logger } from 'winston'
13 |
14 | @Catch()
15 | @Injectable()
16 | export class AllExceptionsFilter implements ExceptionFilter {
17 | constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
18 |
19 | catch(exception: unknown, host: ArgumentsHost) {
20 | const httpContext = host.switchToHttp()
21 |
22 | const request = httpContext.getRequest()
23 | const response = httpContext.getResponse()
24 |
25 | const userAgent = request.headers['user-agent']
26 | const { ip, method, url } = request
27 |
28 | const statusCode
29 | = exception instanceof HttpException
30 | ? exception.getStatus()
31 | : HttpStatus.INTERNAL_SERVER_ERROR
32 |
33 | const message = exception instanceof Error
34 | ? exception.message
35 | : 'Internal server error'
36 |
37 | const data = {
38 | code: statusCode,
39 | status: 'error',
40 | data: {
41 | message,
42 | path: request.url,
43 | stack: exception instanceof Error ? exception.stack : null,
44 | },
45 | }
46 |
47 | const userInfo = request.user?.id ? `USER:${request.user?.username}(${request.user?.id})` : 'USER:guest'
48 |
49 | this.logger.error(
50 | `[${method} › ${url}] ${userInfo} IP:${ip} UA:${userAgent} CODE:${response.statusCode} ERR:${JSON.stringify(data)}`,
51 | )
52 |
53 | response.status(statusCode).send(data)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/filters/http-exception.filter.ts:
--------------------------------------------------------------------------------
1 | import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject, Injectable } from '@nestjs/common'
2 | import { FastifyReply, FastifyRequest } from 'fastify'
3 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'
4 | import { Logger } from 'winston'
5 |
6 | @Catch(HttpException)
7 | @Injectable()
8 | export class HttpExceptionFilter implements ExceptionFilter {
9 | constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
10 |
11 | catch(exception: HttpException, host: ArgumentsHost) {
12 | const httpContext = host.switchToHttp()
13 |
14 | const request = httpContext.getRequest()
15 | const response = httpContext.getResponse()
16 |
17 | const userAgent = request.headers['user-agent']
18 | const { ip, method, url } = request
19 |
20 | const statusCode = exception.getStatus()
21 | const message
22 | = exception.getResponse()
23 | && (exception.getResponse() as { message?: string[] | string }).message
24 | ? (exception.getResponse() as { message: string[] | string }).message
25 | : exception.getResponse()
26 |
27 | const data = {
28 | code: statusCode,
29 | status: 'error',
30 | data: {
31 | message,
32 | path: request.url,
33 | },
34 | }
35 |
36 | const userInfo = request.user?.id ? `USER:${request.user?.username}(${request.user?.id})` : 'USER:guest'
37 |
38 | this.logger.error(
39 | `[${method} › ${url}] ${userInfo} IP:${ip} UA:${userAgent} CODE:${response.statusCode} ERR:${JSON.stringify(data)}`,
40 | )
41 |
42 | response.status(statusCode).send(data)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/filters/index.ts:
--------------------------------------------------------------------------------
1 | export * from './all-exception.filter'
2 | export * from './http-exception.filter'
3 |
--------------------------------------------------------------------------------
/src/guards/auth.guard.ts:
--------------------------------------------------------------------------------
1 | import { ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'
2 | import { Reflector } from '@nestjs/core'
3 | import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'
4 | import { FastifyRequest } from 'fastify'
5 | import { I18nService } from 'nestjs-i18n'
6 |
7 | import { IS_PUBLIC_KEY, IS_REFRESH_KEY, PERMISSIONS_KEY } from '@/decorators'
8 |
9 | @Injectable()
10 | export class AuthGuard extends PassportAuthGuard('jwt') {
11 | constructor(
12 | private reflector: Reflector,
13 | private readonly i18n: I18nService,
14 | ) {
15 | super()
16 | }
17 |
18 | async canActivate(context: ExecutionContext) {
19 | // 检查是否是公开接口
20 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
21 | context.getHandler(),
22 | context.getClass(),
23 | ]) ?? false
24 |
25 | if (isPublic) {
26 | return true
27 | }
28 |
29 | try {
30 | // 进行 jwt 认证
31 | const isAuthenticated = await super.canActivate(context)
32 |
33 | if (!isAuthenticated) {
34 | throw new UnauthorizedException(this.i18n.t('common.tokenExpired'))
35 | }
36 |
37 | const isRefresh = this.reflector.get(IS_REFRESH_KEY, context.getHandler()) ?? false
38 | const request = context.switchToHttp().getRequest()
39 |
40 | if (!isRefresh && request.user.tokenType !== 'access') {
41 | throw new UnauthorizedException(this.i18n.t('common.refreshTokenOnly'))
42 | }
43 |
44 | if (request.user.isSuperAdmin) {
45 | return true
46 | }
47 |
48 | const requiredApiPermissions = this.reflector.getAllAndOverride(PERMISSIONS_KEY, [
49 | context.getClass(),
50 | context.getHandler(),
51 | ]) ?? []
52 |
53 | if (requiredApiPermissions.length === 0) {
54 | return true
55 | }
56 |
57 | const userApiPermissions = request.user.apiPermissions
58 |
59 | const hasAllPermissions = requiredApiPermissions.every(permission => userApiPermissions.includes(permission))
60 |
61 | if (!hasAllPermissions) {
62 | throw new ForbiddenException(this.i18n.t('common.noPermission'))
63 | }
64 |
65 | return true
66 | }
67 | catch (error) {
68 | if (error instanceof UnauthorizedException) {
69 | throw new UnauthorizedException(this.i18n.t('common.accessTokenOnly'))
70 | }
71 |
72 | throw error
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/guards/index.ts:
--------------------------------------------------------------------------------
1 | export * from './auth.guard'
2 |
--------------------------------------------------------------------------------
/src/interceptors/cache.interceptor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CallHandler,
3 | ExecutionContext,
4 | Inject,
5 | Injectable,
6 | NestInterceptor,
7 | } from '@nestjs/common'
8 | import { Reflector } from '@nestjs/core'
9 | import { FastifyRequest } from 'fastify'
10 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'
11 | import { Observable, of, tap } from 'rxjs'
12 | import { Logger } from 'winston'
13 |
14 | import {
15 | CACHE_INVALIDATE_KEY,
16 | CACHE_INVALIDATE_USER_KEY,
17 | CACHE_KEY,
18 | CACHE_TTL_KEY,
19 | CACHE_USER_KEY,
20 | } from '@/decorators'
21 | import { CacheService } from '@/modules/cache/cache.service'
22 |
23 | @Injectable()
24 | export class CacheInterceptor implements NestInterceptor {
25 | private readonly DEFAULT_CACHE_TTL = 60 * 60 // 默认缓存时间 1 小时
26 | private readonly CACHE_NAMESPACE = 'api' // 缓存命名空间
27 |
28 | constructor(
29 | private readonly cacheService: CacheService,
30 | private readonly reflector: Reflector,
31 | @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
32 | ) {}
33 |
34 | private generateCacheKey(
35 | prefix: string | undefined,
36 | userPrefix: string | undefined,
37 | userId: number | undefined,
38 | queryParams: string,
39 | ): string | null {
40 | if (!prefix && !userPrefix)
41 | return null
42 |
43 | const baseKey = `${this.CACHE_NAMESPACE}:`
44 | const queryString = queryParams || 'default'
45 |
46 | if (prefix) {
47 | return `${baseKey}${prefix}:${queryString}`
48 | }
49 |
50 | return `${baseKey}${userPrefix}:${userId}:${queryString}`
51 | }
52 |
53 | async intercept(context: ExecutionContext, next: CallHandler): Promise> {
54 | // 获取缓存前缀
55 | const prefix = this.reflector.get(CACHE_KEY, context.getHandler())
56 | const userPrefix = this.reflector.get(CACHE_USER_KEY, context.getHandler())
57 |
58 | // 获取需要清除的缓存前缀
59 | const invalidatePrefixes = this.reflector.get(CACHE_INVALIDATE_KEY, context.getHandler()) ?? []
60 | const invalidateUserPrefixes = this.reflector.get<{
61 | prefixes: string[]
62 | userIdSelector?: (req: any) => number | number[]
63 | } | undefined>(CACHE_INVALIDATE_USER_KEY, context.getHandler()) ?? {
64 | prefixes: [],
65 | userIdSelector: undefined,
66 | }
67 |
68 | if (!prefix && !userPrefix && invalidatePrefixes.length === 0 && invalidateUserPrefixes.prefixes.length === 0) {
69 | return next.handle()
70 | }
71 |
72 | const ttl = this.reflector.get(CACHE_TTL_KEY, context.getHandler()) ?? this.DEFAULT_CACHE_TTL
73 |
74 | const httpContext = context.switchToHttp()
75 | const request = httpContext.getRequest()
76 | const queryParams = Object.entries(request.query)
77 | .map(([key, value]) => `${key}-${value || ''}`)
78 | .join('|')
79 | const userId = request.user?.id as number
80 |
81 | const cacheKey = this.generateCacheKey(prefix, userPrefix, userId, queryParams)
82 |
83 | try {
84 | // 处理普通缓存失效
85 | if (invalidatePrefixes.length > 0) {
86 | for (const invalidatePrefix of invalidatePrefixes) {
87 | const count = await this.cacheService.deleteByPrefix(this.CACHE_NAMESPACE, invalidatePrefix)
88 | if (count > 0) {
89 | this.logger.info(`已清除前缀为 ${invalidatePrefix} 的 ${count} 个缓存条目`)
90 | }
91 | }
92 | }
93 |
94 | // 处理用户相关的缓存失效
95 | if (invalidateUserPrefixes.prefixes.length > 0) {
96 | let userIds: number[] = []
97 |
98 | if (invalidateUserPrefixes.userIdSelector) {
99 | const selectedIds = invalidateUserPrefixes.userIdSelector(request)
100 |
101 | if (Array.isArray(selectedIds)) {
102 | userIds = selectedIds
103 | }
104 | else {
105 | userIds = [selectedIds]
106 | }
107 | }
108 | else {
109 | userIds = [userId]
110 | }
111 |
112 | // 清除指定用户的缓存
113 | for (const userId of userIds) {
114 | for (const invalidateUserPrefix of invalidateUserPrefixes.prefixes) {
115 | const userSpecificPrefix = `${invalidateUserPrefix}:${userId}`
116 | const count = await this.cacheService.deleteByPrefix(this.CACHE_NAMESPACE, userSpecificPrefix)
117 | if (count > 0) {
118 | this.logger.info(`已清除前缀为 ${userSpecificPrefix} 的 ${count} 个用户缓存条目`)
119 | }
120 | }
121 | }
122 | }
123 |
124 | // 如果是读取操作(有缓存键),尝试从缓存获取数据
125 | if (cacheKey) {
126 | const cachedRawData = await this.cacheService.get(cacheKey)
127 | if (cachedRawData) {
128 | const cachedData = JSON.parse(cachedRawData)
129 | this.logger.info(`Cache hit: ${cacheKey}`)
130 | return of(cachedData)
131 | }
132 |
133 | this.logger.info(`Cache miss: ${cacheKey}`)
134 | }
135 |
136 | // 处理请求并根据需要更新或清除缓存
137 | return next.handle().pipe(
138 | tap(async (data) => {
139 | // 如果是读取操作且有返回数据,则存储到缓存
140 | if (data && cacheKey) {
141 | await this.cacheService.set(cacheKey, JSON.stringify(data), ttl)
142 | }
143 | }),
144 | )
145 | }
146 | catch (error) {
147 | this.logger.error(`缓存操作出错: ${error.message}`, error)
148 | return next.handle()
149 | }
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/src/interceptors/format-response.interceptor.ts:
--------------------------------------------------------------------------------
1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'
2 | import { FastifyReply } from 'fastify'
3 | import { map, Observable } from 'rxjs'
4 |
5 | @Injectable()
6 | export class FormatResponseInterceptor implements NestInterceptor {
7 | intercept(context: ExecutionContext, next: CallHandler): Observable {
8 | const httpContext = context.switchToHttp()
9 | const response = httpContext.getResponse()
10 |
11 | return next.handle().pipe(
12 | map((data) => {
13 | return {
14 | code: response.statusCode,
15 | status: 'success',
16 | data,
17 | }
18 | }),
19 | )
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/interceptors/index.ts:
--------------------------------------------------------------------------------
1 | export * from './cache.interceptor'
2 | export * from './format-response.interceptor'
3 | export * from './invoke-record.interceptor'
4 |
--------------------------------------------------------------------------------
/src/interceptors/invoke-record.interceptor.ts:
--------------------------------------------------------------------------------
1 | import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common'
2 | import { FastifyReply, FastifyRequest } from 'fastify'
3 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'
4 | import { Observable, tap } from 'rxjs'
5 | import { Logger } from 'winston'
6 |
7 | @Injectable()
8 | export class InvokeRecordInterceptor implements NestInterceptor {
9 | constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {}
10 |
11 | intercept(
12 | context: ExecutionContext,
13 | next: CallHandler,
14 | ): Observable | Promise> {
15 | const httpContext = context.switchToHttp()
16 |
17 | const request = httpContext.getRequest()
18 | const response = httpContext.getResponse()
19 |
20 | const userAgent = request.headers['user-agent']
21 | const { ip, method, url } = request
22 |
23 | const userInfo = request.user?.id ? `USER:${request.user?.username}(${request.user?.id})` : 'USER:guest'
24 |
25 | const currentTime = Date.now()
26 |
27 | return next.handle().pipe(
28 | tap((res) => {
29 | this.logger.http(
30 | `[${method} › ${url}] ${userInfo} IP:${ip} UA:${userAgent} CODE:${response.statusCode} RES[${Date.now() - currentTime}ms]:${JSON.stringify(res)}`,
31 | )
32 | }),
33 | )
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/locales/en-US/common.json:
--------------------------------------------------------------------------------
1 | {
2 | "usernameOrPasswordError": "Username or password error",
3 | "userFrozen": "User is frozen",
4 | "userExists": "User already exists",
5 | "userNotFound": "User not found",
6 | "oldPasswordError": "Old password is incorrect",
7 | "passwordUpdated": "Password updated successfully",
8 | "tokenExpired": "Token expired, please login again",
9 | "refreshTokenOnly": "Refresh token can only be used for refreshing",
10 | "accessTokenOnly": "Access token cannot be used for refreshing",
11 | "noPermission": "You do not have permission to access this interface"
12 | }
13 |
--------------------------------------------------------------------------------
/src/locales/en-US/validation.json:
--------------------------------------------------------------------------------
1 | {
2 | "notEmpty": "{field} cannot be empty",
3 | "invalid": "{field} is invalid",
4 | "minLength": "{field} must be at least {min} characters long",
5 | "maxLength": "{field} must be at most {max} characters long",
6 | "min": "{field} must be at least {min}",
7 | "max": "{field} must be at most {max}"
8 | }
9 |
--------------------------------------------------------------------------------
/src/locales/zh-CN/common.json:
--------------------------------------------------------------------------------
1 | {
2 | "usernameOrPasswordError": "用户名或密码错误",
3 | "userFrozen": "用户已冻结",
4 | "userExists": "用户已存在",
5 | "userNotFound": "用户不存在",
6 | "oldPasswordError": "旧密码不正确",
7 | "passwordUpdated": "密码更新成功",
8 | "tokenExpired": "Token 失效,请重新登录",
9 | "refreshTokenOnly": "refreshToken 只能用于刷新接口",
10 | "accessTokenOnly": "accessToken 不能用于刷新接口",
11 | "noPermission": "您没有访问该接口的权限"
12 | }
13 |
--------------------------------------------------------------------------------
/src/locales/zh-CN/validation.json:
--------------------------------------------------------------------------------
1 | {
2 | "notEmpty": "{field}不能为空",
3 | "invalid": "{field}格式不正确",
4 | "minLength": "{field}必须至少{min}个字符",
5 | "maxLength": "{field}必须最多{max}个字符",
6 | "min": "{field}最小值为{min}",
7 | "max": "{field}最大值为{max}"
8 | }
9 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import helmet from '@fastify/helmet'
2 | import { ConfigService } from '@nestjs/config'
3 | import { NestFactory } from '@nestjs/core'
4 | import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
5 | // import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
6 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'
7 |
8 | import { AppModule } from './app.module'
9 |
10 | async function bootstrap() {
11 | const adapter = new FastifyAdapter()
12 | adapter.enableCors({
13 | origin: ['*'],
14 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
15 | allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization', 'x-lang'],
16 | maxAge: 86400, // 预检请求结果缓存时间(秒)
17 | })
18 |
19 | const app = await NestFactory.create(AppModule, adapter)
20 |
21 | await app.register(helmet)
22 |
23 | // 使用 Winston logger 作为全局日志
24 | app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER))
25 |
26 | // const config = new DocumentBuilder()
27 | // .setTitle('Web Crawler')
28 | // .setDescription('api 接口文档')
29 | // .setVersion('1.0')
30 | // .addBearerAuth({
31 | // type: 'http',
32 | // description: '基于 jwt 的认证'
33 | // })
34 | // .build()
35 | // const document = SwaggerModule.createDocument(app, config)
36 | // SwaggerModule.setup('api-doc', app, document)
37 |
38 | const configService = app.get(ConfigService)
39 |
40 | const port = configService.get('NEST_SERVER_PORT')
41 | ? Number.parseInt(configService.get('NEST_SERVER_PORT'), 10)
42 | : 3000
43 |
44 | await app.listen(port, '0.0.0.0')
45 | }
46 |
47 | bootstrap()
48 |
--------------------------------------------------------------------------------
/src/modules/api/api.controller.ts:
--------------------------------------------------------------------------------
1 | import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, UseInterceptors, UsePipes } from '@nestjs/common'
2 | import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'
3 |
4 | import { API } from '@/constants/permissions'
5 | import { CacheInvalidate, CacheKey, CacheTTL, Permissions } from '@/decorators'
6 | import { CacheInterceptor } from '@/interceptors'
7 | import { updateValidationPipe } from '@/pipes'
8 |
9 | import { ApiService } from './api.service'
10 | import { CreateApiDto } from './dto/create-api.dto'
11 | import { UpdateApiDto } from './dto/update-api.dto'
12 |
13 | @Controller('api')
14 | @ApiTags('接口管理模块')
15 | export class ApiController {
16 | private static readonly CACHE_TTL = 60 * 60 * 1
17 |
18 | constructor(private readonly apiService: ApiService) {}
19 |
20 | @Get()
21 | @Permissions(API.READ)
22 | @CacheKey('api:tree')
23 | @CacheTTL(ApiController.CACHE_TTL)
24 | @UseInterceptors(CacheInterceptor)
25 | @ApiBearerAuth()
26 | @ApiOperation({ summary: '获取接口树' })
27 | @ApiOkResponse({
28 | description: '获取接口树成功',
29 | })
30 | findApiTree() {
31 | return this.apiService.findApiTree()
32 | }
33 |
34 | @Get('/flat')
35 | @Permissions(API.READ)
36 | @CacheKey('api:flat')
37 | @CacheTTL(ApiController.CACHE_TTL)
38 | @UseInterceptors(CacheInterceptor)
39 | @ApiBearerAuth()
40 | @ApiOperation({ summary: '获取接口扁平化列表' })
41 | @ApiOkResponse({
42 | description: '获取接口扁平化列表成功',
43 | })
44 | findFlatApiTree() {
45 | return this.apiService.findFlatApiTree()
46 | }
47 |
48 | @Get('/permission')
49 | @Permissions(API.READ)
50 | @CacheKey('api:permission')
51 | @CacheTTL(ApiController.CACHE_TTL)
52 | @UseInterceptors(CacheInterceptor)
53 | @ApiBearerAuth()
54 | @ApiOperation({ summary: '获取有权限的接口列表' })
55 | @ApiOkResponse({
56 | description: '获取有权限的接口列表成功',
57 | })
58 | findPermissionApis() {
59 | return this.apiService.findPermissionApis()
60 | }
61 |
62 | @Post()
63 | @Permissions(API.CREATE)
64 | @CacheInvalidate(['api:tree', 'api:flat', 'api:permission'])
65 | @UseInterceptors(CacheInterceptor)
66 | @ApiBearerAuth()
67 | @ApiOperation({ summary: '创建接口' })
68 | @ApiOkResponse({
69 | description: '创建接口成功',
70 | })
71 | create(@Body() createApiDto: CreateApiDto) {
72 | return this.apiService.create(createApiDto)
73 | }
74 |
75 | @Get(':id')
76 | @ApiBearerAuth()
77 | async getApiById(@Param('id', ParseIntPipe) id: number) {
78 | return this.apiService.findApiById(id)
79 | }
80 |
81 | @Put(':id')
82 | @Permissions(API.UPDATE)
83 | @CacheInvalidate(['api:tree', 'api:flat', 'api:permission'])
84 | @UseInterceptors(CacheInterceptor)
85 | @UsePipes(updateValidationPipe)
86 | @ApiBearerAuth()
87 | @ApiOperation({ summary: '更新接口' })
88 | @ApiOkResponse({
89 | description: '更新接口成功',
90 | })
91 | update(@Param('id', ParseIntPipe) id: number, @Body() updateApiDto: UpdateApiDto) {
92 | return this.apiService.update(id, updateApiDto)
93 | }
94 |
95 | @Delete(':id')
96 | @Permissions(API.DELETE)
97 | @CacheInvalidate(['api:tree', 'api:flat', 'api:permission'])
98 | @UseInterceptors(CacheInterceptor)
99 | @ApiBearerAuth()
100 | @ApiOperation({ summary: '删除接口' })
101 | @ApiOkResponse({
102 | description: '删除接口成功',
103 | })
104 | async delete(@Param('id', ParseIntPipe) id: number) {
105 | return this.apiService.delete(id)
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/modules/api/api.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common'
2 |
3 | import { ApiController } from './api.controller'
4 | import { ApiService } from './api.service'
5 |
6 | @Module({
7 | controllers: [ApiController],
8 | providers: [ApiService],
9 | exports: [ApiService],
10 | })
11 | export class ApiModule {}
12 |
--------------------------------------------------------------------------------
/src/modules/api/api.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common'
2 | import { Api, ApiType, Prisma } from '@prisma/client'
3 |
4 | import { PrismaService } from '@/modules/prisma/prisma.service'
5 | import { convertFlatDataToTree, createSingleFieldFilter, TreeNode } from '@/utils'
6 |
7 | import { CreateApiDto } from './dto/create-api.dto'
8 | import { UpdateApiDto } from './dto/update-api.dto'
9 |
10 | @Injectable()
11 | export class ApiService {
12 | constructor(private readonly prisma: PrismaService) {}
13 |
14 | async findApiTree() {
15 | const apis = await this.prisma.api.findMany()
16 | const apiTree = convertFlatDataToTree>(apis)
17 |
18 | return apiTree
19 | }
20 |
21 | findFlatApiTree() {
22 | return this.prisma.api.findMany()
23 | }
24 |
25 | findApiById(id: number) {
26 | return this.prisma.api.findUnique({
27 | where: { id },
28 | })
29 | }
30 |
31 | findPermissionApis() {
32 | const queryOptions: Prisma.ApiFindManyArgs = {
33 | where: {
34 | NOT: [
35 | { code: null },
36 | { code: '' },
37 | ],
38 | type: 'API',
39 | },
40 | }
41 |
42 | return this.prisma.api.findMany(queryOptions)
43 | }
44 |
45 | create(createApiDto: CreateApiDto) {
46 | return this.prisma.api.create({
47 | data: createApiDto,
48 | })
49 | }
50 |
51 | update(id: number, updateApiDto: UpdateApiDto) {
52 | return this.prisma.api.update({
53 | where: { id },
54 | data: updateApiDto,
55 | })
56 | }
57 |
58 | delete(id: number) {
59 | return this.prisma.api.delete({
60 | where: { id },
61 | })
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/modules/api/dto/create-api.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger'
2 | import { ApiMethod, ApiType } from '@prisma/client'
3 | import { IsNotEmpty, IsNumber, IsOptional, Min, ValidateIf } from 'class-validator'
4 | import { i18nValidationMessage } from 'nestjs-i18n'
5 |
6 | export class CreateApiDto {
7 | @ApiProperty({ required: false })
8 | @IsOptional()
9 | @IsNumber()
10 | parentId?: number
11 |
12 | @ApiProperty()
13 | @IsNotEmpty({
14 | message: i18nValidationMessage('validation.notEmpty', {
15 | field: 'type',
16 | }),
17 | })
18 | type: ApiType
19 |
20 | @ApiProperty()
21 | @IsNotEmpty({
22 | message: i18nValidationMessage('validation.notEmpty', {
23 | field: 'title',
24 | }),
25 | })
26 | title: string
27 |
28 | @ApiProperty({ required: false })
29 | @IsOptional()
30 | code?: string
31 |
32 | @ApiProperty({ required: false })
33 | @IsOptional()
34 | method?: ApiMethod
35 |
36 | @ApiProperty({ required: false })
37 | @ValidateIf(object => object.type === ApiType.API)
38 | @IsNotEmpty({
39 | message: i18nValidationMessage('validation.notEmpty', {
40 | field: 'path',
41 | }),
42 | })
43 | path?: string
44 |
45 | @ApiProperty({ required: false })
46 | @IsOptional()
47 | description?: string
48 |
49 | @ApiProperty({ required: false })
50 | @IsOptional()
51 | @IsNumber({}, {
52 | message: i18nValidationMessage('validation.invalid', {
53 | field: 'sort',
54 | }),
55 | })
56 | @Min(0)
57 | sort?: number = 0
58 | }
59 |
--------------------------------------------------------------------------------
/src/modules/api/dto/update-api.dto.ts:
--------------------------------------------------------------------------------
1 | import { PartialType } from '@nestjs/mapped-types'
2 |
3 | import { CreateApiDto } from './create-api.dto'
4 |
5 | export class UpdateApiDto extends PartialType(CreateApiDto) {}
6 |
--------------------------------------------------------------------------------
/src/modules/auth/auth.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common'
2 |
3 | import { UserModule } from '@/modules/user/user.module'
4 |
5 | import { JwtStrategy } from './strategy/jwt.strategy'
6 | import { LocalStrategy } from './strategy/local.strategy'
7 |
8 | @Module({
9 | imports: [UserModule],
10 | providers: [LocalStrategy, JwtStrategy],
11 | })
12 | export class AuthModule {}
13 |
--------------------------------------------------------------------------------
/src/modules/auth/strategy/jwt.strategy.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common'
2 | import { ConfigService } from '@nestjs/config'
3 | import { PassportStrategy } from '@nestjs/passport'
4 | import { ExtractJwt, Strategy } from 'passport-jwt'
5 |
6 | @Injectable()
7 | export class JwtStrategy extends PassportStrategy(Strategy) {
8 | constructor(private readonly configService: ConfigService) {
9 | super({
10 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
11 | ignoreExpiration: false,
12 | secretOrKey: configService.get('JWT_SECRET'),
13 | })
14 | }
15 |
16 | async validate(payload: any) {
17 | return {
18 | id: payload.id,
19 | username: payload.username,
20 | isSuperAdmin: payload.isSuperAdmin,
21 | menuPermissions: payload.menuPermissions,
22 | featurePermissions: payload.featurePermissions,
23 | apiPermissions: payload.apiPermissions,
24 | tokenType: payload.type,
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/modules/auth/strategy/local.strategy.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common'
2 | import { PassportStrategy } from '@nestjs/passport'
3 | import { Strategy } from 'passport-local'
4 |
5 | import { UserService } from '@/modules/user/user.service'
6 |
7 | @Injectable()
8 | export class LocalStrategy extends PassportStrategy(Strategy) {
9 | constructor(private readonly userService: UserService) {
10 | super()
11 | }
12 |
13 | async validate(username: string, password: string) {
14 | const user = await this.userService.validateUser(username, password)
15 |
16 | return user
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/modules/cache/cache.module.ts:
--------------------------------------------------------------------------------
1 | import { RedisModule } from '@nestjs-modules/ioredis'
2 | import { DynamicModule, Global, Module, Provider } from '@nestjs/common'
3 |
4 | import { CacheService } from './cache.service'
5 |
6 | export interface CacheConfig {
7 | type: 'single'
8 | url: string
9 | }
10 |
11 | export interface CacheModuleOptions {
12 | useFactory?: (...args: any[]) => Promise | CacheConfig
13 | inject?: any[]
14 | imports?: any[]
15 | extraProviders?: Provider[]
16 | }
17 |
18 | @Global()
19 | @Module({})
20 | export class CacheModule {
21 | static forRootAsync(cacheModuleOptions: CacheModuleOptions): DynamicModule {
22 | return {
23 | module: CacheModule,
24 | imports: [
25 | ...(cacheModuleOptions.imports || []),
26 | RedisModule.forRootAsync({
27 | useFactory: cacheModuleOptions.useFactory,
28 | inject: cacheModuleOptions.inject || [],
29 | }),
30 | ],
31 | providers: [CacheService, ...(cacheModuleOptions.extraProviders ?? [])],
32 | exports: [CacheService],
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/modules/cache/cache.service.ts:
--------------------------------------------------------------------------------
1 | import { InjectRedis } from '@nestjs-modules/ioredis'
2 | import { Injectable } from '@nestjs/common'
3 | import Redis from 'ioredis'
4 |
5 | @Injectable()
6 | export class CacheService {
7 | constructor(@InjectRedis() private readonly redis: Redis) {}
8 |
9 | async get(key: string) {
10 | return this.redis.get(key)
11 | }
12 |
13 | async set(key: string, value: string | number, ttl?: number) {
14 | await this.redis.set(key, value)
15 |
16 | if (ttl) {
17 | await this.redis.expire(key, ttl)
18 | }
19 | }
20 |
21 | async lpop(key: string) {
22 | const value = await this.redis.lpop(key)
23 | return value ? JSON.parse(value) : null
24 | }
25 |
26 | async rpush(key: string, value: any) {
27 | const values = Array.isArray(value)
28 | ? value.map(item => JSON.stringify(item))
29 | : [JSON.stringify(value)]
30 |
31 | return this.redis.rpush(key, ...values)
32 | }
33 |
34 | async llen(key: string) {
35 | return this.redis.llen(key)
36 | }
37 |
38 | async del(key: string) {
39 | return this.redis.del(key)
40 | }
41 |
42 | async keys(pattern: string) {
43 | return this.redis.keys(pattern)
44 | }
45 |
46 | /**
47 | * 删除所有指定前缀的键
48 | * @param prefix - 键前缀
49 | * @returns 删除的键的数量
50 | */
51 | async deleteByPrefix(namespace: string, prefix: string): Promise {
52 | const pattern = `${namespace}:${prefix}*`
53 | const keys = await this.redis.keys(pattern)
54 |
55 | if (keys.length === 0) {
56 | return 0
57 | }
58 |
59 | return this.redis.unlink(...keys)
60 | }
61 |
62 | /**
63 | * 获取所有缓存键
64 | * @param namespace - 缓存命名空间
65 | * @returns 缓存键列表
66 | */
67 | async getAllKeys(namespace: string): Promise {
68 | return await this.redis.keys(`${namespace}:*`)
69 | }
70 |
71 | /**
72 | * 获取缓存使用情况
73 | * @param namespace - 缓存命名空间
74 | * @returns 缓存使用情况
75 | */
76 | async getStats(namespace: string): Promise<{
77 | totalKeys: number
78 | memoryUsage: string
79 | }> {
80 | const info = await this.redis.info('memory')
81 | const keys = await this.getAllKeys(namespace)
82 |
83 | return {
84 | totalKeys: keys.length,
85 | memoryUsage: info.match(/used_memory_human:(\S+)/)?.[1] || '0B',
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/modules/menu/dto/create-menu.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger'
2 | import { MenuType } from '@prisma/client'
3 | import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, Min, ValidateIf } from 'class-validator'
4 | import { i18nValidationMessage } from 'nestjs-i18n'
5 |
6 | export class CreateMenuDto {
7 | @ApiProperty({ required: false })
8 | @IsOptional()
9 | @IsNumber()
10 | parentId?: number
11 |
12 | @ApiProperty()
13 | @IsNotEmpty({
14 | message: i18nValidationMessage('validation.notEmpty', {
15 | field: 'type',
16 | }),
17 | })
18 | type: MenuType
19 |
20 | @ApiProperty()
21 | @IsNotEmpty({
22 | message: i18nValidationMessage('validation.notEmpty', {
23 | field: 'title',
24 | }),
25 | })
26 | title: string
27 |
28 | @ApiProperty({ required: false })
29 | @IsOptional()
30 | icon?: string
31 |
32 | @ApiProperty({ required: false })
33 | @IsOptional()
34 | code?: string
35 |
36 | @ApiProperty({ required: false })
37 | @ValidateIf(object => object.type === MenuType.MENU)
38 | @IsNotEmpty({
39 | message: i18nValidationMessage('validation.notEmpty', {
40 | field: 'path',
41 | }),
42 | })
43 | path?: string
44 |
45 | @ApiProperty({ required: false })
46 | @IsOptional()
47 | i18nKey?: string
48 |
49 | @ApiProperty({ required: false })
50 | @IsOptional()
51 | description?: string
52 |
53 | @ApiProperty({ required: false })
54 | @IsOptional()
55 | @IsNumber({}, {
56 | message: i18nValidationMessage('validation.invalid', {
57 | field: 'sort',
58 | }),
59 | })
60 | @Min(0)
61 | sort?: number = 0
62 |
63 | @ApiProperty({ required: false })
64 | @ValidateIf(object => object.type === MenuType.MENU)
65 | @IsNotEmpty({
66 | message: i18nValidationMessage('validation.notEmpty', {
67 | field: 'isShow',
68 | }),
69 | })
70 | @IsBoolean()
71 | isShow?: boolean = true
72 | }
73 |
--------------------------------------------------------------------------------
/src/modules/menu/dto/update-menu.dto.ts:
--------------------------------------------------------------------------------
1 | import { PartialType } from '@nestjs/mapped-types'
2 |
3 | import { CreateMenuDto } from './create-menu.dto'
4 |
5 | export class UpdateMenuDto extends PartialType(CreateMenuDto) {}
6 |
--------------------------------------------------------------------------------
/src/modules/menu/menu.controller.ts:
--------------------------------------------------------------------------------
1 | import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, UseInterceptors, UsePipes } from '@nestjs/common'
2 | import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'
3 | import { MenuType } from '@prisma/client'
4 |
5 | import { MENU } from '@/constants/permissions'
6 | import { CacheInvalidate, CacheKey, CacheTTL, Permissions } from '@/decorators'
7 | import { CacheInterceptor } from '@/interceptors'
8 | import { updateValidationPipe } from '@/pipes'
9 |
10 | import { CreateMenuDto } from './dto/create-menu.dto'
11 | import { UpdateMenuDto } from './dto/update-menu.dto'
12 | import { MenuService } from './menu.service'
13 |
14 | @Controller('menu')
15 | @ApiTags('菜单管理模块')
16 | export class MenuController {
17 | private static readonly CACHE_TTL = 60 * 60 * 1
18 |
19 | constructor(private readonly menuService: MenuService) {}
20 |
21 | @Get()
22 | @Permissions(MENU.READ)
23 | @CacheKey('menu:tree')
24 | @CacheTTL(MenuController.CACHE_TTL)
25 | @UseInterceptors(CacheInterceptor)
26 | @ApiBearerAuth()
27 | @ApiOperation({ summary: '获取菜单' })
28 | @ApiOkResponse({
29 | description: '获取菜单树成功',
30 | })
31 | findMenuTree() {
32 | return this.menuService.findMenuTree()
33 | }
34 |
35 | @Get('/flat')
36 | @Permissions(MENU.READ)
37 | @CacheKey('menu:flat')
38 | @CacheTTL(MenuController.CACHE_TTL)
39 | @UseInterceptors(CacheInterceptor)
40 | @ApiBearerAuth()
41 | @ApiOperation({ summary: '获取菜单扁平化列表' })
42 | @ApiOkResponse({
43 | description: '获取菜单扁平化列表成功',
44 | })
45 | findFlatMenuTree() {
46 | return this.menuService.findFlatMenuTree()
47 | }
48 |
49 | @Get('/permission')
50 | @Permissions(MENU.READ)
51 | @CacheKey('menu:permission')
52 | @CacheTTL(MenuController.CACHE_TTL)
53 | @UseInterceptors(CacheInterceptor)
54 | @ApiBearerAuth()
55 | @ApiOperation({ summary: '获取有权限的菜单和按钮列表' })
56 | @ApiOkResponse({
57 | description: '获取有权限的菜单和按钮列表成功',
58 | })
59 | findPermissionMenus(@Query('type') type: MenuType) {
60 | return this.menuService.findPermissionMenus(type)
61 | }
62 |
63 | @Post()
64 | @Permissions(MENU.CREATE)
65 | @CacheInvalidate(['menu:tree', 'menu:flat', 'menu:permission'])
66 | @UseInterceptors(CacheInterceptor)
67 | @ApiBearerAuth()
68 | @ApiOperation({ summary: '创建菜单' })
69 | @ApiOkResponse({
70 | description: '创建菜单成功',
71 | })
72 | create(@Body() createMenuDto: CreateMenuDto) {
73 | return this.menuService.create(createMenuDto)
74 | }
75 |
76 | @Get(':id')
77 | @ApiBearerAuth()
78 | async getMenuById(@Param('id', ParseIntPipe) id: number) {
79 | return this.menuService.findMenuById(id)
80 | }
81 |
82 | @Put(':id')
83 | @Permissions(MENU.UPDATE)
84 | @CacheInvalidate(['menu:tree', 'menu:flat', 'menu:permission'])
85 | @UseInterceptors(CacheInterceptor)
86 | @UsePipes(updateValidationPipe)
87 | @ApiBearerAuth()
88 | @ApiOperation({ summary: '更新菜单' })
89 | @ApiOkResponse({
90 | description: '更新菜单成功',
91 | })
92 | update(@Param('id', ParseIntPipe) id: number, @Body() updateMenuDto: UpdateMenuDto) {
93 | return this.menuService.update(id, updateMenuDto)
94 | }
95 |
96 | @Delete(':id')
97 | @Permissions(MENU.DELETE)
98 | @CacheInvalidate(['menu:tree', 'menu:flat', 'menu:permission'])
99 | @UseInterceptors(CacheInterceptor)
100 | @ApiBearerAuth()
101 | @ApiOperation({ summary: '删除菜单' })
102 | @ApiOkResponse({
103 | description: '删除菜单成功',
104 | })
105 | async delete(@Param('id', ParseIntPipe) id: number) {
106 | return this.menuService.delete(id)
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/modules/menu/menu.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common'
2 |
3 | import { MenuController } from './menu.controller'
4 | import { MenuService } from './menu.service'
5 |
6 | @Module({
7 | controllers: [MenuController],
8 | providers: [MenuService],
9 | exports: [MenuService],
10 | })
11 | export class MenuModule {}
12 |
--------------------------------------------------------------------------------
/src/modules/menu/menu.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common'
2 | import { Menu, MenuType, Prisma } from '@prisma/client'
3 |
4 | import { PrismaService } from '@/modules/prisma/prisma.service'
5 | import { JwtUserData } from '@/types'
6 | import { convertFlatDataToTree, createSingleFieldFilter, TreeNode } from '@/utils'
7 |
8 | import { CreateMenuDto } from './dto/create-menu.dto'
9 | import { UpdateMenuDto } from './dto/update-menu.dto'
10 |
11 | @Injectable()
12 | export class MenuService {
13 | constructor(private readonly prisma: PrismaService) {}
14 |
15 | async findMenuTree() {
16 | const menus = await this.prisma.menu.findMany()
17 | const menuTree = convertFlatDataToTree>(menus)
18 |
19 | return menuTree
20 | }
21 |
22 | findFlatMenuTree() {
23 | return this.prisma.menu.findMany()
24 | }
25 |
26 | async findMenuById(id: number) {
27 | return this.prisma.menu.findUnique({
28 | where: { id },
29 | })
30 | }
31 |
32 | async findUserMenuTree(jwtUserData: JwtUserData) {
33 | const baseCondition = {
34 | type: {
35 | not: MenuType.FEATURE,
36 | },
37 | isShow: true,
38 | }
39 |
40 | const whereCondition = jwtUserData.isSuperAdmin
41 | ? baseCondition
42 | : {
43 | ...baseCondition,
44 | OR: [
45 | { code: null },
46 | { code: '' },
47 | { code: { in: jwtUserData.menuPermissions } },
48 | ],
49 | }
50 |
51 | const menus = await this.prisma.menu.findMany({
52 | where: whereCondition,
53 | })
54 |
55 | const menuTree = convertFlatDataToTree>(menus)
56 |
57 | return this.filterEmptyDirectories(menuTree)
58 | }
59 |
60 | create(createMenuDto: CreateMenuDto) {
61 | return this.prisma.menu.create({
62 | data: createMenuDto,
63 | })
64 | }
65 |
66 | update(id: number, updateMenuDto: UpdateMenuDto) {
67 | return this.prisma.menu.update({
68 | where: { id },
69 | data: updateMenuDto,
70 | })
71 | }
72 |
73 | delete(id: number) {
74 | return this.prisma.menu.delete({
75 | where: { id },
76 | })
77 | }
78 |
79 | findPermissionMenus(type: MenuType) {
80 | const queryOptions: Prisma.MenuFindManyArgs = {
81 | where: {
82 | NOT: [
83 | { code: null },
84 | { code: '' },
85 | ],
86 | ...createSingleFieldFilter({ field: 'type', value: type }),
87 | },
88 | }
89 |
90 | return this.prisma.menu.findMany(queryOptions)
91 | }
92 |
93 | filterEmptyDirectories(nodes: TreeNode