├── .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](https://img.shields.io/badge/license-MIT-green.svg)](./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 | ![light](https://p.ipic.vip/wbw4rc.png) 72 | 73 | ![light](https://p.ipic.vip/wtvurq.png) 74 | 75 | ![light](https://p.ipic.vip/ahfuw3.png) 76 | 77 | ![light](https://p.ipic.vip/417pqw.png) 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Pure Admin NestJS

3 | 中文 | English 4 |
5 | 6 | --- 7 | 8 | [![license](https://img.shields.io/badge/license-MIT-green.svg)](./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 | ![light](https://p.ipic.vip/wbw4rc.png) 54 | ![light](https://p.ipic.vip/wtvurq.png) 55 | ![light](https://p.ipic.vip/ahfuw3.png) 56 | ![light](https://p.ipic.vip/417pqw.png) 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[]) { 94 | return nodes.filter((node) => { 95 | if (node.children && node.children.length > 0) { 96 | node.children = this.filterEmptyDirectories(node.children) 97 | } 98 | 99 | // 保留非 DIRECTORY 类型的节点,或有子节点的 DIRECTORY 100 | return node.type !== MenuType.DIRECTORY 101 | || (node.children && node.children.length > 0) 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/modules/nodemailer/nodemailer.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Module, Provider } from '@nestjs/common' 2 | import { ConfigService } from '@nestjs/config' 3 | import { createTransport } from 'nodemailer' 4 | 5 | import { NodemailerService } from './nodemailer.service' 6 | 7 | interface NodemailerConfig { 8 | host: string 9 | port: number 10 | secure: boolean 11 | auth: { 12 | user: string 13 | pass: string 14 | } 15 | } 16 | 17 | export interface NodemailerModuleOptions { 18 | useFactory?: (...args: any[]) => Promise | NodemailerConfig 19 | inject?: any[] 20 | imports?: any[] 21 | extraProviders?: Provider[] 22 | } 23 | 24 | @Global() 25 | @Module({}) 26 | export class NodemailerModule { 27 | static forRootAsync(nodemailerModuleOptions: NodemailerModuleOptions): DynamicModule { 28 | const nodemailerTransporterProvider = { 29 | provide: 'NODEMAILER_TRANSPORTER', 30 | async useFactory(configService: ConfigService) { 31 | const options = await nodemailerModuleOptions.useFactory(configService) 32 | return createTransport(options) 33 | }, 34 | inject: nodemailerModuleOptions.inject || [], 35 | } 36 | 37 | return { 38 | module: NodemailerModule, 39 | imports: nodemailerModuleOptions.imports || [], 40 | providers: [ 41 | nodemailerTransporterProvider, 42 | NodemailerService, 43 | ...(nodemailerModuleOptions.extraProviders ?? []), 44 | ], 45 | exports: [NodemailerService], 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/modules/nodemailer/nodemailer.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common' 2 | import { ConfigService } from '@nestjs/config' 3 | import { Transporter } from 'nodemailer' 4 | 5 | @Injectable() 6 | export class NodemailerService { 7 | constructor( 8 | private readonly configService: ConfigService, 9 | @Inject('NODEMAILER_TRANSPORTER') private readonly transporter: Transporter, 10 | ) {} 11 | 12 | async sendMail({ to, subject, html }) { 13 | await this.transporter.sendMail({ 14 | from: { 15 | name: 'Pure Admin', 16 | address: this.configService.get('NODEMAILER_USER'), 17 | }, 18 | to, 19 | subject, 20 | html, 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/prisma/extended-client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | import { existsExtension } from './extensions/exists.extension' 4 | import { findManyAndCountExtension } from './extensions/find-many-count.extension' 5 | 6 | function extendClient(base: PrismaClient) { 7 | return base.$extends(existsExtension).$extends(findManyAndCountExtension) 8 | } 9 | 10 | class UntypedExtendedClient extends PrismaClient { 11 | constructor(options?: ConstructorParameters[0]) { 12 | super(options) 13 | 14 | return extendClient(this) as this 15 | } 16 | } 17 | 18 | const ExtendedPrismaClient = UntypedExtendedClient as unknown as new ( 19 | options?: ConstructorParameters[0] 20 | ) => ReturnType 21 | 22 | export { ExtendedPrismaClient } 23 | -------------------------------------------------------------------------------- /src/modules/prisma/extensions/exists.extension.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client' 2 | 3 | export const existsExtension = Prisma.defineExtension({ 4 | name: 'exists', 5 | model: { 6 | $allModels: { 7 | async exists>( 8 | this: TModel, 9 | args?: Prisma.Exact>, 10 | ): Promise { 11 | const context = Prisma.getExtensionContext(this) 12 | 13 | try { 14 | await (context as any).findUniqueOrThrow(args) 15 | return true 16 | } 17 | catch { 18 | return false 19 | } 20 | }, 21 | }, 22 | }, 23 | }) 24 | -------------------------------------------------------------------------------- /src/modules/prisma/extensions/find-many-count.extension.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client' 2 | 3 | export const findManyAndCountExtension = Prisma.defineExtension((client) => { 4 | return client.$extends({ 5 | name: 'findManyAndCount', 6 | model: { 7 | $allModels: { 8 | async findManyAndCount< 9 | TModel, 10 | TArgs extends Prisma.Args, 11 | TReturnType = Prisma.Result, 12 | >( 13 | this: TModel, 14 | args?: Prisma.Exact>, 15 | ): Promise<[TReturnType, number]> { 16 | const context = Prisma.getExtensionContext(this) 17 | 18 | const [records, totalRecords] = await client.$transaction([ 19 | (context as any).findMany(args), 20 | (context as any).count({ where: (args as any)?.where }), 21 | ]) 22 | 23 | return [records, totalRecords] 24 | }, 25 | }, 26 | }, 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/modules/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Module, Provider } from '@nestjs/common' 2 | import { ConfigService } from '@nestjs/config' 3 | 4 | import { PrismaService } from './prisma.service' 5 | 6 | interface PrismaConfig { 7 | url: string 8 | } 9 | 10 | export interface PrismaModuleOptions { 11 | useFactory?: (...args: any[]) => Promise | PrismaConfig 12 | inject?: any[] 13 | imports?: any[] 14 | extraProviders?: Provider[] 15 | } 16 | 17 | @Global() 18 | @Module({}) 19 | export class PrismaModule { 20 | static forRootAsync(prismaModuleOptions: PrismaModuleOptions): DynamicModule { 21 | const databaseUrlProvider = { 22 | provide: 'DATABASE_URL', 23 | async useFactory(configService: ConfigService) { 24 | const options = await prismaModuleOptions.useFactory(configService) 25 | return options.url 26 | }, 27 | inject: prismaModuleOptions.inject || [], 28 | } 29 | 30 | return { 31 | module: PrismaModule, 32 | imports: prismaModuleOptions.imports || [], 33 | providers: [ 34 | databaseUrlProvider, 35 | PrismaService, 36 | ...(prismaModuleOptions.extraProviders ?? []), 37 | ], 38 | exports: [PrismaService], 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common' 2 | import { Prisma } from '@prisma/client' 3 | 4 | import { ExtendedPrismaClient } from './extended-client' 5 | 6 | interface PageArgs { 7 | page: number 8 | pageSize: number 9 | } 10 | 11 | // https://github.com/prisma/prisma/issues/18628#issuecomment-1975271421 12 | @Injectable() 13 | export class PrismaService extends ExtendedPrismaClient implements OnModuleInit, OnModuleDestroy { 14 | constructor() { 15 | super({ 16 | // log: [ 17 | // { 18 | // emit: 'stdout', 19 | // level: 'query', 20 | // }, 21 | // ], 22 | }) 23 | } 24 | 25 | async onModuleInit() { 26 | await this.$connect() 27 | } 28 | 29 | async onModuleDestroy() { 30 | await this.$disconnect() 31 | } 32 | 33 | /** 34 | * 获取分页列表 35 | * @param model 模型 36 | * @param args 查询参数 37 | * @param pageArgs 分页参数 38 | * @returns 列表和总数 39 | */ 40 | async getPaginatedList< 41 | TModel, 42 | TArgs extends Prisma.Args, 43 | TReturnType extends Prisma.Result, 44 | >( 45 | model: TModel, 46 | args: Prisma.Exact>, 47 | pageArgs: PageArgs, 48 | ): Promise<[TReturnType, number]> { 49 | const { page, pageSize } = pageArgs 50 | 51 | const allArgs = { 52 | ...args, 53 | skip: (page - 1) * pageSize, 54 | take: pageSize, 55 | } 56 | 57 | const [list, total] = await (model as any).findManyAndCount(allArgs) 58 | 59 | return [list, total] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/modules/role/dto/create-role.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator' 3 | import { i18nValidationMessage } from 'nestjs-i18n' 4 | 5 | export class CreateRoleDto { 6 | @ApiProperty() 7 | @IsNotEmpty({ 8 | message: i18nValidationMessage('validation.notEmpty', { 9 | field: 'name', 10 | }), 11 | }) 12 | name: string 13 | 14 | @ApiProperty() 15 | @IsNotEmpty({ 16 | message: i18nValidationMessage('validation.notEmpty', { 17 | field: 'code', 18 | }), 19 | }) 20 | code: string 21 | 22 | @ApiProperty({ required: false }) 23 | @IsOptional() 24 | description?: string 25 | 26 | @ApiProperty({ required: false }) 27 | @IsOptional() 28 | @IsArray() 29 | @IsString({ each: true }) 30 | menuPermissions?: string[] 31 | 32 | @ApiProperty({ required: false }) 33 | @IsOptional() 34 | @IsArray() 35 | @IsString({ each: true }) 36 | featurePermissions?: string[] 37 | 38 | @ApiProperty({ required: false }) 39 | @IsOptional() 40 | @IsArray() 41 | @IsString({ each: true }) 42 | apiPermissions?: string[] 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/role/dto/role-list.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsOptional, IsString } from 'class-validator' 3 | 4 | import { PageDto } from '@/common/dto' 5 | 6 | export class RoleListDto extends PageDto { 7 | @ApiProperty({ required: false }) 8 | @IsOptional() 9 | @IsString() 10 | name?: string 11 | 12 | @ApiProperty({ required: false }) 13 | @IsOptional() 14 | @IsString() 15 | code?: string 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/role/dto/update-role.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types' 2 | 3 | import { CreateRoleDto } from './create-role.dto' 4 | 5 | export class UpdateRoleDto extends PartialType(CreateRoleDto) {} 6 | -------------------------------------------------------------------------------- /src/modules/role/role.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 | 4 | import { DeleteManyDto } from '@/common/dto' 5 | import { ROLE } from '@/constants/permissions' 6 | import { CacheInvalidate, CacheKey, CacheTTL, Permissions } from '@/decorators' 7 | import { CacheInterceptor } from '@/interceptors' 8 | import { updateValidationPipe } from '@/pipes' 9 | 10 | import { CreateRoleDto } from './dto/create-role.dto' 11 | import { RoleListDto } from './dto/role-list.dto' 12 | import { UpdateRoleDto } from './dto/update-role.dto' 13 | import { RoleService } from './role.service' 14 | 15 | @Controller('role') 16 | @ApiTags('角色管理模块') 17 | export class RoleController { 18 | private static readonly CACHE_TTL = 60 * 60 * 1 19 | 20 | constructor(private readonly roleService: RoleService) {} 21 | 22 | @Get() 23 | @Permissions(ROLE.READ) 24 | @CacheKey('role:list') 25 | @CacheTTL(RoleController.CACHE_TTL) 26 | @UseInterceptors(CacheInterceptor) 27 | @ApiBearerAuth() 28 | @ApiOperation({ summary: '获取角色列表' }) 29 | @ApiOkResponse({ description: '获取角色列表成功' }) 30 | async list(@Query() roleListDto: RoleListDto) { 31 | return this.roleService.findMany(roleListDto) 32 | } 33 | 34 | @Get('all') 35 | @Permissions(ROLE.READ) 36 | @CacheKey('role:all') 37 | @CacheTTL(RoleController.CACHE_TTL) 38 | @UseInterceptors(CacheInterceptor) 39 | @ApiBearerAuth() 40 | @ApiOperation({ summary: '获取所有角色' }) 41 | @ApiOkResponse({ description: '获取所有角色成功' }) 42 | async all() { 43 | return this.roleService.findAll() 44 | } 45 | 46 | @Post() 47 | @Permissions(ROLE.CREATE) 48 | @CacheInvalidate(['role:list', 'role:all']) 49 | @UseInterceptors(CacheInterceptor) 50 | @ApiBearerAuth() 51 | @ApiOperation({ summary: '创建角色' }) 52 | @ApiOkResponse({ description: '创建角色成功' }) 53 | async create(@Body() createRoleDto: CreateRoleDto) { 54 | return this.roleService.create(createRoleDto) 55 | } 56 | 57 | @Delete() 58 | @Permissions(ROLE.DELETE) 59 | @CacheInvalidate(['role:list', 'role:all']) 60 | @UseInterceptors(CacheInterceptor) 61 | @ApiBearerAuth() 62 | @ApiOperation({ summary: '批量删除角色' }) 63 | @ApiOkResponse({ description: '批量删除角色成功' }) 64 | async deleteMany(@Body() deleteManyDto: DeleteManyDto) { 65 | return this.roleService.deleteMany(deleteManyDto.ids) 66 | } 67 | 68 | @Get(':id') 69 | @ApiBearerAuth() 70 | @ApiOperation({ summary: '获取角色详情' }) 71 | @ApiOkResponse({ description: '获取角色详情成功' }) 72 | async findOne(@Param('id', ParseIntPipe) id: number) { 73 | return this.roleService.findOne(id) 74 | } 75 | 76 | @Put(':id') 77 | @Permissions(ROLE.UPDATE) 78 | @CacheInvalidate(['role:list', 'role:all']) 79 | @UseInterceptors(CacheInterceptor) 80 | @UsePipes(updateValidationPipe) 81 | @ApiBearerAuth() 82 | @ApiOperation({ summary: '更新角色' }) 83 | @ApiOkResponse({ description: '更新角色成功' }) 84 | async update(@Param('id', ParseIntPipe) id: number, @Body() updateRoleDto: UpdateRoleDto) { 85 | return this.roleService.update(id, updateRoleDto) 86 | } 87 | 88 | @Delete(':id') 89 | @Permissions(ROLE.DELETE) 90 | @CacheInvalidate(['role:list', 'role:all']) 91 | @UseInterceptors(CacheInterceptor) 92 | @ApiBearerAuth() 93 | @ApiOperation({ summary: '删除角色' }) 94 | @ApiOkResponse({ description: '删除角色成功' }) 95 | async delete(@Param('id', ParseIntPipe) id: number) { 96 | return this.roleService.delete(id) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/modules/role/role.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { RoleController } from './role.controller' 4 | import { RoleService } from './role.service' 5 | 6 | @Module({ 7 | controllers: [RoleController], 8 | providers: [RoleService], 9 | exports: [RoleService], 10 | }) 11 | export class RoleModule {} 12 | -------------------------------------------------------------------------------- /src/modules/role/role.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { Prisma } from '@prisma/client' 3 | 4 | import { PrismaService } from '@/modules/prisma/prisma.service' 5 | import { createPaginationParams, createSingleFieldFilter } from '@/utils' 6 | 7 | import { CreateRoleDto } from './dto/create-role.dto' 8 | import { RoleListDto } from './dto/role-list.dto' 9 | import { UpdateRoleDto } from './dto/update-role.dto' 10 | 11 | @Injectable() 12 | export class RoleService { 13 | constructor(private readonly prisma: PrismaService) {} 14 | 15 | async findMany(roleListDto: RoleListDto) { 16 | const { page, pageSize, name, code } = roleListDto 17 | 18 | const queryOptions: Prisma.RoleFindManyArgs = { 19 | where: { 20 | ...createSingleFieldFilter({ field: 'name', value: name, isFuzzy: true }), 21 | ...createSingleFieldFilter({ field: 'code', value: code, isFuzzy: true }), 22 | }, 23 | } 24 | 25 | const [list, total] = await this.prisma.getPaginatedList( 26 | this.prisma.role, 27 | queryOptions, 28 | createPaginationParams(page, pageSize), 29 | ) 30 | 31 | return { list, total } 32 | } 33 | 34 | async findAll() { 35 | return this.prisma.role.findMany() 36 | } 37 | 38 | async findOne(id: number) { 39 | return this.prisma.role.findUnique({ 40 | where: { id }, 41 | }) 42 | } 43 | 44 | create(createRoleDto: CreateRoleDto) { 45 | return this.prisma.role.create({ 46 | data: createRoleDto, 47 | }) 48 | } 49 | 50 | update(id: number, updateRoleDto: UpdateRoleDto) { 51 | return this.prisma.role.update({ 52 | where: { id }, 53 | data: updateRoleDto, 54 | }) 55 | } 56 | 57 | delete(id: number) { 58 | return this.prisma.role.delete({ 59 | where: { id }, 60 | }) 61 | } 62 | 63 | deleteMany(ids: number[]) { 64 | return this.prisma.role.deleteMany({ 65 | where: { id: { in: ids } }, 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/modules/user/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsArray, IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional } from 'class-validator' 3 | import { i18nValidationMessage } from 'nestjs-i18n' 4 | 5 | export class CreateUserDto { 6 | @ApiProperty() 7 | @IsNotEmpty({ 8 | message: i18nValidationMessage('validation.notEmpty', { 9 | field: 'username', 10 | }), 11 | }) 12 | username: string 13 | 14 | @ApiProperty() 15 | @IsNotEmpty({ 16 | message: i18nValidationMessage('validation.notEmpty', { 17 | field: 'password', 18 | }), 19 | }) 20 | password: string 21 | 22 | @ApiProperty() 23 | @IsOptional() 24 | nickName?: string 25 | 26 | @ApiProperty() 27 | @IsOptional() 28 | headPic?: string 29 | 30 | @IsOptional() 31 | @IsEmail( 32 | {}, 33 | { 34 | message: i18nValidationMessage('validation.invalid', { 35 | field: 'email', 36 | }), 37 | }, 38 | ) 39 | @ApiProperty() 40 | email?: string 41 | 42 | @ApiProperty() 43 | @IsOptional() 44 | phone?: string 45 | 46 | @ApiProperty() 47 | @IsOptional() 48 | @IsBoolean() 49 | isFrozen?: boolean = false 50 | 51 | @ApiProperty() 52 | @IsOptional() 53 | @IsArray() 54 | @IsInt({ 55 | each: true, 56 | }) 57 | roles?: number[] 58 | } 59 | -------------------------------------------------------------------------------- /src/modules/user/dto/login-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsNotEmpty } from 'class-validator' 3 | import { i18nValidationMessage } from 'nestjs-i18n' 4 | 5 | export class LoginUserDto { 6 | @IsNotEmpty({ 7 | message: i18nValidationMessage('validation.notEmpty', { 8 | field: 'username', 9 | }), 10 | }) 11 | @ApiProperty() 12 | username: string 13 | 14 | @IsNotEmpty({ 15 | message: i18nValidationMessage('validation.notEmpty', { 16 | field: 'password', 17 | }), 18 | }) 19 | @ApiProperty() 20 | password: string 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/user/dto/update-user-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsNotEmpty, MinLength } from 'class-validator' 3 | import { i18nValidationMessage } from 'nestjs-i18n' 4 | 5 | export class UpdateUserPasswordDto { 6 | @IsNotEmpty({ 7 | message: i18nValidationMessage('validation.notEmpty', { 8 | field: 'oldPassword', 9 | }), 10 | }) 11 | @ApiProperty() 12 | oldPassword: string 13 | 14 | @IsNotEmpty({ 15 | message: i18nValidationMessage('validation.notEmpty', { 16 | field: 'newPassword', 17 | }), 18 | }) 19 | @MinLength(6, { 20 | message: i18nValidationMessage('validation.minLength', { 21 | field: 'newPassword', 22 | min: 6, 23 | }), 24 | }) 25 | @ApiProperty() 26 | newPassword: string 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/user/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { OmitType } from '@nestjs/mapped-types' 2 | 3 | import { CreateUserDto } from './create-user.dto' 4 | 5 | export class UpdateUserDto extends OmitType(CreateUserDto, ['password']) {} 6 | -------------------------------------------------------------------------------- /src/modules/user/dto/user-list.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsNumber, IsOptional, IsString, Min } from 'class-validator' 3 | 4 | import { PageDto } from '@/common/dto' 5 | 6 | export class UserListDto extends PageDto { 7 | @ApiProperty({ required: false }) 8 | @IsOptional() 9 | @IsString() 10 | username?: string 11 | 12 | @ApiProperty({ required: false }) 13 | @IsOptional() 14 | @IsString() 15 | nickName?: string 16 | 17 | @ApiProperty({ required: false }) 18 | @IsOptional() 19 | @IsString() 20 | email?: string 21 | 22 | @ApiProperty({ required: false }) 23 | @IsOptional() 24 | @IsString() 25 | phone?: string 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpStatus, 7 | Param, 8 | ParseIntPipe, 9 | Post, 10 | Put, 11 | Query, 12 | Req, 13 | UnauthorizedException, 14 | UseGuards, 15 | UseInterceptors, 16 | UsePipes, 17 | } from '@nestjs/common' 18 | import { ConfigService } from '@nestjs/config' 19 | import { JwtService } from '@nestjs/jwt' 20 | import { AuthGuard } from '@nestjs/passport' 21 | import { ApiBearerAuth, ApiBody, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger' 22 | import { FastifyRequest } from 'fastify' 23 | 24 | import { DeleteManyDto } from '@/common/dto' 25 | import { USER } from '@/constants/permissions' 26 | import { 27 | CacheInvalidate, 28 | CacheInvalidateUser, 29 | CacheKey, 30 | CacheTTL, 31 | CacheUserKey, 32 | Permissions, 33 | Public, 34 | Refresh, 35 | UserInfo, 36 | } from '@/decorators' 37 | import { CacheInterceptor } from '@/interceptors' 38 | import { updateValidationPipe } from '@/pipes' 39 | import { JwtUserData } from '@/types' 40 | 41 | import { CreateUserDto } from './dto/create-user.dto' 42 | import { UpdateUserPasswordDto } from './dto/update-user-password.dto' 43 | import { UpdateUserDto } from './dto/update-user.dto' 44 | import { UserListDto } from './dto/user-list.dto' 45 | import { UserService } from './user.service' 46 | 47 | @Controller('user') 48 | @ApiTags('用户管理模块') 49 | export class UserController { 50 | private static readonly CACHE_TTL = 60 * 60 * 1 51 | 52 | constructor( 53 | private readonly userService: UserService, 54 | private readonly configService: ConfigService, 55 | private readonly jwtService: JwtService, 56 | ) {} 57 | 58 | @Public() 59 | @Post('login') 60 | @UseGuards(AuthGuard('local')) 61 | async login(@Req() req: FastifyRequest) { 62 | const accessToken = this.jwtService.sign( 63 | { 64 | ...req.user, 65 | type: 'access', 66 | }, 67 | { 68 | expiresIn: this.configService.get('JWT_ACCESS_EXPIRES') || '30m', 69 | }, 70 | ) 71 | 72 | const refreshToken = this.jwtService.sign( 73 | { 74 | ...req.user, 75 | type: 'refresh', 76 | }, 77 | { 78 | expiresIn: this.configService.get('JWT_REFRESH_EXPIRES') || '7d', 79 | }, 80 | ) 81 | 82 | return { 83 | accessToken, 84 | refreshToken, 85 | } 86 | } 87 | 88 | @Refresh() 89 | @Get('refresh') 90 | @ApiQuery({ 91 | name: 'refreshToken', 92 | type: String, 93 | description: '刷新 token', 94 | required: true, 95 | example: '', 96 | }) 97 | @ApiResponse({ 98 | status: HttpStatus.UNAUTHORIZED, 99 | description: 'token 已失效,请重新登录', 100 | }) 101 | @ApiResponse({ 102 | status: HttpStatus.OK, 103 | description: '刷新成功', 104 | }) 105 | async refresh(@Query('refreshToken') refreshToken: string) { 106 | try { 107 | const data = this.jwtService.verify(refreshToken) 108 | 109 | const payload = await this.userService.getJwtPayloadData(data.id) 110 | 111 | const signedAccessToken = this.jwtService.sign( 112 | { 113 | ...payload, 114 | type: 'access', 115 | }, 116 | { 117 | expiresIn: this.configService.get('JWT_ACCESS_EXPIRES') || '30m', 118 | }, 119 | ) 120 | 121 | const signedRefreshToken = this.jwtService.sign( 122 | { 123 | ...payload, 124 | type: 'refresh', 125 | }, 126 | { 127 | expiresIn: this.configService.get('JWT_REFRESH_EXPIRES') || '7d', 128 | }, 129 | ) 130 | 131 | return { 132 | accessToken: signedAccessToken, 133 | refreshToken: signedRefreshToken, 134 | } 135 | } 136 | catch { 137 | throw new UnauthorizedException('token 已失效,请重新登录') 138 | } 139 | } 140 | 141 | @Get('info') 142 | @CacheUserKey('user:info') 143 | @CacheTTL(UserController.CACHE_TTL) 144 | @UseInterceptors(CacheInterceptor) 145 | @ApiBearerAuth() 146 | @ApiResponse({ 147 | status: HttpStatus.OK, 148 | description: 'success', 149 | }) 150 | async info(@UserInfo() jwtUserData: JwtUserData) { 151 | return this.userService.getUserInfo(jwtUserData) 152 | } 153 | 154 | @Post('password') 155 | @ApiBearerAuth() 156 | @ApiBody({ 157 | type: UpdateUserPasswordDto, 158 | }) 159 | async updatePassword(@UserInfo('id') id: number, @Body() updateUserPasswordDto: UpdateUserPasswordDto) { 160 | return this.userService.updatePassword(id, updateUserPasswordDto) 161 | } 162 | 163 | @Get() 164 | @Permissions(USER.READ) 165 | @CacheKey('user:list') 166 | @CacheTTL(UserController.CACHE_TTL) 167 | @UseInterceptors(CacheInterceptor) 168 | @ApiBearerAuth() 169 | async list(@Query() userListDto: UserListDto) { 170 | return this.userService.findMany(userListDto) 171 | } 172 | 173 | @Post() 174 | @Permissions(USER.CREATE) 175 | @CacheInvalidate('user:list') 176 | @UseInterceptors(CacheInterceptor) 177 | @ApiBearerAuth() 178 | @ApiBody({ 179 | type: CreateUserDto, 180 | }) 181 | async create(@Body() createUserDto: CreateUserDto) { 182 | return this.userService.create(createUserDto) 183 | } 184 | 185 | @Delete() 186 | @Permissions(USER.DELETE) 187 | @CacheInvalidate('user:list') 188 | @UseInterceptors(CacheInterceptor) 189 | @ApiBearerAuth() 190 | async deleteMany(@Body() deleteManyDto: DeleteManyDto) { 191 | return this.userService.deleteMany(deleteManyDto.ids) 192 | } 193 | 194 | @Get(':id') 195 | @ApiBearerAuth() 196 | async getUserById(@Param('id', ParseIntPipe) id: number) { 197 | return this.userService.findOne(id) 198 | } 199 | 200 | @Put(':id') 201 | @Permissions(USER.UPDATE) 202 | @CacheInvalidate('user:list') 203 | @CacheInvalidateUser('user:info', req => req.params.id) 204 | @UseInterceptors(CacheInterceptor) 205 | @UsePipes(updateValidationPipe) 206 | @ApiBearerAuth() 207 | @ApiBody({ 208 | type: UpdateUserDto, 209 | }) 210 | async update(@Param('id', ParseIntPipe) id: number, @Body() updateUserDto: UpdateUserDto) { 211 | return this.userService.update(id, updateUserDto) 212 | } 213 | 214 | @Delete(':id') 215 | @Permissions(USER.DELETE) 216 | @CacheInvalidate('user:list') 217 | @CacheInvalidateUser('user:info', req => req.params.id) 218 | @UseInterceptors(CacheInterceptor) 219 | @ApiBearerAuth() 220 | async delete(@Param('id', ParseIntPipe) id: number) { 221 | return this.userService.delete(id) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { ApiModule } from '@/modules/api/api.module' 4 | import { MenuModule } from '@/modules/menu/menu.module' 5 | 6 | import { UserController } from './user.controller' 7 | import { UserService } from './user.service' 8 | 9 | @Module({ 10 | imports: [MenuModule, ApiModule], 11 | controllers: [UserController], 12 | providers: [UserService], 13 | exports: [UserService], 14 | }) 15 | export class UserModule {} 16 | -------------------------------------------------------------------------------- /src/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException, HttpException, HttpStatus, Inject, Injectable, NotFoundException } from '@nestjs/common' 2 | import { Prisma, Role } from '@prisma/client' 3 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston' 4 | import { I18nService } from 'nestjs-i18n' 5 | import { Logger } from 'winston' 6 | 7 | import { MenuService } from '@/modules/menu/menu.service' 8 | import { PrismaService } from '@/modules/prisma/prisma.service' 9 | import { JwtUserData } from '@/types' 10 | import { createPaginationParams, createSingleFieldFilter, hashPassword, verifyPassword } from '@/utils' 11 | 12 | import { CreateUserDto } from './dto/create-user.dto' 13 | import { UpdateUserPasswordDto } from './dto/update-user-password.dto' 14 | import { UpdateUserDto } from './dto/update-user.dto' 15 | import { UserListDto } from './dto/user-list.dto' 16 | 17 | @Injectable() 18 | export class UserService { 19 | constructor( 20 | private readonly prisma: PrismaService, 21 | private readonly i18n: I18nService, 22 | private readonly menuService: MenuService, 23 | @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, 24 | ) {} 25 | 26 | async create(createUserDto: CreateUserDto) { 27 | const { username, password, roles, ...rest } = createUserDto 28 | 29 | const isExist = await this.prisma.user.exists({ 30 | where: { username }, 31 | }) 32 | 33 | if (isExist) { 34 | throw new HttpException({ message: this.i18n.t('common.userExists') }, HttpStatus.CONFLICT) 35 | } 36 | 37 | const user = await this.prisma.user.create({ 38 | data: { 39 | ...rest, 40 | username, 41 | password: await hashPassword(password), 42 | roles: { 43 | create: roles.map(roleId => ({ roleId })), 44 | }, 45 | }, 46 | }) 47 | 48 | return user 49 | } 50 | 51 | async update(id: number, updateUserDto: UpdateUserDto) { 52 | const { roles, ...rest } = updateUserDto 53 | 54 | return this.prisma.user.update({ 55 | where: { 56 | id, 57 | }, 58 | data: { 59 | ...rest, 60 | roles: { 61 | deleteMany: {}, 62 | create: roles.map(roleId => ({ roleId })), 63 | }, 64 | }, 65 | }) 66 | } 67 | 68 | async updatePassword(id: number, updateUserPasswordDto: UpdateUserPasswordDto) { 69 | const { oldPassword, newPassword } = updateUserPasswordDto 70 | 71 | const user = await this.prisma.user.findUnique({ 72 | where: { id }, 73 | }) 74 | 75 | if (!user) { 76 | throw new NotFoundException() 77 | } 78 | 79 | if (!(await verifyPassword(oldPassword, user.password))) { 80 | throw new ForbiddenException({ message: this.i18n.t('common.oldPasswordError') }) 81 | } 82 | 83 | await this.prisma.user.update({ 84 | where: { id }, 85 | data: { password: await hashPassword(newPassword) }, 86 | }) 87 | 88 | return { message: this.i18n.t('common.passwordUpdated') } 89 | } 90 | 91 | async delete(id: number) { 92 | return this.prisma.user.delete({ 93 | where: { 94 | id, 95 | }, 96 | }) 97 | } 98 | 99 | async deleteMany(ids: number[]) { 100 | return this.prisma.user.deleteMany({ 101 | where: { 102 | id: { in: ids }, 103 | }, 104 | }) 105 | } 106 | 107 | async findMany(userListDto: UserListDto) { 108 | const { page, pageSize, username, nickName, email, phone } = userListDto 109 | 110 | const queryOptions: Prisma.UserFindManyArgs = { 111 | where: { 112 | ...createSingleFieldFilter({ field: 'username', value: username, isFuzzy: true }), 113 | ...createSingleFieldFilter({ field: 'nickName', value: nickName, isFuzzy: true }), 114 | ...createSingleFieldFilter({ field: 'email', value: email, isFuzzy: true }), 115 | ...createSingleFieldFilter({ field: 'phone', value: phone, isFuzzy: true }), 116 | isSuperAdmin: false, 117 | }, 118 | select: { 119 | id: true, 120 | username: true, 121 | nickName: true, 122 | email: true, 123 | phone: true, 124 | isFrozen: true, 125 | headPic: true, 126 | createTime: true, 127 | updateTime: true, 128 | }, 129 | } 130 | 131 | const [list, total] = await this.prisma.getPaginatedList( 132 | this.prisma.user, 133 | queryOptions, 134 | createPaginationParams(page, pageSize), 135 | ) 136 | 137 | return { list, total } 138 | } 139 | 140 | async findOne(id: number) { 141 | const user = await this.prisma.user.findUnique({ 142 | where: { id }, 143 | select: { 144 | id: true, 145 | username: true, 146 | nickName: true, 147 | email: true, 148 | phone: true, 149 | headPic: true, 150 | isFrozen: true, 151 | roles: { 152 | select: { 153 | role: true, 154 | }, 155 | }, 156 | }, 157 | }) 158 | 159 | if (!user) { 160 | throw new NotFoundException() 161 | } 162 | 163 | const { roles, ...userData } = user 164 | const flattenedRoles = roles.map(item => item.role.id) 165 | 166 | return { 167 | ...userData, 168 | roles: flattenedRoles, 169 | } 170 | } 171 | 172 | findUser(args: Prisma.UserFindUniqueArgs) { 173 | return this.prisma.user.findUnique(args) 174 | } 175 | 176 | async validateUser(username: string, rawPassword: string) { 177 | const user = await this.prisma.user.findUnique({ 178 | where: { username }, 179 | select: { 180 | id: true, 181 | username: true, 182 | password: true, 183 | isSuperAdmin: true, 184 | isFrozen: true, 185 | roles: { 186 | select: { 187 | role: true, 188 | }, 189 | }, 190 | }, 191 | }) 192 | 193 | if (!user) { 194 | throw new ForbiddenException({ message: this.i18n.t('common.usernameOrPasswordError') }) 195 | } 196 | 197 | const { password, isFrozen, roles, ...userData } = user 198 | 199 | if (!(await verifyPassword(rawPassword, password))) { 200 | throw new ForbiddenException({ message: this.i18n.t('common.usernameOrPasswordError') }) 201 | } 202 | 203 | if (isFrozen) { 204 | throw new ForbiddenException({ message: this.i18n.t('common.userFrozen') }) 205 | } 206 | 207 | const { menuPermissions, featurePermissions } = await this.getUserPermissions(roles) 208 | 209 | return { 210 | ...userData, 211 | menuPermissions, 212 | featurePermissions, 213 | } 214 | } 215 | 216 | async getJwtPayloadData(id: number) { 217 | const user = await this.prisma.user.findUnique({ 218 | where: { id }, 219 | select: { 220 | id: true, 221 | username: true, 222 | isSuperAdmin: true, 223 | roles: { 224 | select: { 225 | role: true, 226 | }, 227 | }, 228 | }, 229 | }) 230 | 231 | if (!user) { 232 | throw new NotFoundException() 233 | } 234 | 235 | const { roles, ...userData } = user 236 | 237 | const { menuPermissions, featurePermissions, apiPermissions } = await this.getUserPermissions(roles) 238 | 239 | return { 240 | ...userData, 241 | menuPermissions, 242 | featurePermissions, 243 | apiPermissions, 244 | } 245 | } 246 | 247 | async getUserInfo(jwtUserData: JwtUserData) { 248 | const user = await this.findUser({ 249 | where: { id: jwtUserData.id }, 250 | select: { 251 | id: true, 252 | username: true, 253 | nickName: true, 254 | headPic: true, 255 | isSuperAdmin: true, 256 | }, 257 | }) 258 | 259 | const userMenu = await this.menuService.findUserMenuTree(jwtUserData) 260 | let menuPermissions: string[] = [] 261 | let featurePermissions: string[] = [] 262 | let apiPermissions: string[] = [] 263 | 264 | if (user.isSuperAdmin) { 265 | menuPermissions = ['*'] 266 | featurePermissions = ['*'] 267 | apiPermissions = ['*'] 268 | } 269 | else { 270 | menuPermissions = jwtUserData.menuPermissions 271 | featurePermissions = jwtUserData.featurePermissions 272 | apiPermissions = jwtUserData.apiPermissions 273 | } 274 | 275 | return { 276 | ...user, 277 | menus: userMenu, 278 | menuPermissions, 279 | featurePermissions, 280 | apiPermissions, 281 | } 282 | } 283 | 284 | async freezeUserById(id: number) { 285 | const user = await this.prisma.user.findUnique({ 286 | where: { 287 | id, 288 | }, 289 | }) 290 | if (!user) { 291 | throw new HttpException({ message: this.i18n.t('common.userNotFound') }, HttpStatus.NOT_FOUND) 292 | } 293 | 294 | await this.prisma.user.update({ 295 | where: { 296 | id, 297 | }, 298 | data: { 299 | isFrozen: true, 300 | }, 301 | }) 302 | } 303 | 304 | async getUserPermissions(roles: { role: Role }[]) { 305 | const menuSet = new Set() 306 | const featureSet = new Set() 307 | const apiSet = new Set() 308 | roles.forEach((item) => { 309 | item.role.menuPermissions.forEach(p => menuSet.add(p)) 310 | item.role.featurePermissions.forEach(p => featureSet.add(p)) 311 | item.role.apiPermissions.forEach(p => apiSet.add(p)) 312 | }) 313 | 314 | const menuPermissions = Array.from(menuSet) 315 | const featurePermissions = Array.from(featureSet) 316 | const apiPermissions = Array.from(apiSet) 317 | 318 | return { menuPermissions, featurePermissions, apiPermissions } 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /src/pipes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './update-validation.pipe' 2 | -------------------------------------------------------------------------------- /src/pipes/update-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { I18nValidationPipe } from 'nestjs-i18n' 2 | 3 | export const updateValidationPipe = new I18nValidationPipe({ 4 | transform: true, 5 | whitelist: true, 6 | validateCustomDecorators: true, 7 | skipMissingProperties: false, 8 | stopAtFirstError: true, 9 | disableErrorMessages: true, 10 | }) 11 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | declare module 'fastify' { 2 | interface FastifyRequest { 3 | user: JwtUserData 4 | } 5 | } 6 | 7 | export type TokenType = 'access' | 'refresh' 8 | 9 | export interface JwtUserData { 10 | id: number 11 | username: string 12 | isSuperAdmin: boolean 13 | menuPermissions: string[] 14 | featurePermissions: string[] 15 | apiPermissions: string[] 16 | tokenType: TokenType 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import crypto from 'node:crypto' 3 | import fs from 'node:fs' 4 | import path from 'node:path' 5 | import { promisify } from 'node:util' 6 | import winston from 'winston' 7 | 8 | const scryptAsync = promisify(crypto.scrypt) 9 | 10 | /** 11 | * 延迟执行 12 | * @param ms 延迟时间(毫秒) 13 | * @returns 延迟执行的 Promise 14 | */ 15 | export function delay(ms: number): Promise { 16 | // const { promise, resolve } = Promise.withResolvers() 17 | // setTimeout(resolve, ms) 18 | // return promise 19 | return new Promise((resolve) => { 20 | setTimeout(resolve, ms) 21 | }) 22 | } 23 | 24 | /** 25 | * 哈希密码 26 | * @param password 密码 27 | * @returns 哈希值 28 | */ 29 | export async function hashPassword(password: string): Promise { 30 | const salt = crypto.randomBytes(16).toString('hex') 31 | const derivedKey = await scryptAsync(password, salt, 64) as Buffer 32 | return `${salt}:${derivedKey.toString('hex')}` 33 | } 34 | 35 | /** 36 | * 验证密码 37 | * @param password 密码 38 | * @param hash 哈希值 39 | * @returns 如果密码匹配,则返回 true,否则返回 false 40 | */ 41 | export async function verifyPassword(password: string, hash: string): Promise { 42 | const [salt, key] = hash.split(':') 43 | const derivedKey = await scryptAsync(password, salt, 64) as Buffer 44 | return key === derivedKey.toString('hex') 45 | } 46 | 47 | /** 48 | * 获取环境文件路径 49 | * @param dest 目标路径 50 | * @returns 环境文件路径 51 | */ 52 | export function getEnvPath(dest: string): string { 53 | const env: string | undefined = process.env.NODE_ENV 54 | 55 | const filename: string = env ? `.env.${env}` : '.env' 56 | 57 | // 尝试多个可能的路径 58 | const possiblePaths = [ 59 | path.resolve(process.cwd(), filename), // 从当前工作目录查找 60 | path.resolve(process.cwd(), 'dist', filename), // 从 dist 目录查找 61 | path.resolve(__dirname, '..', filename), // 从当前文件所在目录的上一级查找 62 | path.resolve(dest, filename), // 从指定目录查找 63 | ] 64 | 65 | // 查找第一个存在的文件路径 66 | const existingPath = possiblePaths.find(filePath => fs.existsSync(filePath)) 67 | 68 | if (!existingPath) { 69 | console.warn('没有找到 env 文件') 70 | return path.resolve(process.cwd(), filename) // 返回默认路径 71 | } 72 | 73 | return existingPath 74 | } 75 | 76 | /** 77 | * 将 kebab-case 字符串转换为 camelCase 78 | * @param str kebab-case 格式的字符串 79 | * @returns camelCase 格式的字符串 80 | */ 81 | export function kebabToCamelCase(str: string | null | undefined): string { 82 | if (!str) { 83 | return '' 84 | } 85 | 86 | return str 87 | .split('-') 88 | .map((word, index) => 89 | index === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), 90 | ) 91 | .join('') 92 | } 93 | 94 | /** 95 | * 创建日志过滤器 96 | * @param level 日志级别 97 | * @returns 日志过滤器 98 | */ 99 | export function createLevelFilter(level: string) { 100 | return winston.format((info) => { 101 | return info.level === level ? info : false 102 | })() 103 | } 104 | 105 | /** 106 | * 创建日志选项 107 | * @param type 日志类型 108 | * @param logDir 日志目录 109 | * @returns 日志选项 110 | */ 111 | export function createLoggerOptions(type: string, logDir: string) { 112 | return { 113 | level: type, 114 | dirname: path.join(logDir, type), 115 | filename: `[${process.env.NEST_SERVER_PORT}]-[${type}]-%DATE%.log`, 116 | datePattern: 'YYYY-MM-DD', 117 | zippedArchive: false, // 不压缩 118 | maxSize: '1m', // 单个文件最大1M 119 | maxFiles: '14d', // 保留14天的日志 120 | } 121 | } 122 | 123 | /** 124 | * 默认日志格式 125 | * @param hasFilter 是否需要过滤日志 126 | * @param level 日志级别 127 | * @returns 日志格式 128 | */ 129 | export function defaultLogFormat(hasFilter = false, level: string = 'http') { 130 | const commonFormats = [ 131 | winston.format.timestamp({ 132 | format: () => dayjs().format('YYYY-MM-DD HH:mm:ss'), 133 | }), 134 | winston.format.printf((info: any) => { 135 | return `[${info.timestamp}] [${info.level}]: ${info.message}` 136 | }), 137 | ] 138 | 139 | // 如果需要过滤日志,则添加过滤器 140 | return winston.format.combine(...(hasFilter ? [createLevelFilter(level)] : []), ...commonFormats) 141 | } 142 | 143 | /** 144 | * 判断是否为 HTTP URL 145 | * @param url 要判断的 URL 146 | * @returns 如果 URL 以 http:// 或 https:// 开头,则返回 true,否则返回 false 147 | */ 148 | export function isHttpUrl(url: string) { 149 | return /^https?:\/\//.test(url) 150 | } 151 | 152 | interface QueryFilterOptions { 153 | field: T 154 | value?: V 155 | isFuzzy?: boolean 156 | } 157 | 158 | /** 159 | * 创建单字段查询条件 160 | * @param options 查询选项 161 | * @param options.field 字段名 162 | * @param options.value 值 163 | * @param options.isFuzzy 是否使用模糊搜索(仅对字符串类型有效) 164 | */ 165 | export function createSingleFieldFilter({ 166 | field, 167 | value, 168 | isFuzzy = false, 169 | }: QueryFilterOptions) { 170 | if (value === undefined || value === null || (typeof value === 'string' && !value.trim())) { 171 | return {} 172 | } 173 | 174 | if (typeof value === 'string' && isFuzzy) { 175 | return { 176 | [field]: { contains: value.trim() }, 177 | } 178 | } 179 | 180 | return { 181 | [field]: value, 182 | } 183 | } 184 | 185 | /** 186 | * 创建逗号分隔的模糊搜索条件 187 | * @param field 要搜索的字段名 188 | * @param value 逗号分隔的搜索值 189 | * @returns Prisma OR 条件数组 190 | */ 191 | export function createCommaSearchFilter( 192 | field: T, 193 | value?: string, 194 | ): { OR?: Array<{ [K in T]: { contains: string } }> } { 195 | if (!value) { 196 | return {} 197 | } 198 | 199 | // 去除字符串开头和结尾的逗号,并分割字符串 200 | const values = value 201 | .trim() 202 | .replace(/^,+|,+$/g, '') 203 | .split(',') 204 | .map(item => item.trim()) 205 | .filter(item => item.length > 0) 206 | 207 | if (values.length === 0) { 208 | return {} 209 | } 210 | 211 | return { 212 | OR: values.map( 213 | item => 214 | ({ 215 | [field]: { contains: item }, 216 | }) as { [K in T]: { contains: string } }, 217 | ), 218 | } 219 | } 220 | 221 | /** 222 | * 计算分页参数 223 | * @param rawPage 当前页码(从1开始) 224 | * @param rawPageSize 每页数量 225 | * @returns [skip, take] 元组,用于 Prisma 查询 226 | */ 227 | export function createPaginationParams(rawPage: number, rawPageSize: number) { 228 | const normalizedPage = Math.max(1, rawPage) 229 | const normalizedPageSize = Math.max(1, rawPageSize) 230 | 231 | return { 232 | page: normalizedPage, 233 | pageSize: normalizedPageSize, 234 | } 235 | } 236 | 237 | /** 238 | * 树节点类型 239 | */ 240 | export type TreeNode = T & { children?: TreeNode[] } 241 | 242 | /** 243 | * 将扁平数据转换为树形结构 244 | * @param flatData 扁平数据 245 | * @param rootId 根节点ID 246 | * @returns 树形结构 247 | */ 248 | export function convertFlatDataToTree(flatData: T[], rootId?: number): TreeNode[] { 249 | const map: Record> = {} 250 | const roots: TreeNode[] = [] 251 | 252 | // 将所有节点添加到 map 中,以 id 作为 key 253 | flatData.forEach((node) => { 254 | map[node.id] = { ...node } as TreeNode // 明确类型转换为 TreeNode 255 | }) 256 | 257 | // 遍历所有节点,构建树形结构 258 | flatData.forEach((node) => { 259 | const parentNode = map[node.parentId ?? rootId] 260 | if (parentNode) { 261 | let children = parentNode.children 262 | if (!children) { 263 | children = [] 264 | Object.assign(parentNode, { children }) // 添加 children 属性 265 | } 266 | children.push(map[node.id]) 267 | } 268 | else { 269 | // 如果找不到父节点,将当前节点作为根节点 270 | roots.push(map[node.id]) 271 | } 272 | }) 273 | 274 | // 移除空的 children 属性 275 | const cleanUpEmptyChildren = (nodes: TreeNode[]): TreeNode[] => 276 | nodes 277 | .sort((a, b) => a.sort - b.sort) 278 | .map(node => ({ 279 | ...node, 280 | children: node.children && node.children.length > 0 ? cleanUpEmptyChildren(node.children) : undefined, 281 | })) 282 | 283 | return cleanUpEmptyChildren(roots) 284 | } 285 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common' 2 | import { Test, TestingModule } from '@nestjs/testing' 3 | import request from 'supertest' 4 | 5 | import { AppModule } from '@/app.module' 6 | 7 | describe('AppController (e2e)', () => { 8 | let app: INestApplication 9 | 10 | beforeEach(async () => { 11 | const moduleFixture: TestingModule = await Test.createTestingModule({ 12 | imports: [AppModule], 13 | }).compile() 14 | 15 | app = moduleFixture.createNestApplication() 16 | await app.init() 17 | }) 18 | 19 | it('/ (GET)', () => { 20 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!') 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "ES2022", 5 | "lib": ["ES2024"], 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "baseUrl": "./", 9 | "module": "commonjs", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | }, 13 | "strictBindCallApply": false, 14 | "strictNullChecks": false, 15 | "noFallthroughCasesInSwitch": false, 16 | "noImplicitAny": false, 17 | "declaration": true, 18 | "declarationMap": true, 19 | "outDir": "./dist", 20 | "removeComments": true, 21 | "sourceMap": true, 22 | "allowSyntheticDefaultImports": true, 23 | "esModuleInterop": true, 24 | "forceConsistentCasingInFileNames": false, 25 | "skipLibCheck": true 26 | } 27 | } 28 | --------------------------------------------------------------------------------