├── .commitlintrc.js ├── .cspell.json ├── .cspell.txt ├── .dockerignore ├── .drone.yml ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.json ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .renovaterc ├── .swcrc ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── README.zh-CN.md ├── docker-compose.yaml ├── ecosystem.config.js ├── nest-cli.json ├── nginx.conf ├── package.json ├── pnpm-lock.yaml ├── prisma ├── migrations │ ├── 20231221144521_ │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public └── stylesheets │ └── style.css ├── src ├── class │ ├── base-resource.ts │ ├── error │ │ ├── env-undefined-error.ts │ │ └── index.ts │ ├── index.ts │ ├── jwt-metadata.ts │ ├── mongo-base-resource.ts │ ├── page.dto.ts │ ├── page.ts │ └── r.ts ├── configs │ ├── app.config.ts │ ├── cos.config.ts │ ├── dev.config.ts │ ├── index.ts │ ├── jwt.config.ts │ ├── mongo.config.ts │ ├── nodemailer.config.ts │ ├── postgres.config.ts │ └── redis.config.ts ├── constants │ ├── decorator-metadata.ts │ ├── files-config.ts │ ├── index.ts │ └── inject-key.ts ├── decorators │ ├── index.ts │ ├── jwt │ │ ├── index.ts │ │ └── jwt.decorator.ts │ ├── metadata │ │ ├── index.ts │ │ └── skip-auth.decorator.ts │ ├── swagger │ │ ├── api-created-object-response.decorator.ts │ │ ├── api-created-response.decorator.ts │ │ ├── api-ok-array-response.decorator.ts │ │ ├── api-ok-boolean-response.decorator.ts │ │ ├── api-ok-object-response.decorator.ts │ │ ├── api-ok-response.decorator.ts │ │ ├── api-page-ok-response.decorator.ts │ │ ├── api-page-query.decorator.ts │ │ └── index.ts │ └── transform │ │ ├── index.ts │ │ ├── to-array.decorator.ts │ │ ├── to-boolean.decorator.ts │ │ ├── to-id.decorator.ts │ │ ├── to-int.decorator.ts │ │ ├── to-iso-string.decorator.ts │ │ ├── to-lower-case.decorator.ts │ │ ├── to-upper-case.decorator.ts │ │ └── trim.decorator.ts ├── enums │ ├── index.ts │ ├── login-type.enum.ts │ ├── sort-column-key.enum.ts │ └── sort-order.enum.ts ├── filters │ ├── http-exception.filter.ts │ ├── index.ts │ ├── mongo-exception.filter.ts │ └── prisma-exception.filter.ts ├── generated │ └── i18n.generated.ts ├── guards │ ├── access-token.guard.ts │ ├── index.ts │ └── refresh-token.guard.ts ├── i18n │ ├── en-US │ │ ├── auth.json │ │ ├── common.json │ │ ├── dictionary.json │ │ └── user.json │ └── zh-CN │ │ ├── auth.json │ │ ├── common.json │ │ ├── dictionary.json │ │ └── user.json ├── interceptors │ ├── errors.interceptor.ts │ ├── index.ts │ └── logging.interceptor.ts ├── interfaces │ ├── custom-request.interface.ts │ ├── index.ts │ └── jwt-payload.interface.ts ├── main.ts ├── maps │ ├── file-extension.ts │ └── index.ts ├── metadata.ts ├── middlewares │ ├── delay.middleware.ts │ └── index.ts ├── modules │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── auth │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── auth.service.ts │ │ ├── dto │ │ │ ├── index.ts │ │ │ ├── login.dto.ts │ │ │ └── signup.dto.ts │ │ ├── strategies │ │ │ ├── access-token.strategy.ts │ │ │ ├── index.ts │ │ │ └── refresh-token.strategy.ts │ │ └── vo │ │ │ ├── index.ts │ │ │ └── token.vo.ts │ ├── dictionaries │ │ ├── dictionaries.controller.ts │ │ ├── dictionaries.module.ts │ │ ├── dictionaries.service.ts │ │ ├── dto │ │ │ ├── create-dictionary.dto.ts │ │ │ ├── index.ts │ │ │ ├── page-dictionary.dto.ts │ │ │ ├── patch-dictionary.dto.ts │ │ │ └── update-dictionary.dto.ts │ │ └── vo │ │ │ ├── dictionary-select-item.vo.ts │ │ │ ├── dictionary.vo.ts │ │ │ ├── index.ts │ │ │ ├── list-dictionary-select-item.vo.ts │ │ │ └── page-dictionary.vo.ts │ ├── dictionary-items │ │ ├── dictionary-items.controller.ts │ │ ├── dictionary-items.module.ts │ │ ├── dictionary-items.service.ts │ │ ├── dto │ │ │ ├── create-dictionary-item.dto.ts │ │ │ ├── index.ts │ │ │ ├── page-dictionary-item.dto.ts │ │ │ ├── patch-dictionary-item.dto.ts │ │ │ └── update-dictionary-item.dto.ts │ │ └── vo │ │ │ ├── dictionary-item.vo.ts │ │ │ ├── index.ts │ │ │ └── page-dictionary-item.vo.ts │ ├── files │ │ ├── files.controller.ts │ │ ├── files.module.ts │ │ ├── files.service.ts │ │ └── vo │ │ │ ├── file.vo.ts │ │ │ └── index.ts │ ├── locales │ │ ├── dto │ │ │ ├── create-locale.dto.ts │ │ │ ├── index.ts │ │ │ ├── page-locale.dto.ts │ │ │ └── update-locale.dto.ts │ │ ├── locales.controller.ts │ │ ├── locales.module.ts │ │ ├── locales.service.ts │ │ ├── schemas │ │ │ ├── index.ts │ │ │ └── locale.schema.ts │ │ └── vo │ │ │ ├── index.ts │ │ │ ├── locale-resource.vo.ts │ │ │ ├── locale.vo.ts │ │ │ └── page-locale.vo.ts │ ├── login-logs │ │ ├── login-logs.controller.ts │ │ ├── login-logs.module.ts │ │ ├── login-logs.service.ts │ │ └── schemas │ │ │ ├── index.ts │ │ │ └── login-log.schema.ts │ ├── menu-items │ │ ├── menu-items.controller.ts │ │ ├── menu-items.module.ts │ │ └── menu-items.service.ts │ ├── operation-logs │ │ ├── operation-logs.controller.ts │ │ ├── operation-logs.module.ts │ │ ├── operation-logs.service.ts │ │ └── schemas │ │ │ ├── index.ts │ │ │ └── operation-log.schema.ts │ ├── permissions │ │ ├── permissions.controller.ts │ │ ├── permissions.module.ts │ │ └── permissions.service.ts │ ├── roles │ │ ├── dto │ │ │ └── index.ts │ │ ├── roles.controller.ts │ │ ├── roles.module.ts │ │ ├── roles.service.ts │ │ └── vo │ │ │ └── index.ts │ ├── settings │ │ ├── dto │ │ │ ├── create-setting.dto.ts │ │ │ ├── index.ts │ │ │ ├── page-setting.dto.ts │ │ │ ├── patch-setting.dto.ts │ │ │ └── update-setting.dto.ts │ │ ├── settings.controller.ts │ │ ├── settings.module.ts │ │ ├── settings.service.ts │ │ └── vo │ │ │ ├── index.ts │ │ │ ├── page-setting.vo.ts │ │ │ └── setting.vo.ts │ ├── user-traffics │ │ ├── schemas │ │ │ ├── index.ts │ │ │ ├── user-traffic-record.schema.ts │ │ │ └── user-traffic.schema.ts │ │ ├── user-traffics.controller.ts │ │ ├── user-traffics.module.ts │ │ └── user-traffics.service.ts │ └── users │ │ ├── dto │ │ ├── create-user.dto.ts │ │ ├── index.ts │ │ ├── page-user.dto.ts │ │ ├── patch-user.dto.ts │ │ └── update-user.dto.ts │ │ ├── online-users.controller.ts │ │ ├── online-users.service.ts │ │ ├── users.controller.ts │ │ ├── users.module.ts │ │ ├── users.service.ts │ │ └── vo │ │ ├── index.ts │ │ ├── page-user.vo.ts │ │ └── user.vo.ts ├── shared │ ├── cos │ │ ├── cos.controller.ts │ │ ├── cos.module.ts │ │ └── cos.service.ts │ ├── email │ │ ├── email.module.ts │ │ └── email.service.ts │ ├── logger │ │ ├── logger.module.ts │ │ └── logger.service.ts │ ├── prisma │ │ ├── prisma.module.ts │ │ └── prisma.service.ts │ └── redis │ │ ├── cache-key.service.ts │ │ ├── redis.module.ts │ │ └── redis.service.ts └── utils │ ├── generator.util.ts │ ├── i18n.util.ts │ ├── index.ts │ └── user-agent.util.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.eslint.json ├── tsconfig.json └── views ├── index.pug └── layout.pug /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('cz-git').UserConfig} */ 2 | module.exports = { 3 | extends: ['@commitlint/config-conventional'], 4 | prompt: { 5 | alias: { 6 | fd: 'docs: fix typos' 7 | }, 8 | messages: { 9 | type: '选择你要提交的类型 :', 10 | scope: '选择一个提交范围(可选):', 11 | customScope: '请输入自定义的提交范围 :', 12 | subject: '填写简短精炼的变更描述 :\n', 13 | body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n', 14 | breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n', 15 | footerPrefixesSelect: '选择关联 Issue 前缀(可选):', 16 | customFooterPrefix: '输入自定义 Issue 前缀 :', 17 | footer: '列举关联 Issue (可选) 例如: #1, #2 :\n', 18 | confirmCommit: '是否提交或修改 Commit ?' 19 | }, 20 | types: [ 21 | { 22 | value: 'feat', 23 | name: 'feat: ✨ 新增功能 | A new feature.', 24 | emoji: ':sparkles:' 25 | }, 26 | { 27 | value: 'fix', 28 | name: 'fix: 🐛 修复缺陷 | A bug fix.', 29 | emoji: ':bug:' 30 | }, 31 | { 32 | value: 'docs', 33 | name: 'docs: 📝 文档更新 | Documentation only changes.', 34 | emoji: ':memo:' 35 | }, 36 | { 37 | value: 'style', 38 | name: 'style: 💄 代码格式 | Changes that do not affect the meaning of the code.', 39 | emoji: ':lipstick:' 40 | }, 41 | { 42 | value: 'refactor', 43 | name: 'refactor: ♻️ 代码重构 | A code change that neither fixes a bug nor adds a feature.', 44 | emoji: ':recycle:' 45 | }, 46 | { 47 | value: 'perf', 48 | name: 'perf: ⚡️ 性能提升 | A code change that improves performance.', 49 | emoji: ':zap:' 50 | }, 51 | { 52 | value: 'test', 53 | name: 'test: ✅ 测试相关 | Adding missing tests or correcting existing tests.', 54 | emoji: ':white_check_mark:' 55 | }, 56 | { 57 | value: 'build', 58 | name: 'build: 📦️ 构建相关 | Changes that affect the build system or external dependencies.', 59 | emoji: ':package:' 60 | }, 61 | { 62 | value: 'ci', 63 | name: 'ci: 🎡 持续集成 | Changes to our CI configuration files and scripts.', 64 | emoji: ':ferris_wheel:' 65 | }, 66 | { 67 | value: 'revert', 68 | name: 'revert: ⏪️ 回退代码 | Revert to a commit.', 69 | emoji: ':rewind:' 70 | }, 71 | { 72 | value: 'chore', 73 | name: 'chore: 🔨 其他修改 | Other changes that do not modify src or test files.', 74 | emoji: ':hammer:' 75 | } 76 | ], 77 | useEmoji: true, 78 | emojiAlign: 'center', 79 | useAI: false, 80 | aiNumber: 1, 81 | themeColorCode: '', 82 | scopes: [], 83 | allowCustomScopes: true, 84 | allowEmptyScopes: true, 85 | customScopesAlign: 'bottom', 86 | customScopesAlias: 'custom', 87 | emptyScopesAlias: 'empty', 88 | upperCaseSubject: false, 89 | markBreakingChangeMode: false, 90 | allowBreakingChanges: ['feat', 'fix'], 91 | breaklineNumber: 100, 92 | breaklineChar: '|', 93 | skipQuestions: [], 94 | issuePrefixes: [ 95 | { value: 'link', name: 'link: 链接 ISSUES 进行中' }, 96 | { value: 'closed', name: 'closed: 标记 ISSUES 已完成' } 97 | ], 98 | customIssuePrefixAlign: 'top', 99 | emptyIssuePrefixAlias: 'skip', 100 | customIssuePrefixAlias: 'custom', 101 | allowCustomIssuePrefix: true, 102 | allowEmptyIssuePrefix: true, 103 | confirmColorize: true, 104 | maxHeaderLength: Infinity, 105 | maxSubjectLength: Infinity, 106 | minSubjectLength: 0, 107 | scopeOverrides: undefined, 108 | defaultBody: '', 109 | defaultIssues: '', 110 | defaultScope: '', 111 | defaultSubject: '' 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", 3 | "version": "0.2", 4 | "language": "en", 5 | "words": [], 6 | "flagWords": [], 7 | "dictionaries": ["custom-words"], 8 | "dictionaryDefinitions": [ 9 | { 10 | "name": "custom-words", 11 | "path": ".cspell.txt", 12 | "addWords": true 13 | } 14 | ], 15 | "ignorePaths": [ 16 | ".cspell.txt", 17 | ".github/workflows/**", 18 | ".vscode/*.json", 19 | "**/.vercel", 20 | "**/dist/**", 21 | "**/build/**", 22 | "**/node_modules/**", 23 | "**/**/CHANGELOG.md", 24 | "**/**/CONTRIBUTORS.md", 25 | "pnpm-lock.yaml", 26 | "README.md", 27 | "coverage", 28 | "public", 29 | "prisma/migrations", 30 | "uploads" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /.cspell.txt: -------------------------------------------------------------------------------- 1 | Apifox 2 | arta 3 | auths 4 | autoincrement 5 | brucesong 6 | CASL 7 | datasource 8 | fieldname 9 | FLUSHALL 10 | ICPU 11 | monokai 12 | msword 13 | nestjs 14 | nord 15 | officedocument 16 | openxmlformats 17 | picocolors 18 | preinstall 19 | requirepass 20 | Timestamptz 21 | Vals 22 | WECHAT 23 | wordprocessingml 24 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: docker 3 | name: deploy staging 4 | 5 | platform: 6 | os: linux 7 | arch: amd64 8 | 9 | trigger: 10 | branch: 11 | exclude: 12 | - main 13 | event: 14 | - pull_request 15 | - push 16 | 17 | steps: 18 | - name: deploy staging 19 | image: appleboy/drone-ssh 20 | settings: 21 | host: 22 | from_secret: SSH_HOST_STAGING 23 | username: 24 | from_secret: SSH_USERNAME 25 | password: 26 | from_secret: SSH_PASSWORD 27 | port: 22 28 | script: 29 | - cd /usr/repo/dolphin-admin-nest 30 | - pnpm pm2 stop dolphin-admin-nest 31 | - git fetch 32 | - git checkout -f ${DRONE_BRANCH} 33 | - git pull 34 | - pnpm i 35 | - pnpm build 36 | - pnpm pm2 restart dolphin-admin-nest 37 | debug: true 38 | --- 39 | kind: pipeline 40 | type: docker 41 | name: deploy production 42 | 43 | platform: 44 | os: linux 45 | arch: amd64 46 | 47 | trigger: 48 | branch: 49 | - main 50 | event: 51 | - push 52 | 53 | steps: 54 | - name: deploy staging 55 | image: appleboy/drone-ssh 56 | settings: 57 | host: 58 | from_secret: SSH_HOST_STAGING 59 | username: 60 | from_secret: SSH_USERNAME 61 | password: 62 | from_secret: SSH_PASSWORD 63 | port: 22 64 | script: 65 | - cd /usr/repo/dolphin-admin-nest 66 | - pnpm pm2 stop dolphin-admin-nest 67 | - git fetch 68 | - git checkout -f main 69 | - git pull 70 | - pnpm i 71 | - pnpm build 72 | - pnpm pm2 restart dolphin-admin-nest 73 | debug: true 74 | 75 | - name: deploy production 76 | image: appleboy/drone-ssh 77 | settings: 78 | host: 79 | from_secret: SSH_HOST_PRODUCTION 80 | username: 81 | from_secret: SSH_USERNAME 82 | password: 83 | from_secret: SSH_PASSWORD 84 | port: 22 85 | script: 86 | - cd /usr/repo/dolphin-admin-nest 87 | - pnpm pm2 stop dolphin-admin-nest 88 | - git fetch 89 | - git checkout -f main 90 | - git pull 91 | - pnpm i 92 | - pnpm build 93 | - pnpm pm2 restart dolphin-admin-nest 94 | debug: true 95 | depends_on: 96 | - deploy staging 97 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | quote_type = single 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | 3 | # 项目配置文件 4 | APP_NAME=Dolphin Admin Nest 5 | APP_VERSION=1.0.0 6 | APP_PORT=3000 7 | APP_BASE_URL=http://localhost:3000 8 | 9 | # 开发配置 10 | DELAY_SECONDS=0 11 | ENABLE_REQUEST_LOG=true 12 | ENABLE_PRISMA_LOG=true 13 | ENABLE_SWAGGER=false 14 | 15 | # JWT 16 | JWT_ACCESS_TOKEN_SECRET=access_token_secret 17 | JWT_ACCESS_TOKEN_EXP=15m 18 | JWT_REFRESH_TOKEN_SECRET=refresh_token_secret 19 | JWT_REFRESH_TOKEN_EXP=7d 20 | 21 | # PostgreSQL 22 | POSTGRES_USER=postgres # 初始化数据库时使用 23 | POSTGRES_PASSWORD= # 初始化数据库时使用 24 | POSTGRES_DB=dolphin-admin-postgres # 初始化数据库时使用 25 | POSTGRES_HOST=localhost # 本地连接 26 | # POSTGRES_HOST=dolphin-admin-postgres # Docker 连接 27 | POSTGRES_PORT=5432 28 | POSTGRES_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} 29 | 30 | # MongoDB 31 | MONGO_INITDB_ROOT_USERNAME=root # 初始化数据库时使用 32 | MONGO_INITDB_ROOT_PASSWORD= # 初始化数据库时使用 33 | MONGO_USERNAME=mongo 34 | MONGO_PASSWORD= 35 | MONGO_DATABASE=dolphin-admin-mongo 36 | MONGO_HOST=127.0.0.1 # 本地连接 37 | # MONGO_HOST=dolphin-admin-mongo # Docker 连接 38 | MONGO_PORT=27017 39 | MONGO_URL=mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@${MONGO_HOST}:${MONGO_PORT}/${MONGO_DATABASE} 40 | 41 | # Redis 42 | REDIS_HOST=localhost # 本地连接 43 | # REDIS_HOST=dolphin-admin-redis # Docker 连接 44 | REDIS_PORT=6379 45 | REDIS_USERNAME=default 46 | REDIS_PASSWORD= # 初始化数据库时使用 47 | REDIS_DATABASE=1 48 | 49 | # Nodemailer 50 | NODEMAILER_HOST=localhost # 本地连接 51 | NODEMAILER_PORT=587 52 | NODEMAILER_AUTH_USER= 53 | NODEMAILER_AUTH_PASS= 54 | 55 | # 腾讯云 COS 56 | COS_SECRET_ID= 57 | COS_SECRET_KEY= 58 | COS_BUCKET= 59 | COS_REGION= 60 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .husky 2 | pnpm-lock.yaml 3 | src/generated 4 | src/metadata.ts 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@brucesong/eslint-config-ts", 3 | "rules": { 4 | "import/no-extraneous-dependencies": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | *.js eol=lf 4 | *.jsx eol=lf 5 | *.ts eol=lf 6 | *.tsx eol=lf 7 | *.vue eol=lf 8 | *.css eol=lf 9 | *.scss eol=lf 10 | *.md eol=lf 11 | *.mdx eol=lf 12 | *.json eol=lf 13 | *.yml eol=lf 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # Env 38 | .env 39 | .env.* 40 | !.env.example 41 | 42 | uploads 43 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm cspell:check 5 | npx lint-staged 6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "**/*.{js,ts}": ["eslint --fix", "prettier --write"], 3 | "**/*.md": ["prettier --write"] 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | engine-strict=true 3 | shamefully-hoist=true 4 | strict-peer-dependencies=false 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .husky 2 | pnpm-lock.yaml 3 | src/generated 4 | src/metadata.ts 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "embeddedLanguageFormatting": "auto", 6 | "endOfLine": "lf", 7 | "htmlWhitespaceSensitivity": "css", 8 | "jsxSingleQuote": false, 9 | "printWidth": 100, 10 | "proseWrap": "preserve", 11 | "quoteProps": "as-needed", 12 | "semi": false, 13 | "singleAttributePerLine": true, 14 | "singleQuote": true, 15 | "tabWidth": 2, 16 | "trailingComma": "none", 17 | "useTabs": false 18 | } 19 | -------------------------------------------------------------------------------- /.renovaterc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "labels": [ 6 | "Dependencies" 7 | ], 8 | "semanticCommits": true, 9 | "packageRules": [ 10 | { 11 | "depTypeList": [ 12 | "devDependencies" 13 | ], 14 | "automerge": true 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "sourceMaps": true, 4 | "jsc": { 5 | "parser": { 6 | "syntax": "typescript", 7 | "decorators": true, 8 | "dynamicImport": true 9 | }, 10 | "baseUrl": "./" 11 | }, 12 | "minify": false 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Nest", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "nest", 9 | "args": ["start", "--watch"], 10 | "skipFiles": ["/**"] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-slim AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json ./ 6 | COPY pnpm-lock.yaml ./ 7 | COPY prisma ./prisma/ 8 | 9 | RUN apt-get update -y && apt-get install -y openssl 10 | 11 | RUN npm install -g pnpm 12 | RUN pnpm install 13 | 14 | COPY . . 15 | 16 | RUN pnpm build 17 | 18 | FROM node:lts-slim 19 | 20 | WORKDIR /app 21 | 22 | COPY --from=builder /app/node_modules ./node_modules 23 | COPY --from=builder /app/package.json ./ 24 | COPY --from=builder /app/pnpm-lock.yaml ./ 25 | COPY --from=builder /app/dist ./dist 26 | 27 | EXPOSE 3000 28 | 29 | CMD [ "npm", "run", "prod" ] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Bruce Song 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.md: -------------------------------------------------------------------------------- 1 | # Dolphin Admin Nest 2 | 3 | English / [简体中文](./README.zh-CN.md) 4 | 5 | Dolphin Admin Nest is the back-end service of Dolphin Admin, based on Nest.js, TypeScript, Prisma and PostgresSQL. 6 | 7 | ## Tech Stack 8 | 9 | - Nest 10 | - TypeScript 11 | - Prisma 12 | - PostgreSQL 13 | 14 | ## Usage 15 | 16 | ### Install 17 | 18 | ```bash 19 | pnpm i 20 | ``` 21 | 22 | ### Build 23 | 24 | ```bash 25 | pnpm build 26 | ``` 27 | 28 | ## License 29 | 30 | [MIT](/LICENSE) License © 2023 [Bruce Song](https://github.com/recallwei) 31 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # Dolphin Admin Nest 2 | 3 | [English](./README.md) / 简体中文 4 | 5 | Dolphin Admin Nest 是 Dolphin Admin 的后端服务,基于 Nest.js、TypeScript、Prisma 和 PostgreSQL。 6 | 7 | ## 技术栈 8 | 9 | - Nest 10 | - TypeScript 11 | - Prisma 12 | - PostgreSQL 13 | 14 | ## 特性 15 | 16 | - JWT 认证 17 | - CASL 权限控制 18 | - 环境变量配置文件 19 | - 本地文件服务 20 | - 阿里云对象存储 OSS 服务 21 | - 腾讯云对象存储 COS 服务 22 | - Swagger 文档 23 | - Prisma Query Builder 24 | - 序列化实体 25 | - 数据分页 26 | - 数据排序 27 | - 数据过滤 28 | - 异常过滤 29 | - 验证管道 30 | - Docker 部署 31 | - 健康检查 32 | 33 | ## 使用 34 | 35 | ### 安装 36 | 37 | ```bash 38 | pnpm i 39 | ``` 40 | 41 | ### 启动 42 | 43 | ```bash 44 | pnpm dev 45 | ``` 46 | 47 | ### 构建 48 | 49 | ```bash 50 | pnpm build 51 | ``` 52 | 53 | ## 许可证 54 | 55 | [MIT](/LICENSE) License © 2023 [Bruce Song](https://github.com/recallwei) 56 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | # api: 4 | # container_name: dolphin-admin-nest 5 | # build: 6 | # context: . 7 | # dockerfile: Dockerfile 8 | # ports: 9 | # - 3000:3000 10 | # env_file: 11 | # - .env 12 | # depends_on: 13 | # - postgres 14 | # - mongodb 15 | # - redis 16 | # command: bash -c "npm run prisma:migrate:dev" 17 | 18 | postgres: 19 | container_name: dolphin-admin-postgres 20 | image: postgres:16 21 | volumes: 22 | - postgres_data:/var/lib/postgresql/data 23 | restart: always 24 | ports: 25 | - 5432:5432 26 | env_file: 27 | - .env 28 | 29 | mongodb: 30 | container_name: dolphin-admin-mongo 31 | image: mongo 32 | command: [ "mongod", "--auth" ] 33 | volumes: 34 | - mongo_data:/data/db 35 | restart: always 36 | ports: 37 | - 27017:27017 38 | env_file: 39 | - .env 40 | 41 | redis: 42 | container_name: dolphin-admin-redis 43 | image: redis 44 | command: [ "--requirepass", "${REDIS_PASSWORD}" ] 45 | volumes: 46 | - redis_data:/data 47 | restart: always 48 | ports: 49 | - 6379:6379 50 | env_file: 51 | - .env 52 | 53 | volumes: 54 | postgres_data: 55 | mongo_data: 56 | redis_data: 57 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'dolphin-admin-nest', 5 | script: './dist/main.js', 6 | env_development: { 7 | NODE_ENV: 'development' 8 | }, 9 | env_staging: { 10 | NODE_ENV: 'staging' 11 | }, 12 | env_production: { 13 | NODE_ENV: 'production' 14 | }, 15 | max_memory_restart: '1452M', 16 | exec_mode: 'cluster', 17 | instances: 4, 18 | exp_backoff_restart_delay: 100, 19 | min_uptime: '5m', 20 | max_restarts: 5 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "compilerOptions": { 5 | "assets": [ 6 | "**/*.css", 7 | { 8 | "include": "**/*.html", 9 | "watchAssets": true 10 | }, 11 | { 12 | "include": "i18n/**/*", 13 | "watchAssets": true 14 | } 15 | ], 16 | "builder": "swc", 17 | "deleteOutDir": true, 18 | "manualRestart": true, 19 | "plugins": [ 20 | { 21 | "name": "@nestjs/swagger", 22 | "options": { 23 | "classValidatorShim": true, 24 | "introspectComments": true, 25 | "dtoFileNameSuffix": [".dto.ts", ".vo.ts"] 26 | } 27 | } 28 | ], 29 | "tsConfigPath": "tsconfig.build.json", 30 | "typeCheck": true, 31 | "watchAssets": false, 32 | "webpackConfigPath": "webpack.config.js" 33 | }, 34 | "entryFile": "main", 35 | "generateOptions": { 36 | "flat": false, 37 | "spec": false 38 | }, 39 | "sourceRoot": "src" 40 | } 41 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name example.com; 3 | charset 'utf-8'; 4 | 5 | location / { 6 | proxy_pass http://localhost:3000; 7 | proxy_http_version 1.1; 8 | proxy_set_header Upgrade $http_upgrade; 9 | proxy_set_header Connection 'Upgrade'; 10 | proxy_set_header Host $host; 11 | proxy_cache_bypass $http_upgrade; 12 | proxy_set_header X-Real-IP $remote_addr; 13 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 14 | proxy_set_header Access-Control-Allow-Origin *; 15 | } 16 | 17 | location = /favicon.ico { 18 | access_log off; 19 | log_not_found off; 20 | } 21 | 22 | location = /robots.txt { 23 | access_log off; 24 | log_not_found off; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { hash } from '@node-rs/bcrypt' 2 | import type { Prisma } from '@prisma/client' 3 | import { PrismaClient } from '@prisma/client' 4 | 5 | const prisma = new PrismaClient() 6 | 7 | const SEED_ADMIN_USERNAME = 'admin' 8 | const SEED_ADMIN_PASSWORD = '123456' 9 | const SEED_VISITOR_USERNAME = 'visitor' 10 | const SEED_VISITOR_PASSWORD = '123456' 11 | 12 | async function main() { 13 | // 创建超级管理员 14 | const commonUserInfo = { 15 | nickName: 'Bruce', 16 | firstName: 'Bruce', 17 | lastName: 'Song', 18 | avatarUrl: 'https://avatars.githubusercontent.com/u/62941121?v=4', 19 | country: 'China', 20 | province: 'Jiangsu', 21 | city: 'Suzhou', 22 | biography: 'The author of Dolphin Admin', 23 | enabled: true 24 | } 25 | 26 | const defaultAdminUser: Prisma.UserCreateInput = { 27 | ...commonUserInfo, 28 | username: SEED_ADMIN_USERNAME, 29 | password: await hash(SEED_ADMIN_PASSWORD, 10), 30 | email: 'recall4056@gmail.com' 31 | } 32 | 33 | const defaultVisitorUser: Prisma.UserCreateInput = { 34 | ...commonUserInfo, 35 | username: SEED_VISITOR_USERNAME, 36 | password: await hash(SEED_VISITOR_PASSWORD, 10) 37 | } 38 | 39 | const adminUser = await prisma.user.findUnique({ where: { username: SEED_ADMIN_USERNAME } }) 40 | const visitorUser = await prisma.user.findUnique({ where: { username: SEED_VISITOR_USERNAME } }) 41 | 42 | if (adminUser) { 43 | console.log('超级管理员已存在,无需重复创建') 44 | } else { 45 | await prisma.user.create({ 46 | data: { 47 | ...defaultAdminUser 48 | } 49 | }) 50 | } 51 | 52 | if (visitorUser) { 53 | console.log('访客用户已存在,无需重复创建') 54 | } else { 55 | await prisma.user.create({ 56 | data: { 57 | ...defaultVisitorUser 58 | } 59 | }) 60 | } 61 | } 62 | 63 | main() 64 | .then(() => { 65 | console.log('Seed your database successfully!') 66 | }) 67 | .catch((err) => { 68 | if (err instanceof Error) { 69 | console.error(err) 70 | } 71 | }) 72 | .finally(() => { 73 | prisma.$disconnect().catch(() => {}) 74 | }) 75 | -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 4 | 16px 'Lucida Grande', 5 | Helvetica, 6 | Arial, 7 | sans-serif; 8 | text-align: center; 9 | } 10 | -------------------------------------------------------------------------------- /src/class/base-resource.ts: -------------------------------------------------------------------------------- 1 | import { ApiHideProperty, ApiProperty } from '@nestjs/swagger' 2 | import { Exclude } from 'class-transformer' 3 | 4 | export class BaseResource { 5 | @ApiProperty({ description: '创建时间' }) 6 | createdAt: Date 7 | 8 | @ApiProperty({ description: '创建人', nullable: true }) 9 | createdBy?: number 10 | 11 | @ApiProperty({ description: '更新时间' }) 12 | updatedAt: Date 13 | 14 | @ApiProperty({ description: '更新人', nullable: true }) 15 | updatedBy?: number 16 | 17 | @ApiHideProperty() 18 | @Exclude() 19 | deletedAt?: Date 20 | 21 | @ApiHideProperty() 22 | @Exclude() 23 | deletedBy?: number 24 | } 25 | -------------------------------------------------------------------------------- /src/class/error/env-undefined-error.ts: -------------------------------------------------------------------------------- 1 | export class EnvUndefinedError extends Error { 2 | constructor(envName: string) { 3 | super(`${envName} 未定义,请在 .env 文件中定义`) 4 | this.name = this.constructor.name 5 | Object.setPrototypeOf(this, new.target.prototype) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/class/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from './env-undefined-error' 2 | -------------------------------------------------------------------------------- /src/class/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base-resource' 2 | export * from './error' 3 | export * from './jwt-metadata' 4 | export * from './mongo-base-resource' 5 | export * from './page' 6 | export * from './page.dto' 7 | export * from './r' 8 | -------------------------------------------------------------------------------- /src/class/jwt-metadata.ts: -------------------------------------------------------------------------------- 1 | export class JwtMetadata { 2 | // JWT ID 3 | jti: string 4 | 5 | // 用户 ID 6 | userId: number 7 | 8 | // 访问令牌 9 | accessToken: string 10 | 11 | // 刷新令牌 12 | refreshToken: string 13 | 14 | // IP 15 | ip?: string 16 | 17 | // 地区 18 | area?: string 19 | 20 | // 来源 21 | source?: string 22 | 23 | // 用户代理 24 | userAgent?: string 25 | 26 | // 浏览器 27 | browser?: string 28 | 29 | // 操作系统 30 | os?: string 31 | 32 | // 登录时间 33 | loginAt: string 34 | 35 | constructor(jwtMetadata?: JwtMetadata) { 36 | Object.assign(this, jwtMetadata) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/class/mongo-base-resource.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { Exclude, Expose } from 'class-transformer' 3 | 4 | @Exclude() 5 | export class MongoBaseResource { 6 | @ApiProperty({ description: 'ID' }) 7 | @Expose() 8 | id: string 9 | 10 | @ApiProperty({ description: '创建时间' }) 11 | @Expose() 12 | createdAt: Date 13 | 14 | @ApiProperty({ description: '更新时间' }) 15 | @Expose() 16 | updatedAt: Date 17 | 18 | @ApiProperty({ description: '排序' }) 19 | @Expose() 20 | sort?: number 21 | } 22 | -------------------------------------------------------------------------------- /src/class/page.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { Transform } from 'class-transformer' 3 | import { IsEnum, IsISO8601, IsOptional, IsPositive, IsString } from 'class-validator' 4 | 5 | import { ToISOString, Trim } from '@/decorators' 6 | import { SortColumnKey, SortOrder } from '@/enums' 7 | 8 | export class PageDto { 9 | @ApiProperty({ description: '页码', example: 1, default: 1 }) 10 | @IsPositive({ message: '页码必须大于 0' }) 11 | page: number = 1 12 | 13 | @ApiProperty({ description: '每页条数', example: 10, default: 10 }) 14 | @IsPositive({ message: '每页条数必须大于 0' }) 15 | pageSize: number = 10 16 | 17 | @ApiPropertyOptional({ description: '搜索关键字' }) 18 | @IsString({ message: '搜索关键字必须是一个字符串' }) 19 | @IsOptional() 20 | @Trim() 21 | keywords?: string 22 | 23 | @ApiPropertyOptional({ description: '开始时间' }) 24 | @IsISO8601( 25 | { 26 | strict: true, 27 | strictSeparator: true 28 | }, 29 | { message: '开始时间必须是一个有效的日期字符串' } 30 | ) 31 | @IsOptional() 32 | @ToISOString() 33 | startTime?: string 34 | 35 | @ApiPropertyOptional({ description: '结束时间' }) 36 | @IsISO8601( 37 | { strict: true, strictSeparator: true }, 38 | { message: '结束时间必须是一个有效的日期字符串' } 39 | ) 40 | @IsOptional() 41 | @ToISOString() 42 | endTime?: string 43 | 44 | @ApiPropertyOptional({ 45 | description: '排序列名', 46 | example: `${SortColumnKey.SORT},${SortColumnKey.CREATED_AT}` 47 | }) 48 | @IsEnum(SortColumnKey, { each: true, message: '排序列名不支持' }) 49 | @IsOptional() 50 | @Transform(({ value }) => value.split(',')) 51 | sortColumnKeys: SortColumnKey[] 52 | 53 | @ApiPropertyOptional({ 54 | description: '排序方式', 55 | example: `${SortOrder.ASC},${SortOrder.DESC}` 56 | }) 57 | @IsEnum(SortOrder, { each: true, message: '排序方法不支持' }) 58 | @IsOptional() 59 | @Transform(({ value }) => value.split(',')) 60 | sortOrders: SortOrder[] 61 | 62 | /** 63 | * Prisma 排序对象数组 64 | */ 65 | orderBy?: Record[] 66 | 67 | /** 68 | * MongoDB 排序数组 69 | */ 70 | sort?: [string, SortOrder][] 71 | 72 | constructor() { 73 | if (this.sortColumnKeys && this.sortOrders) { 74 | this.sortColumnKeys = this.sortColumnKeys.filter(Boolean) 75 | this.sortOrders = this.sortOrders.filter(Boolean) 76 | // 将排序字段和排序方式转化为 Prisma 的排序对象数组 77 | this.orderBy = this.sortColumnKeys.map((field: SortColumnKey, index) => ({ 78 | [field]: this.sortOrders[index] 79 | })) as Record[] 80 | // 将排序字段和排序方式转化为 MongoDB 的排序数组 81 | this.sort = this.sortColumnKeys.map((field: SortColumnKey, index) => [ 82 | field, 83 | this.sortOrders[index] 84 | ]) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/class/page.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | export class Page { 4 | @ApiProperty({ description: '页码', example: 1 }) 5 | page: number 6 | 7 | @ApiProperty({ description: '每页条数', example: 10 }) 8 | pageSize: number 9 | 10 | @ApiProperty({ description: '总条数', example: 100 }) 11 | total: number 12 | 13 | @ApiProperty({ description: '分页数据', type: () => [Object] }) 14 | records: T[] = [] 15 | 16 | constructor(page?: Page) { 17 | Object.assign(this, page) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/class/r.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | 3 | export class R { 4 | @ApiPropertyOptional({ description: '提示信息', example: '请求成功', nullable: true }) 5 | msg?: string 6 | 7 | @ApiPropertyOptional({ description: '响应数据', type: () => Object, nullable: true }) 8 | data?: T 9 | 10 | constructor(r?: R) { 11 | Object.assign(this, r) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/configs/app.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process' 2 | 3 | import { registerAs } from '@nestjs/config' 4 | 5 | import { EnvUndefinedError } from '@/class' 6 | 7 | export const AppConfig = registerAs('app', () => { 8 | if (!env.APP_NAME) { 9 | throw new EnvUndefinedError('APP_NAME') 10 | } 11 | if (!env.APP_VERSION) { 12 | throw new EnvUndefinedError('APP_VERSION') 13 | } 14 | if (!env.APP_PORT) { 15 | throw new EnvUndefinedError('APP_PORT') 16 | } 17 | if (!env.APP_BASE_URL) { 18 | throw new EnvUndefinedError('APP_BASE_URL BASE_URL') 19 | } 20 | return Object.freeze({ 21 | // App 元数据 22 | name: env.APP_NAME, 23 | version: env.APP_VERSION, 24 | port: parseInt(env.APP_PORT, 10), 25 | baseUrl: env.APP_BASE_URL, 26 | env: env.NODE_ENV ?? 'development', 27 | isDEV: env.NODE_ENV === 'development', 28 | isSTAGING: env.NODE_ENV === 'staging', 29 | isPROD: env.NODE_ENV === 'production' 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/configs/cos.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process' 2 | 3 | import { registerAs } from '@nestjs/config' 4 | 5 | // 腾讯云 COS 配置 6 | export const CosConfig = registerAs('cos', () => 7 | Object.freeze({ 8 | secretId: env.COS_SECRET_ID, // 云 API 密钥 SecretId 9 | secretKey: env.COS_SECRET_KEY, // 云 API 密钥 SecretKey 10 | bucket: env.COS_BUCKET, // 存储桶名称 11 | region: env.COS_REGION // 存储桶所在地区 12 | }) 13 | ) 14 | -------------------------------------------------------------------------------- /src/configs/dev.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process' 2 | 3 | import { registerAs } from '@nestjs/config' 4 | 5 | // 开发配置 6 | export const DevConfig = registerAs('dev', () => 7 | Object.freeze({ 8 | delaySeconds: env.DELAY_SECONDS ? parseInt(env.DELAY_SECONDS, 10) : 0, 9 | enableRequestLog: env.ENABLE_REQUEST_LOG === 'true', 10 | enablePrismaLog: env.ENABLE_PRISMA_LOG === 'true', 11 | enableSwagger: env.ENABLE_SWAGGER === 'true' 12 | }) 13 | ) 14 | -------------------------------------------------------------------------------- /src/configs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app.config' 2 | export * from './cos.config' 3 | export * from './dev.config' 4 | export * from './jwt.config' 5 | export * from './mongo.config' 6 | export * from './nodemailer.config' 7 | export * from './postgres.config' 8 | export * from './redis.config' 9 | -------------------------------------------------------------------------------- /src/configs/jwt.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process' 2 | 3 | import { registerAs } from '@nestjs/config' 4 | 5 | import { EnvUndefinedError } from '@/class' 6 | 7 | // JWT 配置 8 | export const JwtConfig = registerAs('jwt', () => { 9 | if (!env.JWT_ACCESS_TOKEN_SECRET) { 10 | throw new EnvUndefinedError('JWT_ACCESS_TOKEN_SECRET') 11 | } 12 | if (!env.JWT_ACCESS_TOKEN_EXP) { 13 | throw new EnvUndefinedError('JWT_ACCESS_TOKEN_EXP') 14 | } 15 | if (!env.JWT_REFRESH_TOKEN_SECRET) { 16 | throw new EnvUndefinedError('JWT_REFRESH_TOKEN_SECRET') 17 | } 18 | if (!env.JWT_REFRESH_TOKEN_EXP) { 19 | throw new EnvUndefinedError('JWT_REFRESH_TOKEN_EXP') 20 | } 21 | return Object.freeze({ 22 | accessTokenSecret: env.JWT_ACCESS_TOKEN_SECRET, 23 | accessTokenExp: env.JWT_ACCESS_TOKEN_EXP, 24 | refreshTokenSecret: env.JWT_REFRESH_TOKEN_SECRET, 25 | refreshTokenExp: env.JWT_REFRESH_TOKEN_EXP 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/configs/mongo.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process' 2 | 3 | import { registerAs } from '@nestjs/config' 4 | 5 | import { EnvUndefinedError } from '@/class' 6 | 7 | // MongoDB 配置 8 | export const MongoConfig = registerAs('mongo', () => { 9 | if (!env.MONGO_USERNAME) { 10 | throw new EnvUndefinedError('MONGO_USERNAME') 11 | } 12 | if (!env.MONGO_PASSWORD) { 13 | throw new EnvUndefinedError('MONGO_PASSWORD') 14 | } 15 | if (!env.MONGO_DATABASE) { 16 | throw new EnvUndefinedError('MONGO_DATABASE') 17 | } 18 | if (!env.MONGO_HOST) { 19 | throw new EnvUndefinedError('MONGO_HOST') 20 | } 21 | if (!env.MONGO_PORT) { 22 | throw new EnvUndefinedError('MONGO_PORT') 23 | } 24 | if (!env.MONGO_URL) { 25 | throw new EnvUndefinedError('MONGO_URL') 26 | } 27 | return Object.freeze({ 28 | user: env.MONGO_USERNAME, 29 | password: env.MONGO_PASSWORD, 30 | db: env.MONGO_DATABASE, 31 | host: env.MONGO_HOST, 32 | port: parseInt(env.MONGO_PORT, 10), 33 | url: env.MONGO_URL 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/configs/nodemailer.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process' 2 | 3 | import { registerAs } from '@nestjs/config' 4 | 5 | import { EnvUndefinedError } from '@/class' 6 | 7 | // Nodemailer 配置 8 | export const NodemailerConfig = registerAs('nodemailer', () => { 9 | if (!env.NODEMAILER_HOST) { 10 | throw new EnvUndefinedError('NODEMAILER_HOST') 11 | } 12 | if (!env.NODEMAILER_PORT) { 13 | throw new EnvUndefinedError('NODEMAILER_PORT') 14 | } 15 | if (!env.NODEMAILER_AUTH_USER) { 16 | throw new EnvUndefinedError('NODEMAILER_AUTH_USER') 17 | } 18 | if (!env.NODEMAILER_AUTH_PASS) { 19 | throw new EnvUndefinedError('NODEMAILER_AUTH_PASS') 20 | } 21 | return Object.freeze({ 22 | host: env.NODEMAILER_HOST, 23 | port: parseInt(env.NODEMAILER_PORT, 10), 24 | auth: { 25 | user: env.NODEMAILER_AUTH_USER, 26 | pass: env.NODEMAILER_AUTH_PASS 27 | } 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/configs/postgres.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process' 2 | 3 | import { registerAs } from '@nestjs/config' 4 | 5 | import { EnvUndefinedError } from '@/class' 6 | 7 | // Postgres 配置 8 | export const PostgresConfig = registerAs('postgres', () => { 9 | if (!env.POSTGRES_USER) { 10 | throw new EnvUndefinedError('POSTGRES_USER') 11 | } 12 | if (!env.POSTGRES_PASSWORD) { 13 | throw new EnvUndefinedError('POSTGRES_PASSWORD') 14 | } 15 | if (!env.POSTGRES_DB) { 16 | throw new EnvUndefinedError('POSTGRES_DB') 17 | } 18 | if (!env.POSTGRES_HOST) { 19 | throw new EnvUndefinedError('POSTGRES_HOST') 20 | } 21 | if (!env.POSTGRES_PORT) { 22 | throw new EnvUndefinedError('POSTGRES_PORT') 23 | } 24 | if (!env.POSTGRES_URL) { 25 | throw new EnvUndefinedError('POSTGRES_URL') 26 | } 27 | return Object.freeze({ 28 | user: env.POSTGRES_USER, 29 | password: env.POSTGRES_PASSWORD, 30 | db: env.POSTGRES_DB, 31 | host: env.POSTGRES_HOST, 32 | port: parseInt(env.POSTGRES_PORT, 10), 33 | url: env.POSTGRES_URL 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/configs/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process' 2 | 3 | import { registerAs } from '@nestjs/config' 4 | 5 | import { EnvUndefinedError } from '@/class' 6 | 7 | // Redis 配置 8 | export const RedisConfig = registerAs('mongo', () => { 9 | if (!env.REDIS_USERNAME) { 10 | throw new EnvUndefinedError('REDIS_USERNAME') 11 | } 12 | if (!env.REDIS_PASSWORD) { 13 | throw new EnvUndefinedError('REDIS_PASSWORD') 14 | } 15 | if (!env.REDIS_DATABASE) { 16 | throw new EnvUndefinedError('REDIS_DATABASE') 17 | } 18 | if (!env.REDIS_HOST) { 19 | throw new EnvUndefinedError('REDIS_HOST') 20 | } 21 | if (!env.REDIS_PORT) { 22 | throw new EnvUndefinedError('REDIS_PORT') 23 | } 24 | return Object.freeze({ 25 | username: env.REDIS_USERNAME, 26 | password: env.REDIS_PASSWORD, 27 | db: env.REDIS_DATABASE, 28 | host: env.REDIS_HOST, 29 | port: parseInt(env.REDIS_PORT, 10) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/constants/decorator-metadata.ts: -------------------------------------------------------------------------------- 1 | // 仅提供给 @/decorators/metadata 使用 2 | export const AUTH = 'AUTH' 3 | export const SKIP_AUTH = 'SKIP_AUTH' 4 | export const ROLES = 'ROLES' 5 | -------------------------------------------------------------------------------- /src/constants/files-config.ts: -------------------------------------------------------------------------------- 1 | // TODO: 放到配置文件 2 | export const STORAGE_DIR = 'uploads' 3 | export const MAX_UPLOAD_FILE_SIZE = 1024 * 1024 * 5 4 | export const MAX_UPLOAD_FILES_COUNT = 5 5 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './decorator-metadata' 2 | export * from './files-config' 3 | export * from './inject-key' 4 | -------------------------------------------------------------------------------- /src/constants/inject-key.ts: -------------------------------------------------------------------------------- 1 | export const REDIS_CLIENT = 'REDIS_CLIENT' 2 | -------------------------------------------------------------------------------- /src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt' 2 | export * from './metadata' 3 | export * from './swagger' 4 | export * from './transform' 5 | -------------------------------------------------------------------------------- /src/decorators/jwt/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt.decorator' 2 | -------------------------------------------------------------------------------- /src/decorators/jwt/jwt.decorator.ts: -------------------------------------------------------------------------------- 1 | import type { ExecutionContext } from '@nestjs/common' 2 | import { createParamDecorator } from '@nestjs/common' 3 | 4 | import type { CustomRequest, JwtPayload } from '@/interfaces' 5 | 6 | /** 7 | * jwt 信息装饰器 8 | * @description 用于获取当前请求执行上下文中的 jwtPayload 9 | */ 10 | export const Jwt = createParamDecorator((key, ctx: ExecutionContext) => { 11 | const { jwtPayload } = ctx.switchToHttp().getRequest() 12 | return key ? jwtPayload?.[key] : jwtPayload 13 | }) 14 | -------------------------------------------------------------------------------- /src/decorators/metadata/index.ts: -------------------------------------------------------------------------------- 1 | export * from './skip-auth.decorator' 2 | -------------------------------------------------------------------------------- /src/decorators/metadata/skip-auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common' 2 | 3 | import { SKIP_AUTH } from '@/constants' 4 | 5 | // 跳过认证 6 | export const SkipAuth = () => SetMetadata(SKIP_AUTH, true) 7 | -------------------------------------------------------------------------------- /src/decorators/swagger/api-created-object-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import type { Type } from '@nestjs/common' 2 | import { applyDecorators, HttpStatus } from '@nestjs/common' 3 | import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger' 4 | 5 | export function ApiCreatedObjectResponse(type: T) { 6 | return applyDecorators( 7 | ApiExtraModels(type), 8 | ApiResponse({ 9 | status: HttpStatus.CREATED, 10 | description: '创建成功', 11 | schema: { 12 | properties: { 13 | msg: { 14 | type: 'string', 15 | description: '提示信息', 16 | example: '创建成功' 17 | }, 18 | data: { 19 | type: 'object', 20 | $ref: getSchemaPath(type) 21 | } 22 | } 23 | } 24 | }) 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/decorators/swagger/api-created-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, HttpStatus } from '@nestjs/common' 2 | import { ApiResponse } from '@nestjs/swagger' 3 | 4 | export function ApiCreatedResponse() { 5 | return applyDecorators( 6 | ApiResponse({ 7 | status: HttpStatus.CREATED, 8 | description: '创建成功', 9 | schema: { 10 | properties: { 11 | msg: { 12 | type: 'string', 13 | description: '提示信息', 14 | example: '创建成功' 15 | } 16 | } 17 | } 18 | }) 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/decorators/swagger/api-ok-array-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import type { Type } from '@nestjs/common' 2 | import { applyDecorators, HttpStatus } from '@nestjs/common' 3 | import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger' 4 | 5 | export function ApiOkArrayResponse(type: T) { 6 | return applyDecorators( 7 | ApiExtraModels(type), 8 | ApiResponse({ 9 | status: HttpStatus.OK, 10 | description: '请求成功', 11 | schema: { 12 | properties: { 13 | msg: { 14 | type: 'string', 15 | description: '提示信息', 16 | example: '' 17 | }, 18 | data: { 19 | type: 'array', 20 | items: { 21 | $ref: getSchemaPath(type) 22 | } 23 | } 24 | } 25 | } 26 | }) 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/decorators/swagger/api-ok-boolean-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, HttpStatus } from '@nestjs/common' 2 | import { ApiResponse } from '@nestjs/swagger' 3 | 4 | export function ApiOkBooleanResponse() { 5 | return applyDecorators( 6 | ApiResponse({ 7 | status: HttpStatus.OK, 8 | description: '请求成功', 9 | schema: { 10 | properties: { 11 | msg: { 12 | type: 'string', 13 | description: '提示信息', 14 | example: '' 15 | }, 16 | data: { 17 | type: 'boolean' 18 | } 19 | } 20 | } 21 | }) 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/decorators/swagger/api-ok-object-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import type { Type } from '@nestjs/common' 2 | import { applyDecorators, HttpStatus } from '@nestjs/common' 3 | import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger' 4 | 5 | export function ApiOkObjectResponse(type: T) { 6 | return applyDecorators( 7 | ApiExtraModels(type), 8 | ApiResponse({ 9 | status: HttpStatus.OK, 10 | description: '请求成功', 11 | schema: { 12 | properties: { 13 | msg: { 14 | type: 'string', 15 | description: '提示信息', 16 | example: '' 17 | }, 18 | data: { 19 | type: 'object', 20 | $ref: getSchemaPath(type) 21 | } 22 | } 23 | } 24 | }) 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/decorators/swagger/api-ok-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, HttpStatus } from '@nestjs/common' 2 | import { ApiResponse } from '@nestjs/swagger' 3 | 4 | export function ApiOkResponse() { 5 | return applyDecorators( 6 | ApiResponse({ 7 | status: HttpStatus.OK, 8 | description: '请求成功', 9 | schema: { 10 | properties: { 11 | msg: { 12 | type: 'string', 13 | description: '提示信息', 14 | example: '' 15 | } 16 | } 17 | } 18 | }) 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/decorators/swagger/api-page-ok-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import type { Type } from '@nestjs/common' 2 | import { applyDecorators } from '@nestjs/common' 3 | import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger' 4 | 5 | import { Page } from '@/class' 6 | 7 | export const ApiPageOKResponse = (type: T) => 8 | applyDecorators( 9 | ApiExtraModels(Page, type), 10 | ApiOkResponse({ 11 | schema: { 12 | description: '分页数据', 13 | properties: { 14 | msg: { 15 | type: 'string', 16 | description: '提示信息', 17 | example: '' 18 | }, 19 | data: { 20 | allOf: [ 21 | { $ref: getSchemaPath(Page) }, 22 | { 23 | properties: { 24 | records: { 25 | type: 'array', 26 | description: '分页数据', 27 | items: { 28 | $ref: getSchemaPath(type) 29 | } 30 | } 31 | } 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | }) 38 | ) 39 | -------------------------------------------------------------------------------- /src/decorators/swagger/api-page-query.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common' 2 | import { ApiExtraModels, ApiQuery } from '@nestjs/swagger' 3 | 4 | import { PageDto } from '@/class' 5 | 6 | type QueryType = 'keywords' | 'date' 7 | 8 | // 分页参数 9 | export const ApiPageQuery = (...type: QueryType[]) => { 10 | const decorators = [ 11 | ApiExtraModels(PageDto), 12 | ApiQuery({ 13 | name: 'page', 14 | description: '页码', 15 | required: true, 16 | example: 1, 17 | type: Number 18 | }), 19 | ApiQuery({ 20 | name: 'pageSize', 21 | description: '每页条数', 22 | required: true, 23 | example: 10, 24 | type: Number 25 | }) 26 | ] 27 | if (type.includes('keywords')) { 28 | decorators.push( 29 | ApiQuery({ 30 | name: 'keywords', 31 | description: '搜索关键字', 32 | required: false, 33 | type: String 34 | }) 35 | ) 36 | } 37 | if (type.includes('date')) { 38 | decorators.push( 39 | ApiQuery({ 40 | name: 'startTime', 41 | description: '开始时间', 42 | required: false, 43 | type: String 44 | }), 45 | ApiQuery({ 46 | name: 'endTime', 47 | description: '结束时间', 48 | required: false, 49 | type: String 50 | }) 51 | ) 52 | } 53 | return applyDecorators(...decorators) 54 | } 55 | -------------------------------------------------------------------------------- /src/decorators/swagger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api-created-object-response.decorator' 2 | export * from './api-created-response.decorator' 3 | export * from './api-ok-boolean-response.decorator' 4 | export * from './api-ok-object-response.decorator' 5 | export * from './api-ok-response.decorator' 6 | export * from './api-page-ok-response.decorator' 7 | export * from './api-page-query.decorator' 8 | -------------------------------------------------------------------------------- /src/decorators/transform/index.ts: -------------------------------------------------------------------------------- 1 | export * from './to-array.decorator' 2 | export * from './to-boolean.decorator' 3 | export * from './to-id.decorator' 4 | export * from './to-int.decorator' 5 | export * from './to-iso-string.decorator' 6 | export * from './to-lower-case.decorator' 7 | export * from './to-upper-case.decorator' 8 | export * from './trim.decorator' 9 | -------------------------------------------------------------------------------- /src/decorators/transform/to-array.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | import { castArray, isNil } from 'lodash' 3 | 4 | export function ToArray(): PropertyDecorator { 5 | return Transform( 6 | ({ value }) => { 7 | if (isNil(value)) { 8 | return [] 9 | } 10 | return castArray(value) 11 | }, 12 | { toClassOnly: true } 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/decorators/transform/to-boolean.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | 3 | export function ToBoolean(): PropertyDecorator { 4 | return Transform( 5 | ({ value }) => { 6 | switch (value) { 7 | case 'true': 8 | return true 9 | case 'false': 10 | return false 11 | default: 12 | return value 13 | } 14 | }, 15 | { toClassOnly: true } 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/decorators/transform/to-id.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | 3 | /** 4 | * Query 的 ID 转换为 number 类型 5 | */ 6 | export function ToId(): PropertyDecorator { 7 | return Transform( 8 | ({ value }) => { 9 | const number = value ? +value : 0 10 | return Number.isInteger(number) && number > 0 ? number : undefined 11 | }, 12 | { toClassOnly: true } 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/decorators/transform/to-int.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | 3 | export function ToInt(): PropertyDecorator { 4 | return Transform(({ value }) => Number.parseInt(value, 10), { 5 | toClassOnly: true 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /src/decorators/transform/to-iso-string.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | 3 | /** 4 | * Query 的时间、日期转换为 ISO 字符串 5 | */ 6 | export function ToISOString(): PropertyDecorator { 7 | return Transform( 8 | ({ value }) => { 9 | try { 10 | return new Date(value).toISOString() 11 | } catch { 12 | return undefined 13 | } 14 | }, 15 | { toClassOnly: true } 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/decorators/transform/to-lower-case.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | 3 | export function ToLowerCase(): PropertyDecorator { 4 | return Transform( 5 | ({ value }) => { 6 | if (!value) { 7 | return undefined 8 | } 9 | if (!Array.isArray(value)) { 10 | return value.toLowerCase() 11 | } 12 | return value.map((v) => v.toLowerCase()) 13 | }, 14 | { toClassOnly: true } 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/decorators/transform/to-upper-case.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | 3 | export function ToUpperCase(): PropertyDecorator { 4 | return Transform( 5 | ({ value }) => { 6 | if (!value) { 7 | return undefined 8 | } 9 | if (!Array.isArray(value)) { 10 | return value.toUpperCase() 11 | } 12 | return value.map((v) => v.toUpperCase()) 13 | }, 14 | { toClassOnly: true } 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/decorators/transform/trim.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | import { isNil, map, trim } from 'lodash' 3 | 4 | export function Trim(): PropertyDecorator { 5 | return Transform(({ value }: { value: string | undefined | null | string[] }) => { 6 | if (isNil(value)) { 7 | return value 8 | } 9 | if (Array.isArray(value)) { 10 | return map(value, (v) => trim(v).replaceAll(/\s\s+/g, ' ')) 11 | } 12 | return trim(value).replaceAll(/\s\s+/g, ' ') 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login-type.enum' 2 | export * from './sort-column-key.enum' 3 | export * from './sort-order.enum' 4 | -------------------------------------------------------------------------------- /src/enums/login-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum LoginType { 2 | USERNAME = '1', 3 | EMAIL = '2' 4 | } 5 | -------------------------------------------------------------------------------- /src/enums/sort-column-key.enum.ts: -------------------------------------------------------------------------------- 1 | export enum SortColumnKey { 2 | CREATED_AT = 'createdAt', 3 | ID = 'id', 4 | SORT = 'sort' 5 | } 6 | -------------------------------------------------------------------------------- /src/enums/sort-order.enum.ts: -------------------------------------------------------------------------------- 1 | export enum SortOrder { 2 | ASC = 'asc', 3 | DESC = 'desc' 4 | } 5 | -------------------------------------------------------------------------------- /src/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common' 2 | import { Catch, HttpException } from '@nestjs/common' 3 | import type { Response } from 'express' 4 | 5 | import { R } from '@/class' 6 | 7 | @Catch(HttpException) 8 | export class HttpExceptionFilter implements ExceptionFilter { 9 | catch(exception: HttpException, host: ArgumentsHost) { 10 | const ctx = host.switchToHttp() 11 | const response = ctx.getResponse() 12 | const status = exception.getStatus() 13 | const exceptionResponse = exception.getResponse() 14 | if (typeof exceptionResponse === 'string') { 15 | response.status(status).json(new R({ msg: exceptionResponse })) 16 | } else { 17 | const { message } = exceptionResponse as Record 18 | response.status(status).json(new R({ msg: message })) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/filters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http-exception.filter' 2 | export * from './mongo-exception.filter' 3 | export * from './prisma-exception.filter' 4 | -------------------------------------------------------------------------------- /src/filters/mongo-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common' 2 | import { Catch, HttpStatus } from '@nestjs/common' 3 | import type { Response } from 'express' 4 | import { MongoError } from 'mongodb' 5 | import { I18nContext } from 'nestjs-i18n' 6 | 7 | import { R } from '@/class' 8 | import type { I18nTranslations } from '@/generated/i18n.generated' 9 | 10 | @Catch(MongoError) 11 | export class MongoExceptionFilter implements ExceptionFilter { 12 | catch(exception: MongoError, host: ArgumentsHost) { 13 | const ctx = host.switchToHttp() 14 | const response = ctx.getResponse() 15 | const i18n = I18nContext.current()! 16 | 17 | switch (exception.code) { 18 | case 11000: 19 | response 20 | .status(HttpStatus.CONFLICT) 21 | .json(new R({ msg: i18n.t('common.RESOURCE.CONFLICT') })) 22 | break 23 | default: 24 | response.status(HttpStatus.INTERNAL_SERVER_ERROR).json() 25 | break 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/filters/prisma-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common' 2 | import { Catch, HttpStatus } from '@nestjs/common' 3 | import { Prisma } from '@prisma/client' 4 | import type { Response } from 'express' 5 | import { I18nContext } from 'nestjs-i18n' 6 | 7 | import { R } from '@/class' 8 | import type { I18nTranslations } from '@/generated/i18n.generated' 9 | 10 | @Catch(Prisma.PrismaClientKnownRequestError) 11 | export class PrismaExceptionFilter implements ExceptionFilter { 12 | catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) { 13 | const ctx = host.switchToHttp() 14 | const response = ctx.getResponse() 15 | const i18n = I18nContext.current()! 16 | 17 | const { code } = exception 18 | 19 | switch (code) { 20 | case 'P2002': 21 | // 处理 Prisma Unique 字段冲突异常 22 | response 23 | .status(HttpStatus.CONFLICT) 24 | .json(new R({ msg: i18n.t('common.RESOURCE.CONFLICT') })) 25 | break 26 | case 'P2003': 27 | // 处理 Prisma 操作失败异常 28 | response 29 | .status(HttpStatus.BAD_REQUEST) 30 | .json(new R({ msg: i18n.t('common.OPERATE.FAILED') })) 31 | break 32 | case 'P2021': 33 | // 处理 Prisma 表不存在 34 | response 35 | .status(HttpStatus.INTERNAL_SERVER_ERROR) 36 | .json(new R({ msg: i18n.t('common.OPERATE.FAILED') })) 37 | break 38 | case 'P2025': 39 | // 处理 Prisma 资源未找到异常 40 | response 41 | .status(HttpStatus.NOT_FOUND) 42 | .json(new R({ msg: i18n.t('common.RESOURCE.NOT.FOUND') })) 43 | break 44 | default: 45 | console.log(exception) 46 | response.status(HttpStatus.INTERNAL_SERVER_ERROR).json() 47 | break 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/generated/i18n.generated.ts: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT, file generated by nestjs-i18n */ 2 | 3 | import { Path } from "nestjs-i18n"; 4 | export type I18nTranslations = { 5 | "auth": { 6 | "LOGIN": { 7 | "SUCCESS": string; 8 | "TYPE.NOT.SUPPORTED": string; 9 | }; 10 | "LOGOUT": { 11 | "SUCCESS": string; 12 | }; 13 | "SIGN.UP": { 14 | "SUCCESS": string; 15 | }; 16 | "UNAUTHORIZED": string; 17 | "USERNAME.OR.PASSWORD.ERROR": string; 18 | }; 19 | "common": { 20 | "CREATE.FAILED": string; 21 | "CREATE.SUCCESS": string; 22 | "DELETE.FAILED": string; 23 | "DELETE.SUCCESS": string; 24 | "DISABLE.FAILED": string; 25 | "DISABLE.SUCCESS": string; 26 | "ENABLE.FAILED": string; 27 | "ENABLE.SUCCESS": string; 28 | "ENABLED": { 29 | "INVALID": string; 30 | "NOT.EMPTY": string; 31 | }; 32 | "ID": { 33 | "INVALID": string; 34 | }; 35 | "KEY": { 36 | "INVALID": string; 37 | "LENGTH": string; 38 | "NO.WHITESPACE": string; 39 | "NOT.EMPTY": string; 40 | }; 41 | "LABEL": { 42 | "INVALID": string; 43 | "LENGTH": string; 44 | "NOT.EMPTY": string; 45 | }; 46 | "LANGUAGE.NOT.SUPPORT": string; 47 | "NS": { 48 | "INVALID": string; 49 | "NO.WHITESPACE": string; 50 | "NOT.EMPTY": string; 51 | }; 52 | "OPERATE.FAILED": string; 53 | "OPERATE.SUCCESS": string; 54 | "REMARK": { 55 | "INVALID": string; 56 | "LENGTH": string; 57 | }; 58 | "RESOURCE.CONFLICT": string; 59 | "RESOURCE.NOT.FOUND": string; 60 | "SORT": { 61 | "INVALID": string; 62 | }; 63 | "UPDATE.FAILED": string; 64 | "UPDATE.SUCCESS": string; 65 | "VALUE": { 66 | "INVALID": string; 67 | "LENGTH": string; 68 | "NO.WHITESPACE": string; 69 | "NOT.EMPTY": string; 70 | }; 71 | }; 72 | "dictionary": { 73 | "CODE": { 74 | "CONFLICT": string; 75 | "INVALID": string; 76 | "LENGTH": string; 77 | "NO.WHITESPACE": string; 78 | "NOT.EMPTY": string; 79 | }; 80 | "ID": { 81 | "INVALID": string; 82 | "NOT.EMPTY": string; 83 | }; 84 | }; 85 | "user": { 86 | "ADDRESS": { 87 | "INVALID": string; 88 | "LENGTH": string; 89 | }; 90 | "AVATAR.URL": { 91 | "INVALID": string; 92 | "LENGTH": string; 93 | }; 94 | "BIOGRAPHY": { 95 | "INVALID": string; 96 | "LENGTH": string; 97 | }; 98 | "BIRTH.DATE": { 99 | "INVALID": string; 100 | }; 101 | "CITY": { 102 | "INVALID": string; 103 | "LENGTH": string; 104 | }; 105 | "COUNTRY": { 106 | "INVALID": string; 107 | "LENGTH": string; 108 | }; 109 | "EMAIL": { 110 | "INVALID": string; 111 | "LENGTH": string; 112 | "NOT.EMPTY": string; 113 | }; 114 | "FIRST.NAME": { 115 | "INVALID": string; 116 | "LENGTH": string; 117 | "NOT.EMPTY": string; 118 | }; 119 | "GENDER": { 120 | "INVALID": string; 121 | "LENGTH": string; 122 | }; 123 | "ID": { 124 | "INVALID": string; 125 | }; 126 | "LAST.NAME": { 127 | "INVALID": string; 128 | "LENGTH": string; 129 | "NOT.EMPTY": string; 130 | }; 131 | "MIDDLE.NAME": { 132 | "INVALID": string; 133 | "LENGTH": string; 134 | "NOT.EMPTY": string; 135 | }; 136 | "NICK.NAME": { 137 | "INVALID": string; 138 | "LENGTH": string; 139 | }; 140 | "PASSWORD": { 141 | "CONTAIN.ONE.DIGITAL.CHARACTER": string; 142 | "CONTAIN.ONE.LETTER": string; 143 | "INVALID": string; 144 | "LENGTH": string; 145 | "NO.WHITESPACE": string; 146 | "NOT.EMPTY": string; 147 | }; 148 | "PHONE.NUMBER": { 149 | "INVALID": string; 150 | "LENGTH": string; 151 | "NOT.EMPTY": string; 152 | }; 153 | "PROFILE": { 154 | "INVALID": string; 155 | "LENGTH": string; 156 | }; 157 | "PROVINCE": { 158 | "INVALID": string; 159 | "LENGTH": string; 160 | }; 161 | "USERNAME": { 162 | "ALREADY.EXIST": string; 163 | "INVALID": string; 164 | "LENGTH": string; 165 | "NO.WHITESPACE": string; 166 | "NOT.EMPTY": string; 167 | "NOT.EXIST": string; 168 | }; 169 | "WEBSITE": { 170 | "INVALID": string; 171 | "LENGTH": string; 172 | }; 173 | }; 174 | }; 175 | export type I18nPath = Path; 176 | -------------------------------------------------------------------------------- /src/guards/access-token.guard.ts: -------------------------------------------------------------------------------- 1 | import type { ExecutionContext } from '@nestjs/common' 2 | import { Injectable, UnauthorizedException } from '@nestjs/common' 3 | import { Reflector } from '@nestjs/core' 4 | import { AuthGuard } from '@nestjs/passport' 5 | import { I18nContext } from 'nestjs-i18n' 6 | 7 | import { SKIP_AUTH } from '@/constants' 8 | import type { I18nTranslations } from '@/generated/i18n.generated' 9 | import type { CustomRequest } from '@/interfaces' 10 | import { OnlineUsersService } from '@/modules/users/online-users.service' 11 | 12 | @Injectable() 13 | export class AccessTokenGuard extends AuthGuard('jwt') { 14 | constructor( 15 | private readonly reflector: Reflector, 16 | private readonly onlineUserService: OnlineUsersService 17 | ) { 18 | super() 19 | } 20 | 21 | async canActivate(context: ExecutionContext) { 22 | const skipAuth = this.reflector.getAllAndOverride(SKIP_AUTH, [ 23 | context.getHandler(), 24 | context.getClass() 25 | ]) 26 | 27 | if (skipAuth) { 28 | return true 29 | } 30 | 31 | if (!(await super.canActivate(context))) { 32 | return false 33 | } 34 | 35 | const { sub, jti } = context.switchToHttp().getRequest().jwtPayload! 36 | this.onlineUserService.create(sub, jti) 37 | 38 | return true 39 | } 40 | 41 | handleRequest(err: any, jwtPayload: any) { 42 | if (err || !jwtPayload) { 43 | const i18n = I18nContext.current()! 44 | throw new UnauthorizedException(i18n.t('auth.UNAUTHORIZED')) 45 | } 46 | return jwtPayload 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './access-token.guard' 2 | export * from './refresh-token.guard' 3 | -------------------------------------------------------------------------------- /src/guards/refresh-token.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { I18nContext } from 'nestjs-i18n' 4 | 5 | import type { I18nTranslations } from '@/generated/i18n.generated' 6 | 7 | @Injectable() 8 | export class RefreshTokenGuard extends AuthGuard('jwt-refresh') { 9 | handleRequest(err: any, jwtPayload: JwtPayload) { 10 | if (err || !jwtPayload) { 11 | const i18n = I18nContext.current()! 12 | throw new UnauthorizedException(i18n.t('auth.UNAUTHORIZED')) 13 | } 14 | return jwtPayload 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/i18n/en-US/auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "LOGIN": { 3 | "SUCCESS": "Login success", 4 | "TYPE.NOT.SUPPORTED": "Login type does not supported" 5 | }, 6 | "LOGOUT": { 7 | "SUCCESS": "Logout success" 8 | }, 9 | "SIGN.UP": { 10 | "SUCCESS": "Sign up success" 11 | }, 12 | "UNAUTHORIZED": "Unauthorized", 13 | "USERNAME.OR.PASSWORD.ERROR": "Username or password error" 14 | } 15 | -------------------------------------------------------------------------------- /src/i18n/en-US/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "CREATE.FAILED": "Create failed", 3 | "CREATE.SUCCESS": "Create success", 4 | "DELETE.FAILED": "Delete failed", 5 | "DELETE.SUCCESS": "Delete success", 6 | "DISABLE.FAILED": "Disable failed", 7 | "DISABLE.SUCCESS": "Disable success", 8 | "ENABLE.FAILED": "Enable failed", 9 | "ENABLE.SUCCESS": "Enable success", 10 | "ENABLED": { 11 | "INVALID": "Enabled:Invalid value", 12 | "NOT.EMPTY": "Enabled: Must not be empty" 13 | }, 14 | "ID": { 15 | "INVALID": "ID:Invalid value" 16 | }, 17 | "KEY": { 18 | "INVALID": "Key: Invalid value", 19 | "LENGTH": "Key: Length cannot exceed 50 characters", 20 | "NO.WHITESPACE": "Key: Must not contain whitespace", 21 | "NOT.EMPTY": "Key: Must not be empty" 22 | }, 23 | "LABEL": { 24 | "INVALID": "Label: Invalid value", 25 | "LENGTH": "Label:Length must not exceed 50 characters", 26 | "NOT.EMPTY": "Label:Must not be empty" 27 | }, 28 | "LANGUAGE.NOT.SUPPORT": "Language not support", 29 | "NS": { 30 | "INVALID": "NS:Invalid value", 31 | "NO.WHITESPACE": "NS: Must not contain whitespace", 32 | "NOT.EMPTY": "NS: Must not be empty" 33 | }, 34 | "OPERATE.FAILED": "Operate failed", 35 | "OPERATE.SUCCESS": "Operate success", 36 | "REMARK": { 37 | "INVALID": "Remark: Invalid value", 38 | "LENGTH": "Remark: Length must not exceed 500 characters" 39 | }, 40 | "RESOURCE.CONFLICT": "Operate failed: Resource conflict", 41 | "RESOURCE.NOT.FOUND": "Operate failed: Resource not found", 42 | "SORT": { 43 | "INVALID": "Sort:Invalid value" 44 | }, 45 | "UPDATE.FAILED": "Update failed", 46 | "UPDATE.SUCCESS": "Update success", 47 | "VALUE": { 48 | "INVALID": "Value: Invalid value", 49 | "LENGTH": "Value: Length cannot exceed 250 characters", 50 | "NO.WHITESPACE": "Value: Must not contain whitespace", 51 | "NOT.EMPTY": "Value: Must not be empty" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/i18n/en-US/dictionary.json: -------------------------------------------------------------------------------- 1 | { 2 | "CODE": { 3 | "CONFLICT": "Code: Already exists", 4 | "INVALID": "Code:Invalid value", 5 | "LENGTH": "Code: Length cannot exceed 50 characters", 6 | "NO.WHITESPACE": "Code:Must not contain whitespace", 7 | "NOT.EMPTY": "Code:Must not be empty" 8 | }, 9 | "ID": { 10 | "INVALID": "Dictionary ID:Invalid value", 11 | "NOT.EMPTY": "Dictionary ID:Must not be empty" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/i18n/en-US/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "ADDRESS": { 3 | "INVALID": "Address: Invalid value", 4 | "LENGTH": "Address: Length must no more than 100 characters" 5 | }, 6 | "AVATAR.URL": { 7 | "INVALID": "Avatar URL: Invalid format", 8 | "LENGTH": "Avatar URL: Length must no more than 100 characters" 9 | }, 10 | "BIOGRAPHY": { 11 | "INVALID": "Biography: Invalid value", 12 | "LENGTH": "Biography: Length must no more than 500 characters" 13 | }, 14 | "BIRTH.DATE": { 15 | "INVALID": "Birth day: Invalid format" 16 | }, 17 | "CITY": { 18 | "INVALID": "City: Invalid value", 19 | "LENGTH": "City: Length must no more than 25 characters" 20 | }, 21 | "COUNTRY": { 22 | "INVALID": "Country: Invalid value", 23 | "LENGTH": "Country: Length must no more than 25 characters" 24 | }, 25 | "EMAIL": { 26 | "INVALID": "Email: Invalid format", 27 | "LENGTH": "Email: Length must no more than 50 characters", 28 | "NOT.EMPTY": "Email: Must not be empty" 29 | }, 30 | "FIRST.NAME": { 31 | "INVALID": "First name: Invalid value", 32 | "LENGTH": "First name: Length must no more than 10 characters", 33 | "NOT.EMPTY": "First name: Must not be empty" 34 | }, 35 | "GENDER": { 36 | "INVALID": "Avatar URL: Invalid value", 37 | "LENGTH": "Avatar URL: Length must no more than 10 characters" 38 | }, 39 | "ID": { 40 | "INVALID": "User ID: Invalid value" 41 | }, 42 | "LAST.NAME": { 43 | "INVALID": "Last name: Invalid value", 44 | "LENGTH": "Last name: Length must no more than 10 characters", 45 | "NOT.EMPTY": "Last name: Must not be empty" 46 | }, 47 | "MIDDLE.NAME": { 48 | "INVALID": "Middle name: Invalid value", 49 | "LENGTH": "Middle name: Length must no more than 10 characters", 50 | "NOT.EMPTY": "Middle name: Must not be empty" 51 | }, 52 | "NICK.NAME": { 53 | "INVALID": "Nick name: Invalid value", 54 | "LENGTH": "Nick name: Length must no more than 10 characters" 55 | }, 56 | "PASSWORD": { 57 | "CONTAIN.ONE.DIGITAL.CHARACTER": "Password: Must contain at least one digital character", 58 | "CONTAIN.ONE.LETTER": "Password: Must contain at least one letter", 59 | "INVALID": "Password: Invalid value", 60 | "LENGTH": "Password: Length must be between 6 and 16", 61 | "NO.WHITESPACE": "Password: Must not contain whitespace", 62 | "NOT.EMPTY": "Password: Must not be empty" 63 | }, 64 | "PHONE.NUMBER": { 65 | "INVALID": "Phone number: Invalid format", 66 | "LENGTH": "Phone number: Length must no more than 25 characters", 67 | "NOT.EMPTY": "Phone number: Must not be empty" 68 | }, 69 | "PROFILE": { 70 | "INVALID": "Profile: Invalid format", 71 | "LENGTH": "Profile: Length must no more than 50 characters" 72 | }, 73 | "PROVINCE": { 74 | "INVALID": "Province: Invalid value", 75 | "LENGTH": "Province: Length must no more than 25 characters" 76 | }, 77 | "USERNAME": { 78 | "ALREADY.EXIST": "Username: Already exist", 79 | "INVALID": "Username: Invalid value", 80 | "LENGTH": "Username: Length must be between 4 and 16", 81 | "NO.WHITESPACE": "Username: Must not contain whitespace", 82 | "NOT.EMPTY": "Username: Must not be empty", 83 | "NOT.EXIST": "Username: Does not exist" 84 | }, 85 | "WEBSITE": { 86 | "INVALID": "Website: Invalid format", 87 | "LENGTH": "Website: Length must no more than 50 characters" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/i18n/zh-CN/auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "LOGIN": { 3 | "SUCCESS": "登录成功", 4 | "TYPE.NOT.SUPPORTED": "不支持的登录类型" 5 | }, 6 | "LOGOUT": { 7 | "SUCCESS": "登出成功" 8 | }, 9 | "SIGN.UP": { 10 | "SUCCESS": "注册成功" 11 | }, 12 | "UNAUTHORIZED": "认证失败", 13 | "USERNAME.OR.PASSWORD.ERROR": "用户名或密码错误" 14 | } 15 | -------------------------------------------------------------------------------- /src/i18n/zh-CN/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "CREATE.FAILED": "创建失败", 3 | "CREATE.SUCCESS": "创建成功", 4 | "DELETE.FAILED": "删除失败", 5 | "DELETE.SUCCESS": "删除成功", 6 | "DISABLE.FAILED": "禁用失败", 7 | "DISABLE.SUCCESS": "禁用成功", 8 | "ENABLE.FAILED": "启用失败", 9 | "ENABLE.SUCCESS": "启用成功", 10 | "ENABLED": { 11 | "INVALID": "是否启用:输入值不合法", 12 | "NOT.EMPTY": "是否启用:不能为空" 13 | }, 14 | "ID": { 15 | "INVALID": "ID:输入值不合法" 16 | }, 17 | "KEY": { 18 | "INVALID": "键:输入值不合法", 19 | "LENGTH": "键: 长度不能超过 50 个字符", 20 | "NO.WHITESPACE": "键:不能包含空格", 21 | "NOT.EMPTY": "键:不能为空" 22 | }, 23 | "LABEL": { 24 | "INVALID": "名称:输入值不合法", 25 | "LENGTH": "名称:长度不能超过 50 个字符", 26 | "NOT.EMPTY": "名称:不能为空" 27 | }, 28 | "LANGUAGE.NOT.SUPPORT": "不支持该语言", 29 | "NS": { 30 | "INVALID": "命名空间:输入值不合法", 31 | "NO.WHITESPACE": "命名空间:不能包含空格", 32 | "NOT.EMPTY": "命名空间:不能为空" 33 | }, 34 | "OPERATE.FAILED": "操作失败", 35 | "OPERATE.SUCCESS": "操作成功", 36 | "REMARK": { 37 | "INVALID": "备注:输入值不合法", 38 | "LENGTH": "备注:长度不能超过 500 个字符" 39 | }, 40 | "RESOURCE.CONFLICT": "操作失败:资源冲突", 41 | "RESOURCE.NOT.FOUND": "操作失败:资源不存在", 42 | "SORT": { 43 | "INVALID": "排序:输入值不合法" 44 | }, 45 | "UPDATE.FAILED": "修改失败", 46 | "UPDATE.SUCCESS": "修改成功", 47 | "VALUE": { 48 | "INVALID": "值:输入值不合法", 49 | "LENGTH": "值:长度不能超过 250 个字符", 50 | "NO.WHITESPACE": "值:不能包含空格", 51 | "NOT.EMPTY": "值:不能为空" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/i18n/zh-CN/dictionary.json: -------------------------------------------------------------------------------- 1 | { 2 | "CODE": { 3 | "CONFLICT": "字典编码:已存在", 4 | "INVALID": "字典编码:输入值不合法", 5 | "LENGTH": "字典编码: 长度不能超过 50 个字符", 6 | "NO.WHITESPACE": "字典编码:不能包含空格", 7 | "NOT.EMPTY": "字典编码:不能为空" 8 | }, 9 | "ID": { 10 | "INVALID": "字典 ID:输入值不合法", 11 | "NOT.EMPTY": "字典 ID:不能为空" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/i18n/zh-CN/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "ADDRESS": { 3 | "INVALID": "地址:输入值不合法", 4 | "LENGTH": "地址:不能超过 100 个字符" 5 | }, 6 | "AVATAR.URL": { 7 | "INVALID": "头像 URL: 格式不正确", 8 | "LENGTH": "头像 URL: 不能超过 100 个字符" 9 | }, 10 | "BIOGRAPHY": { 11 | "INVALID": "个人简介:输入值不合法", 12 | "LENGTH": "个人简介:不能超过 500 个字符" 13 | }, 14 | "BIRTH.DATE": { 15 | "INVALID": "出生日期:格式不正确" 16 | }, 17 | "CITY": { 18 | "INVALID": "城市:输入值不合法", 19 | "LENGTH": "城市:不能超过 25 个字符" 20 | }, 21 | "COUNTRY": { 22 | "INVALID": "国家:输入值不合法", 23 | "LENGTH": "国家:不能超过 25 个字符" 24 | }, 25 | "EMAIL": { 26 | "INVALID": "邮箱:格式不正确", 27 | "LENGTH": "邮箱:长度不能超过 50 个字符", 28 | "NOT.EMPTY": "邮箱:不能为空" 29 | }, 30 | "FIRST.NAME": { 31 | "INVALID": "名:输入值不合法", 32 | "LENGTH": "名:不能超过 10 个字符", 33 | "NOT.EMPTY": "名:不能为空" 34 | }, 35 | "GENDER": { 36 | "INVALID": "性别:输入值不合法", 37 | "LENGTH": "性别:不能超过 10 个字符" 38 | }, 39 | "ID": { 40 | "INVALID": "用户 ID: 输入值不合法" 41 | }, 42 | "LAST.NAME": { 43 | "INVALID": "姓:输入值不合法", 44 | "LENGTH": "姓:不能超过 10 个字符", 45 | "NOT.EMPTY": "姓:不能为空" 46 | }, 47 | "MIDDLE.NAME": { 48 | "INVALID": "中间名:输入值不合法", 49 | "LENGTH": "中间名:不能超过 10 个字符", 50 | "NOT.EMPTY": "中间名:不能为空" 51 | }, 52 | "NICK.NAME": { 53 | "INVALID": "昵称:输入值不合法", 54 | "LENGTH": "昵称:不能超过 16 个字符" 55 | }, 56 | "PASSWORD": { 57 | "CONTAIN.ONE.DIGITAL.CHARACTER": "密码:必须至少包含一个数字", 58 | "CONTAIN.ONE.LETTER": "密码:必须至少包含一个英文字母", 59 | "INVALID": "密码:输入值不合法", 60 | "LENGTH": "密码:长度范围为 4 ~ 16 位", 61 | "NO.WHITESPACE": "密码:不能包含空格", 62 | "NOT.EMPTY": "密码:不能为空" 63 | }, 64 | "PHONE.NUMBER": { 65 | "INVALID": "手机号:格式不正确", 66 | "LENGTH": "手机号:长度不能超过 25 个字符", 67 | "NOT.EMPTY": "手机号:不能为空" 68 | }, 69 | "PROFILE": { 70 | "INVALID": "个人主页:格式不正确", 71 | "LENGTH": "个人主页:不能超过 50 个字符" 72 | }, 73 | "PROVINCE": { 74 | "INVALID": "省份:输入值不合法", 75 | "LENGTH": "省份:不能超过 25 个字符" 76 | }, 77 | "USERNAME": { 78 | "ALREADY.EXIST": "用户名:已存在", 79 | "INVALID": "用户名:输入值不合法", 80 | "LENGTH": "用户名:长度范围为 4 ~ 16 位", 81 | "NO.WHITESPACE": "用户名:不能包含空格", 82 | "NOT.EMPTY": "用户名:不能为空", 83 | "NOT.EXIST": "用户名:不存在" 84 | }, 85 | "WEBSITE": { 86 | "INVALID": "个人网站:格式不正确", 87 | "LENGTH": "个人网站:不能超过 50 个字符" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/interceptors/errors.interceptor.ts: -------------------------------------------------------------------------------- 1 | import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common' 2 | import { Injectable } from '@nestjs/common' 3 | import { stdout } from 'process' 4 | import type { Observable } from 'rxjs' 5 | import { throwError } from 'rxjs' 6 | import { catchError } from 'rxjs/operators' 7 | 8 | @Injectable() 9 | export class ErrorsInterceptor implements NestInterceptor { 10 | intercept(_context: ExecutionContext, next: CallHandler): Observable { 11 | return next.handle().pipe( 12 | catchError((err) => { 13 | const { msg, error, statusCode } = err?.response ?? {} 14 | stdout.write(`调用失败:${statusCode} ${error}\n`) 15 | stdout.write(`错误信息:${msg}\n`) 16 | return throwError(() => err) 17 | }) 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/interceptors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './errors.interceptor' 2 | export * from './logging.interceptor' 3 | -------------------------------------------------------------------------------- /src/interceptors/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common' 2 | import { Inject, Injectable } from '@nestjs/common' 3 | import { ConfigType } from '@nestjs/config' 4 | import type { Observable } from 'rxjs' 5 | import { tap } from 'rxjs/operators' 6 | 7 | import { DevConfig } from '@/configs' 8 | 9 | // import type { CustomRequest } from '../interfaces' 10 | 11 | @Injectable() 12 | export class LoggingInterceptor implements NestInterceptor { 13 | constructor(@Inject(DevConfig.KEY) private readonly devConfig: ConfigType) {} 14 | 15 | intercept(context: ExecutionContext, next: CallHandler): Observable { 16 | const { enableRequestLog } = this.devConfig 17 | if (!enableRequestLog) { 18 | return next.handle() 19 | } 20 | // const request = context.switchToHttp().getRequest() 21 | // console.log(`开始调用:${request.method}:${request.url}`) 22 | // console.log('Request Queries:', request.query) 23 | // console.log('Request Parameters:', request.params) 24 | // console.log('Request Body:', request.body) 25 | 26 | // const now = Date.now() 27 | return next.handle().pipe( 28 | tap(() => 29 | // console.log(`执行了 ${(Number(Date.now() - now) / 1000).toFixed(1)}s`) 30 | {} 31 | ) 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/interfaces/custom-request.interface.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from 'express' 2 | 3 | import type { JwtPayload } from './jwt-payload.interface' 4 | 5 | export interface CustomRequest extends Request { 6 | jwtPayload?: JwtPayload 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './custom-request.interface' 2 | export * from './jwt-payload.interface' 3 | -------------------------------------------------------------------------------- /src/interfaces/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JwtPayload { 2 | sub: number 3 | jti: string 4 | iat?: string 5 | exp?: string 6 | } 7 | -------------------------------------------------------------------------------- /src/maps/file-extension.ts: -------------------------------------------------------------------------------- 1 | // 文件扩展名映射 2 | export const fileExtensionMap = new Map([ 3 | ['text/plain', '.txt'], 4 | ['image/jpeg', '.jpg'], 5 | ['image/png', '.png'], 6 | ['image/gif', '.gif'], 7 | ['image/bmp', '.bmp'], 8 | ['image/webp', '.webp'], 9 | ['image/svg+xml', '.svg'], 10 | ['image/vnd.microsoft.icon', '.ico'], 11 | ['application/pdf', '.pdf'], 12 | ['application/msword', '.doc'], 13 | ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.docx'], 14 | ['application/vnd.ms-excel', '.xls'], 15 | ['application/vnd.openxmlformats-officedocument.spreadsheet.sheet', '.xlsx'], 16 | ['application/vnd.ms-powerpoint', '.ppt'], 17 | ['application/vnd.openxmlformats-officedocument.presentational.presentation', '.pptx'], 18 | ['application/zip', '.zip'] 19 | ]) 20 | -------------------------------------------------------------------------------- /src/maps/index.ts: -------------------------------------------------------------------------------- 1 | export * from './file-extension' 2 | -------------------------------------------------------------------------------- /src/middlewares/delay.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, type NestMiddleware } from '@nestjs/common' 2 | import { ConfigType } from '@nestjs/config' 3 | import type { NextFunction, Request, Response } from 'express' 4 | 5 | import { AppConfig, DevConfig } from '@/configs' 6 | 7 | @Injectable() 8 | export class DelayMiddleware implements NestMiddleware { 9 | constructor( 10 | @Inject(AppConfig.KEY) private readonly appConfig: ConfigType, 11 | @Inject(DevConfig.KEY) private readonly devConfig: ConfigType 12 | ) {} 13 | 14 | use(_req: Request, _res: Response, next: NextFunction) { 15 | const { isDEV } = this.appConfig 16 | const { delaySeconds } = this.devConfig 17 | if (!isDEV) { 18 | next() 19 | } else { 20 | setTimeout(next, delaySeconds * 1000) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './delay.middleware' 2 | -------------------------------------------------------------------------------- /src/modules/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Render } from '@nestjs/common' 2 | import { ApiOperation, ApiTags } from '@nestjs/swagger' 3 | 4 | import { R } from '@/class' 5 | import { SkipAuth } from '@/decorators' 6 | 7 | import { AppService } from './app.service' 8 | 9 | @ApiTags('应用') 10 | @SkipAuth() 11 | @Controller() 12 | export class AppController { 13 | constructor(private readonly appService: AppService) {} 14 | 15 | @ApiOperation({ summary: '应用首页' }) 16 | @Render('index') 17 | @Get() 18 | getApp() { 19 | return { 20 | title: 'Dolphin Admin Nest' 21 | } 22 | } 23 | 24 | @ApiOperation({ summary: '应用信息' }) 25 | @Get('info') 26 | getVersion() { 27 | return new R({ 28 | data: this.appService.getAppInfo() 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common' 2 | import { ConfigType } from '@nestjs/config' 3 | 4 | import { AppConfig } from '@/configs' 5 | 6 | @Injectable() 7 | export class AppService { 8 | constructor(@Inject(AppConfig.KEY) private readonly appConfig: ConfigType) {} 9 | 10 | getAppInfo() { 11 | return { 12 | name: this.appConfig.name, 13 | version: this.appConfig.version 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Inject, 5 | NotImplementedException, 6 | ParseEnumPipe, 7 | Post, 8 | Query, 9 | Req, 10 | UseGuards 11 | } from '@nestjs/common' 12 | import { ConfigType } from '@nestjs/config' 13 | import { ApiBody, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger' 14 | import { SkipThrottle } from '@nestjs/throttler' 15 | import { I18n, I18nContext } from 'nestjs-i18n' 16 | 17 | import { R } from '@/class' 18 | import { AppConfig } from '@/configs' 19 | import { 20 | ApiCreatedObjectResponse, 21 | ApiOkObjectResponse, 22 | ApiOkResponse, 23 | Jwt, 24 | SkipAuth 25 | } from '@/decorators' 26 | import { LoginType } from '@/enums' 27 | import type { I18nTranslations } from '@/generated/i18n.generated' 28 | import { RefreshTokenGuard } from '@/guards' 29 | import { CustomRequest, JwtPayload } from '@/interfaces' 30 | 31 | import { AuthService } from './auth.service' 32 | import { LoginDto, SignupDto } from './dto' 33 | import { TokenVo } from './vo' 34 | 35 | @ApiTags('认证') 36 | @Controller('auth') 37 | export class AuthController { 38 | constructor( 39 | private readonly authService: AuthService, 40 | @Inject(AppConfig.KEY) private readonly appConfig: ConfigType 41 | ) {} 42 | 43 | @ApiOperation({ summary: '注册' }) 44 | @ApiCreatedObjectResponse(TokenVo) 45 | @SkipThrottle() 46 | @SkipAuth() 47 | @Post('signup') 48 | async signup( 49 | @Body() signupDto: SignupDto, 50 | @I18n() i18n: I18nContext, 51 | @Req() req: CustomRequest 52 | ) { 53 | return new R({ 54 | data: await this.authService.signup(signupDto, req), 55 | msg: i18n.t('auth.SIGN.UP.SUCCESS') 56 | }) 57 | } 58 | 59 | @ApiOperation({ summary: '登录' }) 60 | @ApiOkObjectResponse(TokenVo) 61 | @ApiQuery({ 62 | name: 'type', 63 | description: '登录类型:[1] 用户名,[2] 邮箱', 64 | required: true, 65 | type: 'string', 66 | example: LoginType.USERNAME 67 | }) 68 | @ApiBody({ 69 | type: LoginDto, 70 | examples: { admin: { value: { username: 'admin', password: '123456' } } } 71 | }) 72 | @SkipThrottle() 73 | @SkipAuth() 74 | @Post('login') 75 | async login( 76 | @Query( 77 | 'type', 78 | new ParseEnumPipe(LoginType, { 79 | exceptionFactory: () => { 80 | const i18n = I18nContext.current()! 81 | throw new NotImplementedException(i18n.t('auth.LOGIN.TYPE.NOT.SUPPORTED')) 82 | } 83 | }) 84 | ) 85 | type: string, 86 | @Body() loginDto: LoginDto, 87 | @I18n() i18n: I18nContext, 88 | @Req() req: CustomRequest 89 | ) { 90 | return new R({ 91 | data: await this.authService.login(loginDto, type, req), 92 | msg: i18n.t('auth.LOGIN.SUCCESS') 93 | }) 94 | } 95 | 96 | @ApiOperation({ summary: '登出' }) 97 | @ApiOkResponse() 98 | @Post('logout') 99 | async logout(@Jwt('jti') jti: string, @I18n() i18n: I18nContext) { 100 | await this.authService.logout(jti) 101 | return new R({ 102 | msg: i18n.t('auth.LOGOUT.SUCCESS') 103 | }) 104 | } 105 | 106 | @ApiOperation({ summary: '强制下线' }) 107 | @ApiOkResponse() 108 | @ApiQuery({ name: 'jti', description: 'Jwt ID', required: true, type: 'string' }) 109 | @Post('force-logout') 110 | async forceLogout(@Query('jti') jti: string, @I18n() i18n: I18nContext) { 111 | await this.authService.logout(jti) 112 | return new R({ 113 | msg: i18n.t('common.OPERATE.SUCCESS') 114 | }) 115 | } 116 | 117 | @ApiOperation({ summary: '刷新令牌' }) 118 | @ApiOkObjectResponse(TokenVo) 119 | @ApiQuery({ name: 'token', description: '刷新令牌', required: true, type: 'string' }) 120 | @SkipThrottle() 121 | @SkipAuth() 122 | @UseGuards(RefreshTokenGuard) 123 | @Post('refresh') 124 | async refreshTokens(@Jwt() jwtPayload: JwtPayload) { 125 | const data = await this.authService.refreshTokens(jwtPayload) 126 | return new R({ 127 | data 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { PassportModule } from '@nestjs/passport' 3 | 4 | import { UsersModule } from '@/modules/users/users.module' 5 | 6 | import { AuthController } from './auth.controller' 7 | import { AuthService } from './auth.service' 8 | import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies' 9 | 10 | @Module({ 11 | imports: [PassportModule, UsersModule], 12 | controllers: [AuthController], 13 | providers: [AuthService, AccessTokenStrategy, RefreshTokenStrategy], 14 | exports: [AuthService] 15 | }) 16 | export class AuthModule {} 17 | -------------------------------------------------------------------------------- /src/modules/auth/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login.dto' 2 | export * from './signup.dto' 3 | -------------------------------------------------------------------------------- /src/modules/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsNotEmpty, IsString, Length, NotContains } from 'class-validator' 3 | 4 | import { I18nUtils } from '@/utils' 5 | 6 | const { t } = I18nUtils 7 | 8 | export class LoginDto { 9 | @ApiProperty({ description: '用户名' }) 10 | @Length(4, 16, { message: t('user.USERNAME.LENGTH') }) 11 | @NotContains(' ', { message: t('user.USERNAME.NO.WHITESPACE') }) 12 | @IsString({ message: t('user.USERNAME.INVALID') }) 13 | @IsNotEmpty({ message: t('user.USERNAME.NOT.EMPTY') }) 14 | username: string 15 | 16 | @ApiProperty({ description: '密码' }) 17 | @Length(6, 16, { message: t('user.PASSWORD.LENGTH') }) 18 | @NotContains(' ', { message: t('user.PASSWORD.NO.WHITESPACE') }) 19 | @IsString({ message: t('user.PASSWORD.INVALID') }) 20 | @IsNotEmpty({ message: t('user.PASSWORD.NOT.EMPTY') }) 21 | password: string 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/auth/dto/signup.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsNotEmpty, IsString, Length, Matches, MaxLength, NotContains } from 'class-validator' 3 | 4 | import { CreateUserDto } from '@/modules/users/dto' 5 | import { I18nUtils } from '@/utils' 6 | 7 | const { t } = I18nUtils 8 | 9 | export class SignupDto extends CreateUserDto { 10 | @ApiProperty({ description: '用户名' }) 11 | @Length(4, 16, { message: t('user.USERNAME.LENGTH') }) 12 | @NotContains(' ', { message: t('user.USERNAME.NO.WHITESPACE') }) 13 | @IsString({ message: t('user.USERNAME.INVALID') }) 14 | @IsNotEmpty({ message: t('user.USERNAME.NOT.EMPTY') }) 15 | readonly username: string 16 | 17 | @ApiProperty({ description: '密码' }) 18 | @Matches(/[0-9]/, { message: t('user.PASSWORD.CONTAIN.ONE.DIGITAL.CHARACTER') }) 19 | @Matches(/[a-zA-Z]/, { message: t('user.PASSWORD.CONTAIN.ONE.LETTER') }) 20 | @Length(6, 16, { message: t('user.PASSWORD.LENGTH') }) 21 | @NotContains(' ', { message: t('user.PASSWORD.NO.WHITESPACE') }) 22 | @IsString({ message: t('user.PASSWORD.INVALID') }) 23 | @IsNotEmpty({ message: t('user.PASSWORD.NOT.EMPTY') }) 24 | readonly password: string 25 | 26 | @ApiProperty({ description: '名' }) 27 | @MaxLength(10, { message: t('user.FIRST.NAME.INVALID') }) 28 | @IsString({ message: t('user.FIRST.NAME.INVALID') }) 29 | @IsNotEmpty({ message: t('user.FIRST.NAME.INVALID') }) 30 | readonly firstName: string 31 | 32 | @ApiProperty({ description: '姓' }) 33 | @MaxLength(10, { message: t('user.LAST.NAME.INVALID') }) 34 | @IsString({ message: t('user.LAST.NAME.INVALID') }) 35 | @IsNotEmpty({ message: t('user.LAST.NAME.NOT.EMPTY') }) 36 | readonly lastName: string 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/auth/strategies/access-token.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common' 2 | import { ConfigType } from '@nestjs/config' 3 | import { PassportStrategy } from '@nestjs/passport' 4 | import { ExtractJwt, Strategy } from 'passport-jwt' 5 | 6 | import { JwtConfig } from '@/configs' 7 | import type { CustomRequest, JwtPayload } from '@/interfaces' 8 | import { CacheKeyService } from '@/shared/redis/cache-key.service' 9 | import { RedisService } from '@/shared/redis/redis.service' 10 | 11 | @Injectable() 12 | export class AccessTokenStrategy extends PassportStrategy(Strategy, 'jwt') { 13 | constructor( 14 | private readonly redisService: RedisService, 15 | private readonly cacheKeyService: CacheKeyService, 16 | @Inject(JwtConfig.KEY) readonly jwtConfig: ConfigType 17 | ) { 18 | super({ 19 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 20 | ignoreExpiration: false, 21 | secretOrKey: jwtConfig.accessTokenSecret, 22 | passReqToCallback: true 23 | }) 24 | } 25 | 26 | async validate(req: CustomRequest, jwtPayload: JwtPayload) { 27 | if (!jwtPayload.jti) { 28 | return false 29 | } 30 | 31 | const cacheKey = this.cacheKeyService.getJwtMetadataCacheKey(jwtPayload.jti) 32 | if (!(await this.redisService.exists(cacheKey))) { 33 | return false 34 | } 35 | 36 | req.jwtPayload = jwtPayload 37 | 38 | return jwtPayload 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/auth/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './access-token.strategy' 2 | export * from './refresh-token.strategy' 3 | -------------------------------------------------------------------------------- /src/modules/auth/strategies/refresh-token.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common' 2 | import { ConfigType } from '@nestjs/config' 3 | import { PassportStrategy } from '@nestjs/passport' 4 | import { ExtractJwt, Strategy } from 'passport-jwt' 5 | 6 | import { JwtConfig } from '@/configs' 7 | import type { CustomRequest, JwtPayload } from '@/interfaces' 8 | import { CacheKeyService } from '@/shared/redis/cache-key.service' 9 | import { RedisService } from '@/shared/redis/redis.service' 10 | 11 | @Injectable() 12 | export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { 13 | constructor( 14 | private readonly redisService: RedisService, 15 | private readonly cacheKeyService: CacheKeyService, 16 | @Inject(JwtConfig.KEY) readonly jwtConfig: ConfigType 17 | ) { 18 | super({ 19 | jwtFromRequest: ExtractJwt.fromUrlQueryParameter('token'), 20 | ignoreExpiration: false, 21 | secretOrKey: jwtConfig.refreshTokenSecret, 22 | passReqToCallback: true 23 | }) 24 | } 25 | 26 | async validate(req: CustomRequest, jwtPayload: JwtPayload) { 27 | if (!jwtPayload.jti) { 28 | return false 29 | } 30 | 31 | const cacheKey = this.cacheKeyService.getJwtMetadataCacheKey(jwtPayload.jti) 32 | if (!(await this.redisService.exists(cacheKey))) { 33 | return false 34 | } 35 | 36 | req.jwtPayload = jwtPayload 37 | 38 | return jwtPayload 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/auth/vo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './token.vo' 2 | -------------------------------------------------------------------------------- /src/modules/auth/vo/token.vo.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | export class TokenVo { 4 | @ApiProperty({ description: '访问令牌' }) 5 | accessToken: string 6 | 7 | @ApiProperty({ description: '刷新令牌' }) 8 | refreshToken: string 9 | 10 | constructor(tokenVo?: Partial) { 11 | Object.assign(this, tokenVo) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/dictionaries/dictionaries.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseArrayPipe, 8 | ParseIntPipe, 9 | Patch, 10 | Post, 11 | Put, 12 | Query 13 | } from '@nestjs/common' 14 | import { ApiBearerAuth, ApiExtraModels, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger' 15 | import { I18n, I18nContext } from 'nestjs-i18n' 16 | 17 | import { R } from '@/class' 18 | import { 19 | ApiCreatedObjectResponse, 20 | ApiOkObjectResponse, 21 | ApiOkResponse, 22 | ApiPageOKResponse, 23 | ApiPageQuery, 24 | Jwt 25 | } from '@/decorators' 26 | import { ApiOkArrayResponse } from '@/decorators/swagger/api-ok-array-response.decorator' 27 | import type { I18nTranslations } from '@/generated/i18n.generated' 28 | 29 | import { DictionariesService } from './dictionaries.service' 30 | import { 31 | CreateDictionaryDto, 32 | PageDictionaryDto, 33 | PatchDictionaryDto, 34 | UpdateDictionaryDto 35 | } from './dto' 36 | import { DictionaryVo, ListDictionarySelectItemVo } from './vo' 37 | import { DictionarySelectItemVo } from './vo/dictionary-select-item.vo' 38 | 39 | @ApiTags('字典') 40 | @ApiBearerAuth('bearer') 41 | @Controller('dictionaries') 42 | export class DictionariesController { 43 | constructor(private readonly dictionariesService: DictionariesService) {} 44 | 45 | @ApiOperation({ summary: '创建字典' }) 46 | @ApiCreatedObjectResponse(DictionaryVo) 47 | @Post() 48 | async create( 49 | @Body() createDictionaryDto: CreateDictionaryDto, 50 | @Jwt('sub') userId: number, 51 | @I18n() i18n: I18nContext 52 | ) { 53 | return new R({ 54 | data: await this.dictionariesService.create(createDictionaryDto, userId), 55 | msg: i18n.t('common.CREATE.SUCCESS') 56 | }) 57 | } 58 | 59 | @ApiOperation({ summary: '字典列表' }) 60 | @ApiPageOKResponse(DictionaryVo) 61 | @ApiPageQuery('keywords', 'date') 62 | @Get() 63 | async findMany(@Query() pageDictionaryDto: PageDictionaryDto) { 64 | return new R({ 65 | data: await this.dictionariesService.findMany(pageDictionaryDto) 66 | }) 67 | } 68 | 69 | @ApiOperation({ summary: '字典详情 [ID]' }) 70 | @ApiOkObjectResponse(DictionaryVo) 71 | @Get(':id(\\d+)') 72 | async findOneById(@Param('id') id: number) { 73 | return new R({ 74 | data: await this.dictionariesService.findOneById(id) 75 | }) 76 | } 77 | 78 | @ApiOperation({ summary: '字典数据 [Codes]' }) 79 | @ApiOkArrayResponse(ListDictionarySelectItemVo) 80 | @ApiExtraModels(DictionarySelectItemVo) 81 | @ApiQuery({ 82 | name: 'codes', 83 | description: '字典编码(多个编码用逗号分隔)', 84 | type: String, 85 | required: true, 86 | example: 'AUTH_TYPE,GENDER' 87 | }) 88 | @Get('codes') 89 | async findManyByCodes( 90 | @Query('codes', new ParseArrayPipe({ items: String, separator: ',' })) codes: string[] 91 | ) { 92 | return new R({ 93 | data: await this.dictionariesService.findManyByCodes(codes) 94 | }) 95 | } 96 | 97 | @ApiOperation({ summary: '字典数据 [Code]' }) 98 | @ApiOkObjectResponse(ListDictionarySelectItemVo) 99 | @Get(':code') 100 | async findOneByCode(@Param('code') code: string) { 101 | return new R({ 102 | data: await this.dictionariesService.findOneByCode(code) 103 | }) 104 | } 105 | 106 | @ApiOperation({ summary: '更新字典' }) 107 | @ApiOkObjectResponse(DictionaryVo) 108 | @Put(':id(\\d+)') 109 | async update( 110 | @Param('id', new ParseIntPipe()) id: number, 111 | @Body() updateDictionaryDto: UpdateDictionaryDto, 112 | @Jwt('sub') userId: number, 113 | @I18n() i18n: I18nContext 114 | ) { 115 | return new R({ 116 | data: await this.dictionariesService.update(id, updateDictionaryDto, userId), 117 | msg: i18n.t('common.OPERATE.SUCCESS') 118 | }) 119 | } 120 | 121 | @ApiOperation({ summary: '部分更新' }) 122 | @ApiOkObjectResponse(DictionaryVo) 123 | @Patch(':id(\\d+)') 124 | async patch( 125 | @Param('id', new ParseIntPipe()) id: number, 126 | @Body() patchDictionaryDto: PatchDictionaryDto, 127 | @Jwt('sub') userId: number, 128 | @I18n() i18n: I18nContext 129 | ) { 130 | return new R({ 131 | data: await this.dictionariesService.update(id, patchDictionaryDto, userId), 132 | msg: i18n.t('common.OPERATE.SUCCESS') 133 | }) 134 | } 135 | 136 | @ApiOperation({ summary: '删除字典' }) 137 | @ApiOkResponse() 138 | @Delete(':id(\\d+)') 139 | async remove( 140 | @Param('id', new ParseIntPipe()) id: number, 141 | @Jwt('sub') userId: number, 142 | @I18n() i18n: I18nContext 143 | ) { 144 | await this.dictionariesService.remove(id, userId) 145 | return new R({ 146 | msg: i18n.t('common.DELETE.SUCCESS') 147 | }) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/modules/dictionaries/dictionaries.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { DictionariesController } from './dictionaries.controller' 4 | import { DictionariesService } from './dictionaries.service' 5 | 6 | @Module({ 7 | controllers: [DictionariesController], 8 | providers: [DictionariesService] 9 | }) 10 | export class DictionariesModule {} 11 | -------------------------------------------------------------------------------- /src/modules/dictionaries/dto/create-dictionary.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { 3 | IsBoolean, 4 | IsNotEmpty, 5 | IsNumber, 6 | IsOptional, 7 | IsString, 8 | MaxLength, 9 | NotContains 10 | } from 'class-validator' 11 | 12 | import { I18nUtils } from '@/utils' 13 | 14 | const { t } = I18nUtils 15 | 16 | export class CreateDictionaryDto { 17 | @ApiProperty({ description: '字典编码' }) 18 | @MaxLength(50, { message: t('dictionary.CODE.LENGTH') }) 19 | @NotContains(' ', { message: t('dictionary.CODE.NO.WHITESPACE') }) 20 | @IsString({ message: t('dictionary.CODE.INVALID') }) 21 | @IsNotEmpty({ message: t('dictionary.CODE.NOT.EMPTY') }) 22 | code: string 23 | 24 | @ApiProperty({ description: '名称' }) 25 | @MaxLength(50, { message: t('common.LABEL.LENGTH') }) 26 | @IsString({ message: t('common.LABEL.INVALID') }) 27 | @IsNotEmpty({ message: t('common.LABEL.NOT.EMPTY') }) 28 | label: string 29 | 30 | @ApiPropertyOptional({ description: '备注' }) 31 | @MaxLength(500, { message: t('common.REMARK.LENGTH') }) 32 | @IsString({ message: t('common.REMARK.INVALID') }) 33 | @IsOptional() 34 | remark?: string 35 | 36 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 37 | @IsNotEmpty({ message: t('common.ENABLED.NOT.EMPTY') }) 38 | @ApiProperty({ description: '是否启用' }) 39 | enabled: boolean 40 | 41 | @ApiPropertyOptional({ description: '排序' }) 42 | @IsNumber({}, { message: t('common.SORT.INVALID') }) 43 | @IsOptional() 44 | sort?: number 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/dictionaries/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-dictionary.dto' 2 | export * from './page-dictionary.dto' 3 | export * from './patch-dictionary.dto' 4 | export * from './update-dictionary.dto' 5 | -------------------------------------------------------------------------------- /src/modules/dictionaries/dto/page-dictionary.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator' 3 | 4 | import { PageDto } from '@/class' 5 | import { ToId, Trim } from '@/decorators' 6 | import { I18nUtils } from '@/utils' 7 | 8 | const { t } = I18nUtils 9 | 10 | export class PageDictionaryDto extends PageDto { 11 | @ApiPropertyOptional({ description: 'ID' }) 12 | @IsNumber({}, { message: t('common.ID.INVALID') }) 13 | @IsOptional() 14 | @ToId() 15 | id?: number 16 | 17 | @ApiPropertyOptional({ description: '字典编码' }) 18 | @IsString({ message: t('common.KEY.INVALID') }) 19 | @IsOptional() 20 | @Trim() 21 | code?: string 22 | 23 | @ApiPropertyOptional({ description: '是否启用' }) 24 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 25 | @IsOptional() 26 | enabled?: boolean 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/dictionaries/dto/patch-dictionary.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsNumber, IsOptional, IsString, MaxLength, NotContains } from 'class-validator' 3 | 4 | import { I18nUtils } from '@/utils' 5 | 6 | const { t } = I18nUtils 7 | 8 | export class PatchDictionaryDto { 9 | @ApiPropertyOptional({ description: '字典编码' }) 10 | @MaxLength(50, { message: t('common.KEY.LENGTH') }) 11 | @NotContains(' ', { message: t('common.KEY.NO.WHITESPACE') }) 12 | @IsString({ message: t('common.KEY.INVALID') }) 13 | @IsOptional() 14 | code: string 15 | 16 | @ApiPropertyOptional({ description: '名称' }) 17 | @MaxLength(50, { message: t('common.LABEL.LENGTH') }) 18 | @IsString({ message: t('common.LABEL.INVALID') }) 19 | @IsOptional() 20 | label?: string 21 | 22 | @ApiPropertyOptional({ description: '备注' }) 23 | @MaxLength(500, { message: t('common.REMARK.LENGTH') }) 24 | @IsString({ message: t('common.REMARK.INVALID') }) 25 | @IsOptional() 26 | remark?: string 27 | 28 | @ApiPropertyOptional({ description: '是否启用' }) 29 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 30 | @IsOptional() 31 | enabled?: boolean 32 | 33 | @ApiPropertyOptional({ description: '排序' }) 34 | @IsNumber({}, { message: t('common.SORT.INVALID') }) 35 | @IsOptional() 36 | sort?: number 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/dictionaries/dto/update-dictionary.dto.ts: -------------------------------------------------------------------------------- 1 | import { CreateDictionaryDto } from './create-dictionary.dto' 2 | 3 | export class UpdateDictionaryDto extends CreateDictionaryDto {} 4 | -------------------------------------------------------------------------------- /src/modules/dictionaries/vo/dictionary-select-item.vo.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | export class DictionarySelectItemVo { 4 | @ApiProperty({ description: 'ID' }) 5 | id: number 6 | 7 | @ApiProperty({ description: '值' }) 8 | value: string 9 | 10 | @ApiProperty({ description: '名称' }) 11 | label: string 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/dictionaries/vo/dictionary.vo.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | 3 | import { BaseResource } from '@/class' 4 | 5 | export class DictionaryVo extends BaseResource { 6 | @ApiProperty({ description: 'ID' }) 7 | id: number 8 | 9 | @ApiProperty({ description: '字典编码' }) 10 | code: string 11 | 12 | @ApiProperty({ description: '名称' }) 13 | label: string 14 | 15 | @ApiPropertyOptional({ description: '备注', nullable: true }) 16 | remark?: string 17 | 18 | @ApiProperty({ description: '是否启用' }) 19 | enabled: boolean 20 | 21 | @ApiPropertyOptional({ description: '排序', nullable: true }) 22 | sort?: number 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/dictionaries/vo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dictionary.vo' 2 | export * from './list-dictionary-select-item.vo' 3 | export * from './page-dictionary.vo' 4 | -------------------------------------------------------------------------------- /src/modules/dictionaries/vo/list-dictionary-select-item.vo.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { Type } from 'class-transformer' 3 | 4 | import { DictionarySelectItemVo } from './dictionary-select-item.vo' 5 | 6 | export class ListDictionarySelectItemVo { 7 | @Type(() => DictionarySelectItemVo) 8 | @ApiProperty({ type: [DictionarySelectItemVo], description: '字典项列表' }) 9 | records: DictionarySelectItemVo[] 10 | 11 | @ApiProperty({ description: '字典编码' }) 12 | code: string 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/dictionaries/vo/page-dictionary.vo.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer' 2 | 3 | import { Page } from '@/class' 4 | 5 | import { DictionaryVo } from './dictionary.vo' 6 | 7 | export class PageDictionaryVo extends Page { 8 | @Type(() => DictionaryVo) 9 | records: DictionaryVo[] 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/dictionary-items.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Patch, 9 | Post, 10 | Put, 11 | Query 12 | } from '@nestjs/common' 13 | import { ApiBasicAuth, ApiOperation, ApiTags } from '@nestjs/swagger' 14 | import { I18n, I18nContext } from 'nestjs-i18n' 15 | 16 | import { R } from '@/class' 17 | import { 18 | ApiCreatedObjectResponse, 19 | ApiOkObjectResponse, 20 | ApiOkResponse, 21 | ApiPageOKResponse, 22 | ApiPageQuery, 23 | Jwt 24 | } from '@/decorators' 25 | import type { I18nTranslations } from '@/generated/i18n.generated' 26 | 27 | import { DictionaryItemsService } from './dictionary-items.service' 28 | import { 29 | CreateDictionaryItemDto, 30 | PageDictionaryItemDto, 31 | PatchDictionaryItemDto, 32 | UpdateDictionaryItemDto 33 | } from './dto' 34 | import { DictionaryItemVo } from './vo' 35 | 36 | @ApiTags('字典数据') 37 | @ApiBasicAuth('bearer') 38 | @Controller('dictionary-items') 39 | export class DictionaryItemsController { 40 | constructor(private readonly dictionaryItemsService: DictionaryItemsService) {} 41 | 42 | @ApiOperation({ summary: '创建字典项' }) 43 | @ApiCreatedObjectResponse(DictionaryItemVo) 44 | @Post() 45 | async create( 46 | @Body() createDictionaryDto: CreateDictionaryItemDto, 47 | @Jwt('sub') userId: number, 48 | @I18n() i18n: I18nContext 49 | ) { 50 | return new R({ 51 | data: await this.dictionaryItemsService.create(createDictionaryDto, userId), 52 | msg: i18n.t('common.CREATE.SUCCESS') 53 | }) 54 | } 55 | 56 | @ApiOperation({ summary: '字典项列表' }) 57 | @ApiPageOKResponse(DictionaryItemVo) 58 | @ApiPageQuery('keywords', 'date') 59 | @Get() 60 | async findMany(@Query() pageDictionaryItemDto: PageDictionaryItemDto) { 61 | return new R({ 62 | data: await this.dictionaryItemsService.findMany(pageDictionaryItemDto) 63 | }) 64 | } 65 | 66 | @ApiOperation({ summary: '字典项详情' }) 67 | @ApiOkObjectResponse(DictionaryItemVo) 68 | @Get(':id(\\d+)') 69 | async findOneById(@Param('id') id: number) { 70 | return new R({ 71 | data: await this.dictionaryItemsService.findOneById(id) 72 | }) 73 | } 74 | 75 | @ApiOperation({ summary: '更新字典项' }) 76 | @ApiOkObjectResponse(DictionaryItemVo) 77 | @Put(':id(\\d+)') 78 | async update( 79 | @Param('id', new ParseIntPipe()) id: number, 80 | @Body() updateDictionaryItemDto: UpdateDictionaryItemDto, 81 | @Jwt('sub') userId: number, 82 | @I18n() i18n: I18nContext 83 | ) { 84 | return new R({ 85 | data: await this.dictionaryItemsService.update(id, updateDictionaryItemDto, userId), 86 | msg: i18n.t('common.OPERATE.SUCCESS') 87 | }) 88 | } 89 | 90 | @ApiOperation({ summary: '部分更新' }) 91 | @ApiOkObjectResponse(DictionaryItemVo) 92 | @Patch(':id(\\d+)') 93 | async patch( 94 | @Param('id', new ParseIntPipe()) id: number, 95 | @Body() patchDictionaryItemDto: PatchDictionaryItemDto, 96 | @Jwt('sub') userId: number, 97 | @I18n() i18n: I18nContext 98 | ) { 99 | return new R({ 100 | data: await this.dictionaryItemsService.update(id, patchDictionaryItemDto, userId), 101 | msg: i18n.t('common.OPERATE.SUCCESS') 102 | }) 103 | } 104 | 105 | @ApiOperation({ summary: '删除字典项' }) 106 | @ApiOkResponse() 107 | @Delete(':id(\\d+)') 108 | async remove( 109 | @Param('id', new ParseIntPipe()) id: number, 110 | @Jwt('sub') userId: number, 111 | @I18n() i18n: I18nContext 112 | ) { 113 | await this.dictionaryItemsService.remove(id, userId) 114 | return new R({ 115 | msg: i18n.t('common.DELETE.SUCCESS') 116 | }) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/dictionary-items.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { DictionaryItemsController } from './dictionary-items.controller' 4 | import { DictionaryItemsService } from './dictionary-items.service' 5 | 6 | @Module({ 7 | controllers: [DictionaryItemsController], 8 | providers: [DictionaryItemsService] 9 | }) 10 | export class DictionaryItemsModule {} 11 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/dictionary-items.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common' 2 | import type { Prisma } from '@prisma/client' 3 | import { plainToClass } from 'class-transformer' 4 | import _ from 'lodash' 5 | import { I18nContext, I18nService } from 'nestjs-i18n' 6 | 7 | import type { I18nTranslations } from '@/generated/i18n.generated' 8 | import { PrismaService } from '@/shared/prisma/prisma.service' 9 | 10 | import type { 11 | CreateDictionaryItemDto, 12 | PageDictionaryItemDto, 13 | PatchDictionaryItemDto, 14 | UpdateDictionaryItemDto 15 | } from './dto' 16 | import { DictionaryItemVo, PageDictionaryItemVo } from './vo' 17 | 18 | @Injectable() 19 | export class DictionaryItemsService { 20 | constructor( 21 | private readonly prismaService: PrismaService, 22 | private readonly i18nService: I18nService 23 | ) {} 24 | 25 | async create(createDictionaryItemDto: CreateDictionaryItemDto, createdBy: number) { 26 | return plainToClass( 27 | DictionaryItemVo, 28 | await this.prismaService.dictionaryItem.create({ 29 | data: { 30 | ...createDictionaryItemDto, 31 | createdBy 32 | } 33 | }) 34 | ) 35 | } 36 | 37 | async findMany(pageDictionaryItemDto: PageDictionaryItemDto) { 38 | const { 39 | page, 40 | pageSize, 41 | keywords, 42 | startTime, 43 | endTime, 44 | orderBy, 45 | dictionaryId, 46 | label, 47 | enabled, 48 | id 49 | } = pageDictionaryItemDto 50 | 51 | const where: Prisma.DictionaryItemWhereInput = { 52 | deletedAt: null, 53 | AND: [ 54 | { 55 | createdAt: { 56 | ...(startTime && { gte: startTime }), 57 | ...(endTime && { lte: endTime }) 58 | }, 59 | id: { 60 | ...(id && { equals: id }) 61 | }, 62 | enabled: { 63 | ...(enabled && { equals: enabled }) 64 | }, 65 | dictionaryId: { 66 | ...(dictionaryId && { equals: dictionaryId }) 67 | }, 68 | label: { 69 | ...(label && { contains: label }) 70 | } 71 | } 72 | ], 73 | OR: keywords 74 | ? [ 75 | { id: { equals: _.toNumber(keywords) < 100000 ? _.toNumber(keywords) : 0 } }, 76 | { label: { contains: keywords } }, 77 | { remark: { contains: keywords } } 78 | ] 79 | : undefined 80 | } 81 | 82 | const records = await this.prismaService.dictionaryItem.findMany({ 83 | where, 84 | orderBy, 85 | skip: (page - 1) * pageSize, 86 | take: pageSize 87 | }) 88 | 89 | const total = await this.prismaService.dictionaryItem.count({ where }) 90 | 91 | return plainToClass(PageDictionaryItemVo, { records, total, page, pageSize }) 92 | } 93 | 94 | async findOneById(id: number) { 95 | const dictionaryItem = await this.prismaService.dictionaryItem.findUnique({ 96 | where: { 97 | id, 98 | deletedAt: null 99 | } 100 | }) 101 | 102 | if (!dictionaryItem) { 103 | throw new NotFoundException( 104 | this.i18nService.t('common.RESOURCE.NOT.FOUND', { lang: I18nContext.current()!.lang }) 105 | ) 106 | } 107 | 108 | return plainToClass(DictionaryItemVo, dictionaryItem) 109 | } 110 | 111 | async update( 112 | id: number, 113 | updateOrPatchDictionaryItemDto: UpdateDictionaryItemDto | PatchDictionaryItemDto, 114 | updatedBy: number 115 | ) { 116 | return plainToClass( 117 | DictionaryItemVo, 118 | await this.prismaService.dictionaryItem.update({ 119 | where: { 120 | id, 121 | deletedAt: null 122 | }, 123 | data: { 124 | ...updateOrPatchDictionaryItemDto, 125 | updatedBy 126 | } 127 | }) 128 | ) 129 | } 130 | 131 | async remove(id: number, deletedBy: number) { 132 | await this.prismaService.dictionaryItem.update({ 133 | where: { 134 | id, 135 | deletedAt: null 136 | }, 137 | data: { 138 | deletedAt: new Date().toISOString(), 139 | deletedBy 140 | } 141 | }) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/dto/create-dictionary-item.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { 3 | IsBoolean, 4 | IsNotEmpty, 5 | IsNumber, 6 | IsOptional, 7 | IsString, 8 | MaxLength, 9 | NotContains 10 | } from 'class-validator' 11 | 12 | import { I18nUtils } from '@/utils' 13 | 14 | const { t } = I18nUtils 15 | 16 | export class CreateDictionaryItemDto { 17 | @ApiProperty({ description: '值' }) 18 | @MaxLength(250, { message: t('common.VALUE.LENGTH') }) 19 | @IsString({ message: t('common.VALUE.INVALID') }) 20 | @NotContains(' ', { message: t('common.VALUE.NO.WHITESPACE') }) 21 | @IsNotEmpty({ message: t('common.VALUE.NOT.EMPTY') }) 22 | value: string 23 | 24 | @ApiProperty({ description: '名称' }) 25 | @MaxLength(50, { message: t('common.LABEL.LENGTH') }) 26 | @IsString({ message: t('common.LABEL.INVALID') }) 27 | @IsNotEmpty({ message: t('common.LABEL.NOT.EMPTY') }) 28 | label: string 29 | 30 | @ApiPropertyOptional({ description: '备注' }) 31 | @MaxLength(500, { message: t('common.REMARK.LENGTH') }) 32 | @IsString({ message: t('common.REMARK.INVALID') }) 33 | @IsOptional() 34 | remark?: string 35 | 36 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 37 | @IsNotEmpty({ message: t('common.ENABLED.NOT.EMPTY') }) 38 | @ApiProperty({ description: '是否启用' }) 39 | enabled: boolean 40 | 41 | @ApiPropertyOptional({ description: '排序' }) 42 | @IsNumber({}, { message: t('common.SORT.INVALID') }) 43 | @IsOptional() 44 | sort?: number 45 | 46 | @ApiProperty({ description: '字典 ID' }) 47 | @IsNumber({}, { message: t('dictionary.ID.INVALID') }) 48 | @IsNotEmpty({ message: t('dictionary.ID.NOT.EMPTY') }) 49 | dictionaryId: number 50 | } 51 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-dictionary-item.dto' 2 | export * from './page-dictionary-item.dto' 3 | export * from './patch-dictionary-item.dto' 4 | export * from './update-dictionary-item.dto' 5 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/dto/page-dictionary-item.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator' 3 | 4 | import { PageDto } from '@/class' 5 | import { ToId } from '@/decorators' 6 | import { I18nUtils } from '@/utils' 7 | 8 | const { t } = I18nUtils 9 | 10 | export class PageDictionaryItemDto extends PageDto { 11 | @ApiPropertyOptional({ description: '字典项 ID' }) 12 | @IsNumber({}, { message: t('common.ID.INVALID') }) 13 | @IsOptional() 14 | @ToId() 15 | id?: number 16 | 17 | @ApiPropertyOptional({ description: '字典 ID' }) 18 | @IsNumber({}, { message: t('common.ID.INVALID') }) 19 | @IsOptional() 20 | @ToId() 21 | dictionaryId?: number 22 | 23 | @ApiPropertyOptional({ description: '字典项名称' }) 24 | @IsString({ message: t('common.LABEL.INVALID') }) 25 | @IsOptional() 26 | label?: string 27 | 28 | @ApiPropertyOptional({ description: '是否启用' }) 29 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 30 | @IsOptional() 31 | enabled?: boolean 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/dto/patch-dictionary-item.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsNumber, IsOptional, IsString, MaxLength, NotContains } from 'class-validator' 3 | 4 | import { I18nUtils } from '@/utils' 5 | 6 | const { t } = I18nUtils 7 | 8 | export class PatchDictionaryItemDto { 9 | @ApiPropertyOptional({ description: '值' }) 10 | @MaxLength(250, { message: t('common.VALUE.LENGTH') }) 11 | @NotContains(' ', { message: t('common.VALUE.NO.WHITESPACE') }) 12 | @IsString({ message: t('common.VALUE.INVALID') }) 13 | @IsOptional() 14 | value?: string 15 | 16 | @ApiPropertyOptional({ description: '名称' }) 17 | @MaxLength(50, { message: t('common.LABEL.LENGTH') }) 18 | @IsString({ message: t('common.LABEL.INVALID') }) 19 | @IsOptional() 20 | label?: string 21 | 22 | @ApiPropertyOptional({ description: '备注' }) 23 | @MaxLength(500, { message: t('common.REMARK.LENGTH') }) 24 | @IsString({ message: t('common.REMARK.INVALID') }) 25 | @IsOptional() 26 | remark?: string 27 | 28 | @ApiPropertyOptional({ description: '是否启用' }) 29 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 30 | @IsOptional() 31 | enabled?: boolean 32 | 33 | @ApiPropertyOptional({ description: '排序' }) 34 | @IsNumber({}, { message: t('common.SORT.INVALID') }) 35 | @IsOptional() 36 | sort?: number 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/dto/update-dictionary-item.dto.ts: -------------------------------------------------------------------------------- 1 | import { OmitType } from '@nestjs/swagger' 2 | 3 | import { CreateDictionaryItemDto } from './create-dictionary-item.dto' 4 | 5 | export class UpdateDictionaryItemDto extends OmitType(CreateDictionaryItemDto, [ 6 | 'dictionaryId' 7 | ] as const) {} 8 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/vo/dictionary-item.vo.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | 3 | import { BaseResource } from '@/class' 4 | 5 | export class DictionaryItemVo extends BaseResource { 6 | @ApiProperty({ description: 'ID' }) 7 | id: number 8 | 9 | @ApiProperty({ description: '值' }) 10 | value: string 11 | 12 | @ApiProperty({ description: '名称' }) 13 | label: string 14 | 15 | @ApiPropertyOptional({ description: '备注', nullable: true }) 16 | remark?: string 17 | 18 | @ApiProperty({ description: '是否启用' }) 19 | enabled: boolean 20 | 21 | @ApiPropertyOptional({ description: '排序', nullable: true }) 22 | sort?: number 23 | 24 | @ApiProperty({ description: '字典编码' }) 25 | code: string 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/vo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dictionary-item.vo' 2 | export * from './page-dictionary-item.vo' 3 | -------------------------------------------------------------------------------- /src/modules/dictionary-items/vo/page-dictionary-item.vo.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer' 2 | 3 | import { Page } from '@/class' 4 | 5 | import { DictionaryItemVo } from './dictionary-item.vo' 6 | 7 | export class PageDictionaryItemVo extends Page { 8 | @Type(() => DictionaryItemVo) 9 | records: DictionaryItemVo[] 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/files/files.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, Post, Res, UploadedFiles, UseInterceptors } from '@nestjs/common' 2 | import { AnyFilesInterceptor } from '@nestjs/platform-express' 3 | import { 4 | ApiBadRequestResponse, 5 | ApiBearerAuth, 6 | ApiBody, 7 | ApiConsumes, 8 | ApiCreatedResponse, 9 | ApiOkResponse, 10 | ApiOperation, 11 | ApiTags, 12 | ApiUnprocessableEntityResponse 13 | } from '@nestjs/swagger' 14 | import { Response } from 'express' 15 | 16 | import { STORAGE_DIR } from '@/constants' 17 | 18 | import { FilesService } from './files.service' 19 | import { FileVo } from './vo' 20 | 21 | @ApiTags('文件') 22 | @ApiBearerAuth('bearer') 23 | @Controller('files') 24 | export class FilesController { 25 | constructor(private readonly filesService: FilesService) {} 26 | 27 | @ApiOperation({ summary: '上传文件' }) 28 | @ApiConsumes('multipart/form-data') 29 | @ApiCreatedResponse({ description: '上传成功', type: FileVo, isArray: true }) 30 | @ApiBadRequestResponse({ description: '文件为空' }) 31 | @ApiUnprocessableEntityResponse({ 32 | description: '文件大于 5MB | 文件类型不支持' 33 | }) 34 | @ApiBody({ description: '上传的文件' }) 35 | @UseInterceptors(AnyFilesInterceptor()) 36 | @Post() 37 | upload(@UploadedFiles() files: Express.Multer.File[]) { 38 | return this.filesService.upload(files) 39 | } 40 | 41 | @ApiOperation({ summary: '下载文件' }) 42 | @ApiOkResponse({ description: '下载成功' }) 43 | @Get('download/:path') 44 | download(@Param('path') path: string, @Res() res: Response) { 45 | return res.download(`${STORAGE_DIR}/${path}`) 46 | } 47 | 48 | @ApiOperation({ summary: '获取文件' }) 49 | @ApiOkResponse({ description: '获取成功' }) 50 | @Get(':path') 51 | findOne(@Param('path') path: string, @Res() res: Response) { 52 | return res.sendFile(path, { root: STORAGE_DIR }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/modules/files/files.module.ts: -------------------------------------------------------------------------------- 1 | import { extname } from 'node:path' 2 | 3 | import { Module, UnprocessableEntityException } from '@nestjs/common' 4 | import { MulterModule } from '@nestjs/platform-express' 5 | import { diskStorage } from 'multer' 6 | 7 | import { MAX_UPLOAD_FILE_SIZE } from '@/constants' 8 | import { fileExtensionMap } from '@/maps' 9 | import { GeneratorUtils } from '@/utils' 10 | 11 | import { FilesController } from './files.controller' 12 | import { FilesService } from './files.service' 13 | 14 | @Module({ 15 | imports: [ 16 | /** 17 | * TODO: 考虑使用配置文件 18 | * - 文件存储路径 19 | * - 文件大小限制 20 | * - 文件上传数量限制 21 | */ 22 | MulterModule.registerAsync({ 23 | useFactory: () => ({ 24 | storage: diskStorage({ 25 | destination: 'uploads', 26 | filename: (_, file, callback) => 27 | callback(null, `${GeneratorUtils.generateFileName(extname(file.originalname))}`) 28 | }), 29 | limits: { 30 | fileSize: MAX_UPLOAD_FILE_SIZE 31 | }, 32 | fileFilter: (_req, file, callback) => { 33 | if (fileExtensionMap.get(file.mimetype)) { 34 | callback(null, true) 35 | } else { 36 | callback(new UnprocessableEntityException('无法处理的文件类型'), false) 37 | } 38 | } 39 | }) 40 | }) 41 | ], 42 | controllers: [FilesController], 43 | providers: [FilesService] 44 | }) 45 | export class FilesModule {} 46 | -------------------------------------------------------------------------------- /src/modules/files/files.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { sep } from 'path' 3 | 4 | import { FileVo } from './vo' 5 | 6 | @Injectable() 7 | export class FilesService { 8 | upload(files: Express.Multer.File[]): FileVo[] { 9 | const filesResult = 10 | files?.map((file) => { 11 | const { fieldname, filename, mimetype, size, originalname } = file 12 | const path = file.path.replaceAll(sep, '/') 13 | return new FileVo({ 14 | path, 15 | fieldname, 16 | filename, 17 | originalname, 18 | mimetype, 19 | size 20 | }) 21 | }) ?? [] 22 | return filesResult 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/files/vo/file.vo.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | export class FileVo { 4 | @ApiProperty({ description: '文件路径' }) 5 | path: string 6 | 7 | @ApiProperty({ description: '接口提交字段名称' }) 8 | fieldname: string 9 | 10 | @ApiProperty({ description: '文件名称' }) 11 | filename: string 12 | 13 | @ApiProperty({ description: '原始文件名称' }) 14 | originalname: string 15 | 16 | @ApiProperty({ description: '媒体类型' }) 17 | mimetype: string 18 | 19 | @ApiProperty({ description: '文件大小' }) 20 | size: number 21 | 22 | constructor(partial: Partial) { 23 | Object.assign(this, partial) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/files/vo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './file.vo' 2 | -------------------------------------------------------------------------------- /src/modules/locales/dto/create-locale.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsNotEmpty, IsOptional, IsString, NotContains } from 'class-validator' 3 | 4 | import { I18nUtils } from '@/utils' 5 | 6 | const { t } = I18nUtils 7 | 8 | export class CreateLocaleDto { 9 | @ApiProperty({ description: '键' }) 10 | @IsString({ message: t('common.KEY.INVALID') }) 11 | @IsNotEmpty({ message: t('common.KEY.NOT.EMPTY') }) 12 | @NotContains(' ', { message: t('common.KEY.NO.WHITESPACE') }) 13 | key: string 14 | 15 | @ApiProperty({ description: '命名空间' }) 16 | @IsString({ message: t('common.NS.INVALID') }) 17 | @IsNotEmpty({ message: t('common.NS.NOT.EMPTY') }) 18 | @NotContains(' ', { message: t('common.NS.NO.WHITESPACE') }) 19 | ns: string 20 | 21 | @ApiPropertyOptional({ description: '英文' }) 22 | @IsOptional() 23 | 'en-US'?: string 24 | 25 | @ApiPropertyOptional({ description: '简体中文' }) 26 | @IsOptional() 27 | 'zh-CN'?: string 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/locales/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-locale.dto' 2 | export * from './page-locale.dto' 3 | export * from './update-locale.dto' 4 | -------------------------------------------------------------------------------- /src/modules/locales/dto/page-locale.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsOptional, IsString } from 'class-validator' 3 | 4 | import { PageDto } from '@/class' 5 | import { Trim } from '@/decorators' 6 | import { I18nUtils } from '@/utils' 7 | 8 | const { t } = I18nUtils 9 | 10 | export class PageLocaleDto extends PageDto { 11 | @ApiPropertyOptional({ description: '键' }) 12 | @IsString({ message: t('common.KEY.INVALID') }) 13 | @IsOptional() 14 | @Trim() 15 | key?: string 16 | 17 | @ApiPropertyOptional({ description: '命名空间' }) 18 | @IsString({ message: t('common.VALUE.INVALID') }) 19 | @IsOptional() 20 | @Trim() 21 | ns?: string 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/locales/dto/update-locale.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger' 2 | 3 | import { CreateLocaleDto } from './create-locale.dto' 4 | 5 | export class UpdateLocaleDto extends PartialType(CreateLocaleDto) {} 6 | -------------------------------------------------------------------------------- /src/modules/locales/locales.controller.ts: -------------------------------------------------------------------------------- 1 | import { Lang } from '@dolphin-admin/utils' 2 | import { 3 | Body, 4 | Controller, 5 | Delete, 6 | Get, 7 | NotFoundException, 8 | Param, 9 | ParseEnumPipe, 10 | Post, 11 | Put, 12 | Query 13 | } from '@nestjs/common' 14 | import { ApiBearerAuth, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger' 15 | import { I18n, I18nContext } from 'nestjs-i18n' 16 | 17 | import { R } from '@/class' 18 | import { 19 | ApiCreatedObjectResponse, 20 | ApiOkObjectResponse, 21 | ApiOkResponse, 22 | ApiPageOKResponse, 23 | ApiPageQuery 24 | } from '@/decorators' 25 | import { ApiOkArrayResponse } from '@/decorators/swagger/api-ok-array-response.decorator' 26 | import type { I18nTranslations } from '@/generated/i18n.generated' 27 | 28 | import { PageLocaleDto } from './dto' 29 | import { CreateLocaleDto } from './dto/create-locale.dto' 30 | import { UpdateLocaleDto } from './dto/update-locale.dto' 31 | import { LocalesService } from './locales.service' 32 | import { LocaleResourceVO, LocaleVo } from './vo' 33 | 34 | @ApiTags('多语言资源') 35 | @ApiBearerAuth('bearer') 36 | @Controller('locales') 37 | export class LocalesController { 38 | constructor(private readonly localesService: LocalesService) {} 39 | 40 | @ApiOperation({ summary: '创建多语言资源' }) 41 | @ApiCreatedObjectResponse(LocaleVo) 42 | @Post() 43 | async create( 44 | @Body() createLocaleDto: CreateLocaleDto, 45 | @I18n() i18n: I18nContext 46 | ) { 47 | return new R({ 48 | data: await this.localesService.create(createLocaleDto), 49 | msg: i18n.t('common.CREATE.SUCCESS') 50 | }) 51 | } 52 | 53 | @ApiOperation({ summary: '多语言资源列表' }) 54 | @ApiPageOKResponse(PageLocaleDto) 55 | @ApiPageQuery('keywords', 'date') 56 | @Get() 57 | async findAll(@Query() pageLocaleDto: PageLocaleDto) { 58 | return new R({ 59 | data: await this.localesService.findAll(pageLocaleDto) 60 | }) 61 | } 62 | 63 | @ApiOperation({ summary: '多语言资源 [根据语言]' }) 64 | @ApiOkArrayResponse(LocaleResourceVO) 65 | @ApiParam({ name: 'lang', description: '语言标识', enum: Lang, example: Lang['en-US'] }) 66 | @Get(':lang') 67 | async findManyByLang( 68 | @Param( 69 | 'lang', 70 | new ParseEnumPipe(Lang, { 71 | exceptionFactory: () => { 72 | const i18n = I18nContext.current()! 73 | throw new NotFoundException(i18n.t('common.LANGUAGE.NOT.SUPPORT')) 74 | } 75 | }) 76 | ) 77 | lang: string 78 | ) { 79 | return new R({ 80 | data: await this.localesService.findManyByLang(lang) 81 | }) 82 | } 83 | 84 | @ApiOperation({ summary: '多语言资源详情' }) 85 | @ApiOkObjectResponse(LocaleVo) 86 | @Get(':id') 87 | async findOneById(@Param('id') id: string) { 88 | return new R({ 89 | data: await this.localesService.findOneById(id) 90 | }) 91 | } 92 | 93 | @ApiOperation({ summary: '修改多语言资源' }) 94 | @ApiOkObjectResponse(LocaleVo) 95 | @Put(':id') 96 | async update( 97 | @Param('id') id: string, 98 | @Body() updateLocaleDto: UpdateLocaleDto, 99 | @I18n() i18n: I18nContext 100 | ) { 101 | return new R({ 102 | data: await this.localesService.update(id, updateLocaleDto), 103 | msg: i18n.t('common.UPDATE.SUCCESS') 104 | }) 105 | } 106 | 107 | @ApiOperation({ summary: '删除多语言资源' }) 108 | @ApiOkResponse() 109 | @Delete(':id') 110 | async remove(@Param('id') id: string, @I18n() i18n: I18nContext) { 111 | await this.localesService.remove(id) 112 | return new R({ 113 | msg: i18n.t('common.DELETE.SUCCESS') 114 | }) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/modules/locales/locales.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { MongooseModule } from '@nestjs/mongoose' 3 | 4 | import { LocalesController } from './locales.controller' 5 | import { LocalesService } from './locales.service' 6 | import { Locale, LocaleSchema } from './schemas' 7 | 8 | @Module({ 9 | imports: [ 10 | MongooseModule.forFeature([ 11 | { 12 | name: Locale.name, 13 | schema: LocaleSchema 14 | } 15 | ]) 16 | ], 17 | controllers: [LocalesController], 18 | providers: [LocalesService] 19 | }) 20 | export class LocalesModule {} 21 | -------------------------------------------------------------------------------- /src/modules/locales/locales.service.ts: -------------------------------------------------------------------------------- 1 | import { Lang } from '@dolphin-admin/utils' 2 | import { Injectable, NotFoundException } from '@nestjs/common' 3 | import { InjectModel } from '@nestjs/mongoose' 4 | import { plainToClass } from 'class-transformer' 5 | import type { FilterQuery } from 'mongoose' 6 | import { Model } from 'mongoose' 7 | import { I18nContext, I18nService } from 'nestjs-i18n' 8 | 9 | import type { I18nTranslations } from '@/generated/i18n.generated' 10 | import { CacheKeyService } from '@/shared/redis/cache-key.service' 11 | import { RedisService } from '@/shared/redis/redis.service' 12 | 13 | import type { CreateLocaleDto, PageLocaleDto, UpdateLocaleDto } from './dto' 14 | import { Locale } from './schemas' 15 | import type { LocaleResourceVO } from './vo' 16 | import { LocaleVo, PageLocaleVo } from './vo' 17 | 18 | @Injectable() 19 | export class LocalesService { 20 | constructor( 21 | @InjectModel(Locale.name) private readonly LocaleModel: Model, 22 | private readonly i18nService: I18nService, 23 | private readonly redisService: RedisService, 24 | private readonly cacheKeyService: CacheKeyService 25 | ) {} 26 | 27 | private async clearAllResourcesCache() { 28 | await Promise.all( 29 | Object.values(Lang).map((lang) => { 30 | const cacheKey = this.cacheKeyService.getLocalesCacheKey(lang) 31 | return this.redisService.del(cacheKey) 32 | }) 33 | ) 34 | } 35 | 36 | async create(createLocaleDto: CreateLocaleDto) { 37 | const locale = await new this.LocaleModel(createLocaleDto).save() 38 | 39 | await this.clearAllResourcesCache() 40 | 41 | return plainToClass(LocaleVo, locale) 42 | } 43 | 44 | async findAll(pageLocaleDto: PageLocaleDto) { 45 | const { page, pageSize, keywords, startTime, endTime, sort, key, ns } = pageLocaleDto 46 | 47 | const query: FilterQuery = { 48 | ...(key && { key: { $regex: key, $options: 'i' } }), 49 | ...(ns && { ns: { $regex: ns, $options: 'i' } }), 50 | ...(startTime && { createdAt: { $gte: startTime } }), 51 | ...(endTime && { createdAt: { $lte: endTime } }), 52 | ...(keywords && { 53 | $or: [ 54 | { key: { $regex: keywords, $options: 'i' } }, 55 | { ns: { $regex: keywords, $options: 'i' } }, 56 | ...Object.values(Lang).map((lang) => ({ 57 | [lang]: { $regex: keywords, $options: 'i' } 58 | })) 59 | ] 60 | }) 61 | } 62 | 63 | return plainToClass(PageLocaleVo, { 64 | records: await this.LocaleModel.find(query) 65 | .sort(sort) 66 | .skip((page - 1) * pageSize) 67 | .limit(pageSize) 68 | .exec(), 69 | total: await this.LocaleModel.countDocuments(query), 70 | page, 71 | pageSize 72 | }) 73 | } 74 | 75 | async findManyByLang(lang: string) { 76 | const cacheKey = this.cacheKeyService.getLocalesCacheKey(lang) 77 | const cachedResult = await this.redisService.jsonGet(cacheKey) 78 | if (cachedResult) { 79 | return cachedResult 80 | } 81 | const result = (await this.LocaleModel.aggregate([ 82 | { 83 | $match: { 84 | $and: [ 85 | { 86 | [lang]: { $exists: true } 87 | }, 88 | { [lang]: { $ne: null } }, 89 | { [lang]: { $ne: '' } } 90 | ] 91 | } 92 | }, 93 | { $project: { _id: 0, ns: 1, key: 1, value: `$${lang}` } }, 94 | { $group: { _id: '$ns', items: { $push: { key: '$key', value: '$value' } } } }, 95 | { 96 | $project: { 97 | _id: 0, 98 | ns: '$_id', 99 | resources: { 100 | $arrayToObject: { 101 | $map: { 102 | input: '$items', 103 | as: 'item', 104 | in: { 105 | k: '$$item.key', 106 | v: '$$item.value' 107 | } 108 | } 109 | } 110 | } 111 | } 112 | } 113 | ]).exec()) as LocaleResourceVO[] 114 | 115 | await this.redisService.jsonSet(cacheKey, result, 60 * 60 * 24) 116 | 117 | return result 118 | } 119 | 120 | async findOneById(id: string) { 121 | const locale = await this.LocaleModel.findById(id).exec() 122 | if (!locale) { 123 | throw new NotFoundException( 124 | this.i18nService.t('common.RESOURCE.NOT.FOUND', { lang: I18nContext.current()!.lang }) 125 | ) 126 | } 127 | 128 | return plainToClass(LocaleVo, locale) 129 | } 130 | 131 | async update(id: string, updateLocaleDto: UpdateLocaleDto) { 132 | await this.findOneById(id) 133 | 134 | const locale = await this.LocaleModel.findByIdAndUpdate(id, updateLocaleDto).exec() 135 | 136 | await this.clearAllResourcesCache() 137 | 138 | return plainToClass(LocaleVo, locale) 139 | } 140 | 141 | async remove(id: string) { 142 | await this.findOneById(id) 143 | 144 | await this.LocaleModel.findByIdAndDelete(id).exec() 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/modules/locales/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './locale.schema' 2 | -------------------------------------------------------------------------------- /src/modules/locales/schemas/locale.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' 2 | import type { HydratedDocument } from 'mongoose' 3 | 4 | export type LocaleDocument = HydratedDocument 5 | 6 | @Schema({ 7 | id: true, 8 | timestamps: true, 9 | autoIndex: true, 10 | collection: 'locales' 11 | }) 12 | export class Locale { 13 | // 多语言的 key 14 | @Prop({ required: true }) 15 | key: string 16 | 17 | // 命名空间 18 | @Prop({ required: true }) 19 | ns: string 20 | 21 | // 英文 22 | @Prop() 23 | 'en-US': string 24 | 25 | // 简体中文 26 | @Prop() 27 | 'zh-CN': string 28 | } 29 | 30 | export const LocaleSchema = SchemaFactory.createForClass(Locale).index( 31 | { key: 1, ns: 1 }, 32 | { unique: true } 33 | ) 34 | -------------------------------------------------------------------------------- /src/modules/locales/vo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './locale.vo' 2 | export * from './locale-resource.vo' 3 | export * from './page-locale.vo' 4 | -------------------------------------------------------------------------------- /src/modules/locales/vo/locale-resource.vo.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | export class LocaleResourceVO { 4 | @ApiProperty({ description: '命名空间' }) 5 | ns: string 6 | 7 | @ApiProperty({ description: '多语言资源' }) 8 | resources: Record 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/locales/vo/locale.vo.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { Exclude, Expose } from 'class-transformer' 3 | 4 | import { MongoBaseResource } from '@/class' 5 | 6 | @Exclude() 7 | export class LocaleVo extends MongoBaseResource { 8 | @ApiProperty({ description: '键' }) 9 | @Expose() 10 | key: string 11 | 12 | @ApiProperty({ description: '命名空间' }) 13 | @Expose() 14 | ns: string 15 | 16 | @ApiProperty({ description: '英文' }) 17 | @Expose() 18 | 'en-US': string 19 | 20 | @ApiProperty({ description: '简体中文' }) 21 | @Expose() 22 | 'zh-CN': string 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/locales/vo/page-locale.vo.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer' 2 | 3 | import { Page } from '@/class' 4 | 5 | import { LocaleVo } from './locale.vo' 6 | 7 | export class PageLocaleVo extends Page { 8 | @Type(() => LocaleVo) 9 | records: LocaleVo[] 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/login-logs/login-logs.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common' 2 | import { ApiBasicAuth, ApiTags } from '@nestjs/swagger' 3 | 4 | import { LoginLogsService } from './login-logs.service' 5 | 6 | @ApiTags('登录日志') 7 | @ApiBasicAuth('bearer') 8 | @Controller('login-logs') 9 | export class LoginLogsController { 10 | constructor(private readonly loginLogsService: LoginLogsService) {} 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/login-logs/login-logs.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { MongooseModule } from '@nestjs/mongoose' 3 | 4 | import { LoginLogsController } from './login-logs.controller' 5 | import { LoginLogsService } from './login-logs.service' 6 | import { LoginLog, LoginLogSchema } from './schemas' 7 | 8 | @Module({ 9 | imports: [MongooseModule.forFeature([{ name: LoginLog.name, schema: LoginLogSchema }])], 10 | controllers: [LoginLogsController], 11 | providers: [LoginLogsService] 12 | }) 13 | export class LoginLogsModule {} 14 | -------------------------------------------------------------------------------- /src/modules/login-logs/login-logs.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class LoginLogsService {} 5 | -------------------------------------------------------------------------------- /src/modules/login-logs/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login-log.schema' 2 | -------------------------------------------------------------------------------- /src/modules/login-logs/schemas/login-log.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' 2 | import type { HydratedDocument } from 'mongoose' 3 | 4 | export type LoginLogDocument = HydratedDocument 5 | 6 | @Schema({ 7 | id: true, 8 | timestamps: true, 9 | collection: 'login_logs' 10 | }) 11 | export class LoginLog { 12 | // 用户 ID 13 | @Prop() 14 | userId?: number 15 | 16 | // 用户名 17 | @Prop() 18 | username?: string 19 | 20 | // 认证类型 21 | @Prop() 22 | authType?: string 23 | 24 | // IP 25 | @Prop() 26 | ip?: string 27 | 28 | // 地区 29 | @Prop() 30 | area?: string 31 | 32 | // 访问来源 33 | @Prop() 34 | source?: string 35 | 36 | // 用户代理 37 | @Prop() 38 | userAgent?: string 39 | 40 | // 浏览器 41 | @Prop() 42 | browser?: string 43 | 44 | // 操作系统 45 | @Prop() 46 | os?: string 47 | 48 | // 响应状态 49 | @Prop() 50 | responseStatus?: string 51 | 52 | // 响应消息 53 | @Prop() 54 | responseMessage?: string 55 | } 56 | 57 | export const LoginLogSchema = SchemaFactory.createForClass(LoginLog) 58 | -------------------------------------------------------------------------------- /src/modules/menu-items/menu-items.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common' 2 | import { ApiBasicAuth, ApiTags } from '@nestjs/swagger' 3 | 4 | import { MenuItemsService } from './menu-items.service' 5 | 6 | @ApiTags('菜单') 7 | @ApiBasicAuth('bearer') 8 | @Controller('menu-items') 9 | export class MenuItemsController { 10 | constructor(private readonly menuItemsService: MenuItemsService) {} 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/menu-items/menu-items.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { MenuItemsController } from './menu-items.controller' 4 | import { MenuItemsService } from './menu-items.service' 5 | 6 | @Module({ 7 | controllers: [MenuItemsController], 8 | providers: [MenuItemsService] 9 | }) 10 | export class MenuItemsModule {} 11 | -------------------------------------------------------------------------------- /src/modules/menu-items/menu-items.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class MenuItemsService {} 5 | -------------------------------------------------------------------------------- /src/modules/operation-logs/operation-logs.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common' 2 | import { ApiBasicAuth, ApiTags } from '@nestjs/swagger' 3 | 4 | import { OperationLogsService } from './operation-logs.service' 5 | 6 | @ApiTags('操作日志') 7 | @ApiBasicAuth('bearer') 8 | @Controller('operation-logs') 9 | export class OperationLogsController { 10 | constructor(private readonly operationLogsService: OperationLogsService) {} 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/operation-logs/operation-logs.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { OperationLogsController } from './operation-logs.controller' 4 | import { OperationLogsService } from './operation-logs.service' 5 | 6 | @Module({ 7 | controllers: [OperationLogsController], 8 | providers: [OperationLogsService] 9 | }) 10 | export class OperationLogsModule {} 11 | -------------------------------------------------------------------------------- /src/modules/operation-logs/operation-logs.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class OperationLogsService {} 5 | -------------------------------------------------------------------------------- /src/modules/operation-logs/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './operation-log.schema' 2 | -------------------------------------------------------------------------------- /src/modules/operation-logs/schemas/operation-log.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' 2 | import type { HydratedDocument } from 'mongoose' 3 | 4 | export type OperationLogDocument = HydratedDocument 5 | 6 | @Schema({ 7 | id: true, 8 | timestamps: true, 9 | autoIndex: true 10 | }) 11 | export class OperationLog { 12 | // 模块名称 13 | @Prop() 14 | moduleName?: string 15 | 16 | // 操作类型 17 | @Prop() 18 | type?: string 19 | 20 | // 方法名称 21 | @Prop() 22 | method?: string 23 | 24 | // 请求方法 25 | @Prop() 26 | requestMethod?: string 27 | 28 | // 请求路径 29 | @Prop() 30 | requestUrl?: string 31 | 32 | // 请求查询参数 33 | @Prop() 34 | requestQuery?: string 35 | 36 | // 请求路径参数 37 | @Prop() 38 | requestParams?: string 39 | 40 | // 请求正文 41 | @Prop() 42 | requestBody?: string 43 | 44 | // 响应正文 45 | @Prop() 46 | responseBody?: string 47 | 48 | // 响应状态码 49 | @Prop() 50 | responseStatusCode?: number 51 | 52 | // 响应业务代码 53 | @Prop() 54 | responseBusinessCode?: number 55 | 56 | // 响应状态 57 | @Prop() 58 | responseStatus?: string 59 | 60 | // 响应消息 61 | @Prop() 62 | responseMessage?: string 63 | 64 | // 响应时间 65 | @Prop() 66 | responseDuration?: number 67 | 68 | // IP 69 | @Prop() 70 | ip?: string 71 | 72 | // 地区 73 | @Prop() 74 | area?: string 75 | 76 | // 访问来源 77 | @Prop() 78 | source?: string 79 | 80 | // 用户代理 81 | @Prop() 82 | userAgent?: string 83 | } 84 | 85 | export const OperationLogSchema = SchemaFactory.createForClass(OperationLog) 86 | -------------------------------------------------------------------------------- /src/modules/permissions/permissions.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common' 2 | import { ApiBasicAuth, ApiTags } from '@nestjs/swagger' 3 | 4 | import { PermissionsService } from './permissions.service' 5 | 6 | @ApiTags('权限') 7 | @ApiBasicAuth('bearer') 8 | @Controller('permissions') 9 | export class PermissionsController { 10 | constructor(private readonly permissionsService: PermissionsService) {} 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/permissions/permissions.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { PermissionsController } from './permissions.controller' 4 | import { PermissionsService } from './permissions.service' 5 | 6 | @Module({ 7 | controllers: [PermissionsController], 8 | providers: [PermissionsService] 9 | }) 10 | export class PermissionsModule {} 11 | -------------------------------------------------------------------------------- /src/modules/permissions/permissions.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class PermissionsService {} 5 | -------------------------------------------------------------------------------- /src/modules/roles/dto/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/nest-api/d190d4541625ef6ee9b5d88e167490d7410fd506/src/modules/roles/dto/index.ts -------------------------------------------------------------------------------- /src/modules/roles/roles.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Delete, Get, Patch, Post, Put } from '@nestjs/common' 2 | import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' 3 | 4 | import { R } from '@/class' 5 | 6 | import { RolesService } from './roles.service' 7 | 8 | @ApiTags('角色') 9 | @ApiBearerAuth('bearer') 10 | @Controller('roles') 11 | export class RolesController { 12 | constructor(private readonly rolesService: RolesService) {} 13 | 14 | @ApiOperation({ summary: '创建角色' }) 15 | @Post() 16 | async create() { 17 | return new R({}) 18 | } 19 | 20 | @ApiOperation({ summary: '角色列表' }) 21 | @Get() 22 | async findMany() { 23 | return new R({}) 24 | } 25 | 26 | @ApiOperation({ summary: '更新角色' }) 27 | @Put() 28 | async update() { 29 | return new R({}) 30 | } 31 | 32 | @ApiOperation({ summary: '部分更新' }) 33 | @Patch() 34 | async patch() { 35 | return new R({}) 36 | } 37 | 38 | @ApiOperation({ summary: '删除角色' }) 39 | @Delete() 40 | async remove() { 41 | return new R({}) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/roles/roles.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { RolesController } from './roles.controller' 4 | import { RolesService } from './roles.service' 5 | 6 | @Module({ 7 | controllers: [RolesController], 8 | providers: [RolesService] 9 | }) 10 | export class RolesModule {} 11 | -------------------------------------------------------------------------------- /src/modules/roles/roles.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class RolesService {} 5 | -------------------------------------------------------------------------------- /src/modules/roles/vo/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolphin-admin/nest-api/d190d4541625ef6ee9b5d88e167490d7410fd506/src/modules/roles/vo/index.ts -------------------------------------------------------------------------------- /src/modules/settings/dto/create-setting.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { 3 | IsBoolean, 4 | IsNotEmpty, 5 | IsOptional, 6 | IsString, 7 | MaxLength, 8 | NotContains 9 | } from 'class-validator' 10 | 11 | import { I18nUtils } from '@/utils' 12 | 13 | const { t } = I18nUtils 14 | 15 | export class CreateSettingDto { 16 | @ApiProperty({ description: '键' }) 17 | @MaxLength(50, { message: t('common.KEY.LENGTH') }) 18 | @IsString({ message: t('common.KEY.INVALID') }) 19 | @NotContains(' ', { message: t('common.KEY.NO.WHITESPACE') }) 20 | @IsNotEmpty({ message: t('common.KEY.NOT.EMPTY') }) 21 | key: string 22 | 23 | @ApiProperty({ description: '值' }) 24 | @MaxLength(250, { message: t('common.VALUE.LENGTH') }) 25 | @IsString({ message: t('common.VALUE.INVALID') }) 26 | @NotContains(' ', { message: t('common.VALUE.NO.WHITESPACE') }) 27 | @IsNotEmpty({ message: t('common.VALUE.NOT.EMPTY') }) 28 | value: string 29 | 30 | @ApiProperty({ description: '名称' }) 31 | @MaxLength(50, { message: t('common.LABEL.LENGTH') }) 32 | @IsString({ message: t('common.LABEL.INVALID') }) 33 | @IsNotEmpty({ message: t('common.LABEL.NOT.EMPTY') }) 34 | label: string 35 | 36 | @ApiPropertyOptional({ description: '备注' }) 37 | @MaxLength(500, { message: t('common.REMARK.LENGTH') }) 38 | @IsString({ message: t('common.REMARK.INVALID') }) 39 | @IsOptional() 40 | remark?: string 41 | 42 | @ApiProperty({ description: '是否启用' }) 43 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 44 | @IsNotEmpty({ message: t('common.ENABLED.NOT.EMPTY') }) 45 | enabled: boolean 46 | } 47 | -------------------------------------------------------------------------------- /src/modules/settings/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-setting.dto' 2 | export * from './page-setting.dto' 3 | export * from './update-setting.dto' 4 | -------------------------------------------------------------------------------- /src/modules/settings/dto/page-setting.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator' 3 | 4 | import { PageDto } from '@/class' 5 | import { ToId, Trim } from '@/decorators' 6 | import { I18nUtils } from '@/utils' 7 | 8 | const { t } = I18nUtils 9 | 10 | export class PageSettingDto extends PageDto { 11 | @ApiPropertyOptional({ description: 'ID' }) 12 | @IsNumber({}, { message: t('common.ID.INVALID') }) 13 | @IsOptional() 14 | @ToId() 15 | id?: number 16 | 17 | @ApiPropertyOptional({ description: '键' }) 18 | @IsString({ message: t('common.KEY.INVALID') }) 19 | @IsOptional() 20 | @Trim() 21 | key?: string 22 | 23 | @ApiPropertyOptional({ description: '值' }) 24 | @IsString({ message: t('common.VALUE.INVALID') }) 25 | @IsOptional() 26 | @Trim() 27 | value?: string 28 | 29 | @ApiPropertyOptional({ description: '是否启用' }) 30 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 31 | @IsOptional() 32 | enabled?: boolean 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/settings/dto/patch-setting.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsOptional, IsString, MaxLength, NotContains } from 'class-validator' 3 | 4 | import { I18nUtils } from '@/utils' 5 | 6 | const { t } = I18nUtils 7 | 8 | export class PatchSettingDto { 9 | @ApiPropertyOptional({ description: '键' }) 10 | @MaxLength(50, { message: t('common.KEY.LENGTH') }) 11 | @NotContains(' ', { message: t('common.KEY.NO.WHITESPACE') }) 12 | @IsString({ message: t('common.KEY.INVALID') }) 13 | @IsOptional() 14 | key?: string 15 | 16 | @ApiPropertyOptional({ description: '值' }) 17 | @MaxLength(250, { message: t('common.VALUE.LENGTH') }) 18 | @NotContains(' ', { message: t('common.VALUE.NO.WHITESPACE') }) 19 | @IsString({ message: t('common.VALUE.INVALID') }) 20 | @IsOptional() 21 | value?: string 22 | 23 | @ApiPropertyOptional({ description: '名称' }) 24 | @MaxLength(50, { message: t('common.LABEL.LENGTH') }) 25 | @IsString({ message: t('common.LABEL.INVALID') }) 26 | @IsOptional() 27 | label?: string 28 | 29 | @ApiPropertyOptional({ description: '备注' }) 30 | @MaxLength(500, { message: t('common.REMARK.LENGTH') }) 31 | @IsString({ message: t('common.REMARK.INVALID') }) 32 | @IsOptional() 33 | remark?: string 34 | 35 | @ApiPropertyOptional({ description: '是否启用' }) 36 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 37 | @IsOptional() 38 | enabled?: boolean 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/settings/dto/update-setting.dto.ts: -------------------------------------------------------------------------------- 1 | import { CreateSettingDto } from './create-setting.dto' 2 | 3 | export class UpdateSettingDto extends CreateSettingDto {} 4 | -------------------------------------------------------------------------------- /src/modules/settings/settings.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Patch, 9 | Post, 10 | Put, 11 | Query 12 | } from '@nestjs/common' 13 | import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger' 14 | import { I18n, I18nContext } from 'nestjs-i18n' 15 | 16 | import { R } from '@/class' 17 | import { 18 | ApiCreatedObjectResponse, 19 | ApiOkObjectResponse, 20 | ApiPageOKResponse, 21 | ApiPageQuery, 22 | Jwt 23 | } from '@/decorators' 24 | import type { I18nTranslations } from '@/generated/i18n.generated' 25 | 26 | import { PageSettingDto } from './dto' 27 | import { CreateSettingDto } from './dto/create-setting.dto' 28 | import { PatchSettingDto } from './dto/patch-setting.dto' 29 | import { UpdateSettingDto } from './dto/update-setting.dto' 30 | import { SettingsService } from './settings.service' 31 | import { SettingVo } from './vo' 32 | 33 | @ApiTags('系统设置') 34 | @ApiBearerAuth('bearer') 35 | @Controller('settings') 36 | export class SettingsController { 37 | constructor(private readonly settingsService: SettingsService) {} 38 | 39 | @ApiOperation({ summary: '创建设置' }) 40 | @ApiCreatedObjectResponse(SettingVo) 41 | @Post() 42 | async create( 43 | @Body() createSettingDto: CreateSettingDto, 44 | @Jwt('sub') userId: number, 45 | @I18n() i18n: I18nContext 46 | ) { 47 | return new R({ 48 | data: await this.settingsService.create(createSettingDto, userId), 49 | msg: i18n.t('common.CREATE.SUCCESS') 50 | }) 51 | } 52 | 53 | @ApiOperation({ summary: '设置列表' }) 54 | @ApiPageOKResponse(SettingVo) 55 | @ApiPageQuery('keywords', 'date') 56 | @Get() 57 | async findMany(@Query() pageSettingDto: PageSettingDto) { 58 | return new R({ 59 | data: await this.settingsService.findMany(pageSettingDto) 60 | }) 61 | } 62 | 63 | @ApiOperation({ summary: '设置详情 [ID]' }) 64 | @ApiOkObjectResponse(SettingVo) 65 | @Get(':id(\\d+)') 66 | async findOneById(@Param('id', new ParseIntPipe()) id: number) { 67 | return new R({ 68 | data: await this.settingsService.findOneById(id) 69 | }) 70 | } 71 | 72 | @ApiOperation({ summary: '设置详情 [Key]' }) 73 | @ApiOkObjectResponse(SettingVo) 74 | @Get(':key') 75 | async findOneByKey(@Param('key') key: string) { 76 | return new R({ 77 | data: await this.settingsService.findOneByKey(key) 78 | }) 79 | } 80 | 81 | @ApiOperation({ summary: '更新设置' }) 82 | @ApiOkObjectResponse(SettingVo) 83 | @Put(':id(\\d+)') 84 | async update( 85 | @Param('id', new ParseIntPipe()) id: number, 86 | @Body() updateSettingDto: UpdateSettingDto, 87 | @Jwt('sub') userId: number, 88 | @I18n() i18n: I18nContext 89 | ) { 90 | return new R({ 91 | data: await this.settingsService.update(id, updateSettingDto, userId), 92 | msg: i18n.t('common.UPDATE.SUCCESS') 93 | }) 94 | } 95 | 96 | @ApiOperation({ summary: '部分更新' }) 97 | @ApiOkObjectResponse(SettingVo) 98 | @Patch(':id(\\d+)') 99 | async patch( 100 | @Param('id', new ParseIntPipe()) id: number, 101 | @Body() patchSettingDto: PatchSettingDto, 102 | @Jwt('sub') userId: number, 103 | @I18n() i18n: I18nContext 104 | ) { 105 | return new R({ 106 | data: await this.settingsService.update(id, patchSettingDto, userId), 107 | msg: i18n.t('common.OPERATE.SUCCESS') 108 | }) 109 | } 110 | 111 | @ApiOperation({ summary: '删除设置' }) 112 | @ApiOkResponse() 113 | @Delete(':id(\\d+)') 114 | async remove( 115 | @Param('id', new ParseIntPipe()) id: number, 116 | @Jwt('sub') userId: number, 117 | @I18n() i18n: I18nContext 118 | ) { 119 | await this.settingsService.remove(id, userId) 120 | return new R({ 121 | msg: i18n.t('common.DELETE.SUCCESS') 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/modules/settings/settings.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { SettingsController } from './settings.controller' 4 | import { SettingsService } from './settings.service' 5 | 6 | @Module({ 7 | controllers: [SettingsController], 8 | providers: [SettingsService] 9 | }) 10 | export class SettingsModule {} 11 | -------------------------------------------------------------------------------- /src/modules/settings/vo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './setting.vo' 2 | -------------------------------------------------------------------------------- /src/modules/settings/vo/page-setting.vo.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer' 2 | 3 | import { Page } from '@/class' 4 | 5 | import { SettingVo } from './setting.vo' 6 | 7 | export class PageSettingVo extends Page { 8 | @Type(() => SettingVo) 9 | records: SettingVo[] 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/settings/vo/setting.vo.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | 3 | import { BaseResource } from '@/class' 4 | 5 | export class SettingVo extends BaseResource { 6 | @ApiProperty({ description: 'ID' }) 7 | id: number 8 | 9 | @ApiProperty({ description: '键' }) 10 | key: string 11 | 12 | @ApiProperty({ description: '值' }) 13 | value: string 14 | 15 | @ApiProperty({ description: '名称' }) 16 | label: string 17 | 18 | @ApiPropertyOptional({ description: '备注', nullable: true }) 19 | remark?: string 20 | 21 | @ApiProperty({ description: '是否启用' }) 22 | enabled: boolean 23 | 24 | @ApiPropertyOptional({ description: '排序', nullable: true }) 25 | sort?: number 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/user-traffics/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user-traffic.schema' 2 | export * from './user-traffic-record.schema' 3 | -------------------------------------------------------------------------------- /src/modules/user-traffics/schemas/user-traffic-record.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' 2 | import type { HydratedDocument } from 'mongoose' 3 | 4 | export type UserTrafficRecordDocument = HydratedDocument 5 | 6 | @Schema({ 7 | id: true, 8 | timestamps: true, 9 | collection: 'user_traffic_records' 10 | }) 11 | export class UserTrafficRecord { 12 | // 路由标题 13 | @Prop() 14 | title?: string 15 | 16 | // 路由 Url 17 | @Prop() 18 | url?: string 19 | 20 | // 路由路径 21 | @Prop() 22 | path?: string 23 | 24 | // 进入时间 25 | @Prop() 26 | enterAt?: Date 27 | 28 | // 离开时间 29 | @Prop() 30 | leaveAt?: Date 31 | 32 | // 停留时长(毫秒) 33 | @Prop() 34 | duration?: number 35 | } 36 | 37 | export const UserTrafficRecordSchema = SchemaFactory.createForClass(UserTrafficRecord) 38 | -------------------------------------------------------------------------------- /src/modules/user-traffics/schemas/user-traffic.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' 2 | import { type HydratedDocument, Types } from 'mongoose' 3 | 4 | import type { UserTrafficRecord } from './user-traffic-record.schema' 5 | 6 | export type UserTrafficDocument = HydratedDocument 7 | 8 | @Schema({ 9 | id: true, 10 | timestamps: true, 11 | collection: 'user_traffics' 12 | }) 13 | export class UserTraffic { 14 | // 应用名称 15 | @Prop() 16 | app?: string 17 | 18 | // 应用版本 19 | @Prop() 20 | version?: string 21 | 22 | // IP 23 | @Prop() 24 | ip?: string 25 | 26 | // 地区 27 | @Prop() 28 | area?: string 29 | 30 | // 访问来源 31 | @Prop() 32 | source?: string 33 | 34 | // 用户代理 35 | @Prop() 36 | userAgent?: string 37 | 38 | // 经度 39 | @Prop() 40 | longitude?: number 41 | 42 | // 纬度 43 | @Prop() 44 | latitude?: number 45 | 46 | // 海拔 47 | @Prop() 48 | altitude?: number 49 | 50 | // 进入页面时间 51 | @Prop() 52 | enterAt?: Date 53 | 54 | // 离开页面时间 55 | @Prop() 56 | leaveAt?: Date 57 | 58 | // 页面停留时长(毫秒) 59 | @Prop() 60 | duration?: number 61 | 62 | // 页面访问记录 63 | @Prop({ type: [{ type: Types.ObjectId, ref: 'UserTrafficRecord' }] }) 64 | pageViews?: UserTrafficRecord[] 65 | } 66 | 67 | export const UserTrafficSchema = SchemaFactory.createForClass(UserTraffic) 68 | -------------------------------------------------------------------------------- /src/modules/user-traffics/user-traffics.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common' 2 | import { ApiBasicAuth, ApiTags } from '@nestjs/swagger' 3 | 4 | import { UserTrafficsService } from './user-traffics.service' 5 | 6 | @ApiTags('用户流量') 7 | @ApiBasicAuth('bearer') 8 | @Controller('user-traffics') 9 | export class UserTrafficsController { 10 | constructor(private readonly userTrafficsService: UserTrafficsService) {} 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/user-traffics/user-traffics.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { MongooseModule } from '@nestjs/mongoose' 3 | 4 | import { 5 | UserTraffic, 6 | UserTrafficRecord, 7 | UserTrafficRecordSchema, 8 | UserTrafficSchema 9 | } from './schemas' 10 | import { UserTrafficsController } from './user-traffics.controller' 11 | import { UserTrafficsService } from './user-traffics.service' 12 | 13 | @Module({ 14 | imports: [ 15 | MongooseModule.forFeature([ 16 | { name: UserTraffic.name, schema: UserTrafficSchema }, 17 | { name: UserTrafficRecord.name, schema: UserTrafficRecordSchema } 18 | ]) 19 | ], 20 | controllers: [UserTrafficsController], 21 | providers: [UserTrafficsService] 22 | }) 23 | export class UserTrafficsModule {} 24 | -------------------------------------------------------------------------------- /src/modules/user-traffics/user-traffics.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class UserTrafficsService {} 5 | -------------------------------------------------------------------------------- /src/modules/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsNotEmpty, IsString, Length, Matches, NotContains } from 'class-validator' 3 | 4 | import { I18nUtils } from '@/utils' 5 | 6 | const { t } = I18nUtils 7 | 8 | export class CreateUserDto { 9 | @ApiProperty({ description: '用户名' }) 10 | @Length(4, 16, { message: t('user.USERNAME.LENGTH') }) 11 | @NotContains(' ', { message: t('user.USERNAME.NO.WHITESPACE') }) 12 | @IsString({ message: t('user.USERNAME.INVALID') }) 13 | @IsNotEmpty({ message: t('user.USERNAME.NOT.EMPTY') }) 14 | username: string 15 | 16 | @ApiProperty({ description: '密码' }) 17 | @Matches(/[0-9]/, { message: t('user.PASSWORD.CONTAIN.ONE.DIGITAL.CHARACTER') }) 18 | @Matches(/[a-zA-Z]/, { message: t('user.PASSWORD.CONTAIN.ONE.LETTER') }) 19 | @Length(6, 16, { message: t('user.PASSWORD.LENGTH') }) 20 | @NotContains(' ', { message: t('user.PASSWORD.NO.WHITESPACE') }) 21 | @IsString({ message: t('user.PASSWORD.INVALID') }) 22 | @IsNotEmpty({ message: t('user.PASSWORD.NOT.EMPTY') }) 23 | password: string 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/users/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-user.dto' 2 | export * from './page-user.dto' 3 | export * from './patch-user.dto' 4 | export * from './update-user.dto' 5 | -------------------------------------------------------------------------------- /src/modules/users/dto/page-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger' 2 | import { IsBoolean, IsNumber, IsOptional } from 'class-validator' 3 | 4 | import { PageDto } from '@/class' 5 | import { ToId } from '@/decorators' 6 | import { I18nUtils } from '@/utils' 7 | 8 | const { t } = I18nUtils 9 | 10 | export class PageUserDto extends PageDto { 11 | @ApiPropertyOptional({ description: 'ID' }) 12 | @IsNumber({}, { message: t('common.ID.INVALID') }) 13 | @IsOptional() 14 | @ToId() 15 | id?: number 16 | 17 | @ApiPropertyOptional({ description: '是否启用' }) 18 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 19 | @IsOptional() 20 | enabled?: boolean 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/users/dto/patch-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional, OmitType } from '@nestjs/swagger' 2 | import { IsOptional, IsString, Length, Matches, NotContains } from 'class-validator' 3 | 4 | import { I18nUtils } from '@/utils' 5 | 6 | import { UpdateUserDto } from './update-user.dto' 7 | 8 | const { t } = I18nUtils 9 | 10 | export class PatchUserDto extends OmitType(UpdateUserDto, ['username'] as const) { 11 | @ApiPropertyOptional({ description: '用户名' }) 12 | @IsString({ message: t('user.USERNAME.INVALID') }) 13 | @IsOptional() 14 | username?: string 15 | 16 | @ApiPropertyOptional({ description: '密码' }) 17 | @Matches(/[0-9]/, { message: t('user.PASSWORD.CONTAIN.ONE.DIGITAL.CHARACTER') }) 18 | @Matches(/[a-zA-Z]/, { message: t('user.PASSWORD.CONTAIN.ONE.LETTER') }) 19 | @Length(6, 16, { message: t('user.PASSWORD.LENGTH') }) 20 | @NotContains(' ', { message: t('user.PASSWORD.NO.WHITESPACE') }) 21 | @IsString({ message: t('user.PASSWORD.INVALID') }) 22 | @IsOptional() 23 | password?: string 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { 3 | IsBoolean, 4 | IsDate, 5 | IsEmail, 6 | IsNotEmpty, 7 | IsOptional, 8 | IsPhoneNumber, 9 | IsString, 10 | IsUrl, 11 | Length, 12 | MaxLength, 13 | NotContains 14 | } from 'class-validator' 15 | 16 | import { I18nUtils } from '@/utils' 17 | 18 | const { t } = I18nUtils 19 | 20 | export class UpdateUserDto { 21 | @ApiProperty({ description: '用户名' }) 22 | @Length(4, 16, { message: t('user.USERNAME.LENGTH') }) 23 | @NotContains(' ', { message: t('user.USERNAME.NO.WHITESPACE') }) 24 | @IsString({ message: t('user.USERNAME.INVALID') }) 25 | @IsNotEmpty({ message: t('user.USERNAME.NOT.EMPTY') }) 26 | username: string 27 | 28 | @ApiPropertyOptional({ description: '昵称' }) 29 | @MaxLength(16, { message: t('user.NICK.NAME.LENGTH') }) 30 | @IsString({ message: t('user.NICK.NAME.INVALID') }) 31 | @IsOptional() 32 | nickName?: string 33 | 34 | @ApiPropertyOptional({ description: '邮箱' }) 35 | @MaxLength(50, { message: t('user.EMAIL.LENGTH') }) 36 | @IsEmail(undefined, { message: t('user.EMAIL.INVALID') }) 37 | @IsOptional() 38 | email?: string 39 | 40 | @ApiPropertyOptional({ description: '手机号' }) 41 | @MaxLength(25, { message: t('user.PHONE.NUMBER.LENGTH') }) 42 | @IsPhoneNumber(undefined, { message: t('user.PHONE.NUMBER.INVALID') }) 43 | @IsOptional() 44 | phoneNumber?: string 45 | 46 | @ApiPropertyOptional({ description: '名' }) 47 | @MaxLength(10, { message: t('user.FIRST.NAME.INVALID') }) 48 | @IsString({ message: t('user.FIRST.NAME.INVALID') }) 49 | @IsOptional() 50 | firstName?: string 51 | 52 | @ApiPropertyOptional({ description: '中间名' }) 53 | @MaxLength(10, { message: t('user.MIDDLE.NAME.INVALID') }) 54 | @IsString({ message: t('user.MIDDLE.NAME.INVALID') }) 55 | @IsOptional() 56 | middleName?: string 57 | 58 | @ApiPropertyOptional({ description: '姓' }) 59 | @MaxLength(10, { message: t('user.LAST.NAME.INVALID') }) 60 | @IsString({ message: t('user.LAST.NAME.INVALID') }) 61 | @IsOptional() 62 | lastName?: string 63 | 64 | @ApiPropertyOptional({ description: '头像' }) 65 | @MaxLength(100, { message: t('user.AVATAR.URL.LENGTH') }) 66 | @IsUrl(undefined, { message: t('user.AVATAR.URL.INVALID') }) 67 | @IsOptional() 68 | avatarUrl?: string 69 | 70 | @ApiPropertyOptional({ description: '性别' }) 71 | @MaxLength(10, { message: t('user.GENDER.LENGTH') }) 72 | @IsString({ message: t('user.GENDER.INVALID') }) 73 | @IsOptional() 74 | gender?: string 75 | 76 | @ApiPropertyOptional({ description: '国家' }) 77 | @MaxLength(25, { message: t('user.COUNTRY.LENGTH') }) 78 | @IsString({ message: t('user.COUNTRY.INVALID') }) 79 | @IsOptional() 80 | country?: string 81 | 82 | @ApiPropertyOptional({ description: '省份' }) 83 | @MaxLength(25, { message: t('user.PROVINCE.LENGTH') }) 84 | @IsString({ message: t('user.PROVINCE.INVALID') }) 85 | @IsOptional() 86 | province?: string 87 | 88 | @ApiPropertyOptional({ description: '城市' }) 89 | @MaxLength(25, { message: t('user.CITY.LENGTH') }) 90 | @IsString({ message: t('user.CITY.INVALID') }) 91 | @IsOptional() 92 | city?: string 93 | 94 | @ApiPropertyOptional({ description: '地址' }) 95 | @MaxLength(100, { message: t('user.ADDRESS.LENGTH') }) 96 | @IsString({ message: t('user.ADDRESS.INVALID') }) 97 | @IsOptional() 98 | address?: string 99 | 100 | @ApiPropertyOptional({ description: '个人简介' }) 101 | @MaxLength(500, { message: t('user.BIOGRAPHY.LENGTH') }) 102 | @IsString({ message: t('user.BIOGRAPHY.INVALID') }) 103 | @IsOptional() 104 | biography?: string 105 | 106 | @ApiPropertyOptional({ description: '个人网站' }) 107 | @MaxLength(50, { message: t('user.WEBSITE.LENGTH') }) 108 | @IsUrl(undefined, { message: t('user.WEBSITE.INVALID') }) 109 | @IsOptional() 110 | website?: string 111 | 112 | @ApiPropertyOptional({ description: '个人主页' }) 113 | @MaxLength(50, { message: t('user.PROFILE.LENGTH') }) 114 | @IsUrl(undefined, { message: t('user.PROFILE.INVALID') }) 115 | @IsOptional() 116 | profile?: string 117 | 118 | @ApiPropertyOptional({ description: '出生日期' }) 119 | @IsDate({ message: t('user.BIRTH.DATE.INVALID') }) 120 | @IsOptional() 121 | birthDate?: Date 122 | 123 | @ApiPropertyOptional({ description: '是否启用' }) 124 | @IsBoolean({ message: t('common.ENABLED.INVALID') }) 125 | @IsOptional() 126 | enabled?: boolean 127 | } 128 | -------------------------------------------------------------------------------- /src/modules/users/online-users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common' 2 | import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' 3 | 4 | import { R } from '@/class' 5 | 6 | import { OnlineUsersService } from './online-users.service' 7 | 8 | @ApiTags('在线用户') 9 | @ApiBearerAuth('bearer') 10 | @Controller('online-users') 11 | export class OnlineUsersController { 12 | constructor(private readonly onlineUsersService: OnlineUsersService) {} 13 | 14 | @ApiOperation({ summary: '在线用户列表' }) 15 | @Get() 16 | async findMany() { 17 | return new R({ 18 | data: await this.onlineUsersService.findMany() 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/users/online-users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | import { CacheKeyService } from '@/shared/redis/cache-key.service' 4 | import { RedisService } from '@/shared/redis/redis.service' 5 | 6 | import type { UserVo } from './vo' 7 | 8 | @Injectable() 9 | export class OnlineUsersService { 10 | constructor( 11 | private readonly redisService: RedisService, 12 | private readonly cacheKeyService: CacheKeyService 13 | ) {} 14 | 15 | async create(userId: number, jti: string) { 16 | const cacheKey = this.cacheKeyService.getOnlineUserCacheKey(userId) 17 | await this.redisService.hSetObj( 18 | cacheKey, 19 | { 20 | userId, 21 | jti, 22 | lastActivityAt: new Date().toISOString() 23 | }, 24 | this.redisService.ONLINE_USER_TTL 25 | ) 26 | } 27 | 28 | async findMany() { 29 | const pattern = this.cacheKeyService.getOnlineUserCacheKey('*') 30 | const cacheKeys = await this.redisService.keys(pattern) 31 | const result = await this.redisService.mGetToJSON(cacheKeys) 32 | return result 33 | } 34 | 35 | async delete(userId: number) { 36 | const cacheKey = this.cacheKeyService.getOnlineUserCacheKey(userId) 37 | await this.redisService.del(cacheKey) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Patch, 9 | Post, 10 | Put, 11 | Query 12 | } from '@nestjs/common' 13 | import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' 14 | import { I18n, I18nContext } from 'nestjs-i18n' 15 | 16 | import { R } from '@/class' 17 | import { 18 | ApiCreatedObjectResponse, 19 | ApiOkObjectResponse, 20 | ApiOkResponse, 21 | ApiPageOKResponse, 22 | ApiPageQuery, 23 | Jwt 24 | } from '@/decorators' 25 | import type { I18nTranslations } from '@/generated/i18n.generated' 26 | 27 | import { CreateUserDto, PageUserDto, PatchUserDto, UpdateUserDto } from './dto' 28 | import { UsersService } from './users.service' 29 | import { UserVo } from './vo' 30 | 31 | @ApiTags('用户') 32 | @ApiBearerAuth('bearer') 33 | @Controller('users') 34 | export class UsersController { 35 | constructor(private readonly usersService: UsersService) {} 36 | 37 | @ApiOperation({ summary: '创建用户' }) 38 | @ApiCreatedObjectResponse(UserVo) 39 | @Post() 40 | async create( 41 | @Body() createUserDto: CreateUserDto, 42 | @Jwt('sub') userId: number, 43 | @I18n() i18n: I18nContext 44 | ) { 45 | return new R({ 46 | data: await this.usersService.create(createUserDto, userId), 47 | msg: i18n.t('common.CREATE.SUCCESS') 48 | }) 49 | } 50 | 51 | @ApiOperation({ summary: '用户列表' }) 52 | @ApiPageOKResponse(UserVo) 53 | @ApiPageQuery('keywords', 'date') 54 | @Get() 55 | async findMany(@Query() pageUserDto: PageUserDto) { 56 | return new R({ 57 | data: await this.usersService.findMany(pageUserDto) 58 | }) 59 | } 60 | 61 | @ApiOperation({ summary: '个人信息' }) 62 | @ApiOkObjectResponse(UserVo) 63 | @Get('profile') 64 | async findCurrent(@Jwt('sub') id: number) { 65 | return new R({ 66 | data: await this.usersService.findOneById(id) 67 | }) 68 | } 69 | 70 | @ApiOperation({ summary: '用户详情 [id]' }) 71 | @ApiOkObjectResponse(UserVo) 72 | @Get(':id(\\d+)') 73 | async findOneById(@Param('id') id: number) { 74 | return new R({ 75 | data: await this.usersService.findOneById(id) 76 | }) 77 | } 78 | 79 | @ApiOperation({ summary: '更新用户' }) 80 | @ApiOkObjectResponse(UserVo) 81 | @Put(':id(\\d+)') 82 | async update( 83 | @Param('id', new ParseIntPipe()) id: number, 84 | @Body() updateUserDto: UpdateUserDto, 85 | @Jwt('sub') userId: number, 86 | @I18n() i18n: I18nContext 87 | ) { 88 | return new R({ 89 | data: await this.usersService.update(id, updateUserDto, userId), 90 | msg: i18n.t('common.OPERATE.SUCCESS') 91 | }) 92 | } 93 | 94 | @ApiOperation({ summary: '部分更新' }) 95 | @ApiOkObjectResponse(UserVo) 96 | @Patch(':id(\\d+)') 97 | async patch( 98 | @Param('id', new ParseIntPipe()) id: number, 99 | @Body() patchUserDto: PatchUserDto, 100 | @Jwt('sub') userId: number, 101 | @I18n() i18n: I18nContext 102 | ) { 103 | return new R({ 104 | data: await this.usersService.update(id, patchUserDto, userId), 105 | msg: i18n.t('common.OPERATE.SUCCESS') 106 | }) 107 | } 108 | 109 | @ApiOperation({ summary: '删除用户' }) 110 | @ApiOkResponse() 111 | @Delete(':id(\\d+)') 112 | async remove( 113 | @Param('id', new ParseIntPipe()) id: number, 114 | @Jwt('sub') userId: number, 115 | @I18n() i18n: I18nContext 116 | ) { 117 | await this.usersService.remove(id, userId) 118 | return new R({ 119 | msg: i18n.t('common.DELETE.SUCCESS') 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/modules/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | 3 | import { OnlineUsersController } from './online-users.controller' 4 | import { OnlineUsersService } from './online-users.service' 5 | import { UsersController } from './users.controller' 6 | import { UsersService } from './users.service' 7 | 8 | @Module({ 9 | controllers: [UsersController, OnlineUsersController], 10 | providers: [UsersService, OnlineUsersService], 11 | exports: [UsersService, OnlineUsersService] 12 | }) 13 | export class UsersModule {} 14 | -------------------------------------------------------------------------------- /src/modules/users/vo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './page-user.vo' 2 | export * from './user.vo' 3 | -------------------------------------------------------------------------------- /src/modules/users/vo/page-user.vo.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer' 2 | 3 | import { Page } from '@/class' 4 | 5 | import { UserVo } from './user.vo' 6 | 7 | export class PageUserVo extends Page { 8 | @Type(() => UserVo) 9 | records: UserVo[] 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/users/vo/user.vo.ts: -------------------------------------------------------------------------------- 1 | import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' 2 | import { Exclude, Expose } from 'class-transformer' 3 | 4 | import { BaseResource } from '@/class' 5 | 6 | export class UserVo extends BaseResource { 7 | @ApiProperty({ description: 'ID' }) 8 | id: number 9 | 10 | @ApiProperty({ description: '用户名' }) 11 | username: string 12 | 13 | @ApiPropertyOptional({ description: '昵称', nullable: true }) 14 | nickName?: string 15 | 16 | // 密码 17 | @ApiHideProperty() 18 | @Exclude() 19 | password: string 20 | 21 | @ApiPropertyOptional({ description: '邮箱', nullable: true }) 22 | email?: string 23 | 24 | @ApiPropertyOptional({ description: '手机号', nullable: true }) 25 | phoneNumber?: string 26 | 27 | @ApiPropertyOptional({ description: '名', nullable: true }) 28 | firstName?: string 29 | 30 | @ApiPropertyOptional({ description: '中间名', nullable: true }) 31 | middleName?: string 32 | 33 | @ApiPropertyOptional({ description: '姓', nullable: true }) 34 | lastName?: string 35 | 36 | @ApiPropertyOptional({ 37 | name: 'fullName', 38 | description: '全名', 39 | type: () => String, 40 | nullable: true 41 | }) 42 | @Expose({ name: 'fullName' }) 43 | getFullName(): string { 44 | return [this.firstName, this.middleName ?? '', this.lastName].filter((item) => item).join(' ') 45 | } 46 | 47 | @ApiPropertyOptional({ description: '头像', nullable: true }) 48 | avatarUrl?: string 49 | 50 | @ApiPropertyOptional({ description: '性别', nullable: true }) 51 | gender?: string 52 | 53 | @ApiPropertyOptional({ description: '国家', nullable: true }) 54 | country?: string 55 | 56 | @ApiPropertyOptional({ description: '省份', nullable: true }) 57 | province?: string 58 | 59 | @ApiPropertyOptional({ description: '城市', nullable: true }) 60 | city?: string 61 | 62 | @ApiPropertyOptional({ description: '地址', nullable: true }) 63 | address?: string 64 | 65 | @ApiPropertyOptional({ description: '个人简介', nullable: true }) 66 | biography?: string 67 | 68 | @ApiPropertyOptional({ description: '个人网站', nullable: true }) 69 | website?: string 70 | 71 | @ApiPropertyOptional({ description: '个人主页', nullable: true }) 72 | profile?: string 73 | 74 | @ApiProperty({ description: '出生日期', nullable: true }) 75 | birthDate?: Date 76 | 77 | @ApiProperty({ description: '是否启用' }) 78 | enabled: boolean 79 | } 80 | -------------------------------------------------------------------------------- /src/shared/cos/cos.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, UploadedFiles, UseInterceptors } from '@nestjs/common' 2 | import { AnyFilesInterceptor } from '@nestjs/platform-express' 3 | import { 4 | ApiBadRequestResponse, 5 | ApiBasicAuth, 6 | ApiBody, 7 | ApiConsumes, 8 | ApiCreatedResponse, 9 | ApiOperation, 10 | ApiTags, 11 | ApiUnprocessableEntityResponse 12 | } from '@nestjs/swagger' 13 | 14 | import { FileVo } from '@/modules/files/vo' 15 | 16 | import { CosService } from './cos.service' 17 | 18 | @ApiTags('COS') 19 | @ApiBasicAuth('bearer') 20 | @Controller('cos') 21 | export class CosController { 22 | constructor(private readonly cosService: CosService) {} 23 | 24 | @ApiOperation({ summary: '上传文件 (腾讯云COS)' }) 25 | @ApiConsumes('multipart/form-data') 26 | @ApiCreatedResponse({ description: '上传成功', type: FileVo, isArray: true }) 27 | @ApiBadRequestResponse({ description: '文件为空' }) 28 | @ApiUnprocessableEntityResponse({ 29 | description: '文件大于 5MB | 文件类型不支持' 30 | }) 31 | @ApiBody({ description: '上传的文件' }) 32 | @UseInterceptors(AnyFilesInterceptor()) 33 | @Post() 34 | uploadToCos(@UploadedFiles() files: Express.Multer.File[]) { 35 | return this.cosService.uploadToCos(files) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/shared/cos/cos.module.ts: -------------------------------------------------------------------------------- 1 | import { extname } from 'node:path' 2 | 3 | import { Module, UnprocessableEntityException } from '@nestjs/common' 4 | import { MulterModule } from '@nestjs/platform-express' 5 | import { diskStorage } from 'multer' 6 | 7 | import { MAX_UPLOAD_FILE_SIZE } from '@/constants' 8 | import { fileExtensionMap } from '@/maps' 9 | import { GeneratorUtils } from '@/utils' 10 | 11 | import { CosController } from './cos.controller' 12 | import { CosService } from './cos.service' 13 | 14 | @Module({ 15 | imports: [ 16 | /** 17 | * TODO: 考虑使用配置文件 18 | * - 文件存储路径 19 | * - 文件大小限制 20 | * - 文件上传数量限制 21 | */ 22 | MulterModule.registerAsync({ 23 | useFactory: () => ({ 24 | storage: diskStorage({ 25 | destination: 'uploads', 26 | filename: (_, file, callback) => 27 | callback(null, GeneratorUtils.generateFileName(extname(file.originalname))) 28 | }), 29 | limits: { 30 | fileSize: MAX_UPLOAD_FILE_SIZE 31 | }, 32 | fileFilter: (_req, file, callback) => { 33 | if (fileExtensionMap.get(file.mimetype)) { 34 | callback(null, true) 35 | } else { 36 | callback(new UnprocessableEntityException('无法处理的文件类型'), false) 37 | } 38 | } 39 | }) 40 | }) 41 | ], 42 | controllers: [CosController], 43 | providers: [CosService] 44 | }) 45 | export class CosModule {} 46 | -------------------------------------------------------------------------------- /src/shared/cos/cos.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Inject, Injectable } from '@nestjs/common' 2 | import { ConfigType } from '@nestjs/config' 3 | import COS from 'cos-nodejs-sdk-v5' 4 | import { sep } from 'path' 5 | 6 | import { CosConfig } from '@/configs' 7 | import { FileVo } from '@/modules/files/vo' 8 | 9 | @Injectable() 10 | export class CosService { 11 | private cos: COS 12 | 13 | private bucket: string | undefined 14 | 15 | private region: string | undefined 16 | 17 | private readonly MIN_SLICE_SIZE = 1024 * 1024 * 10 18 | 19 | constructor(@Inject(CosConfig.KEY) private readonly cosConfig: ConfigType) { 20 | const { secretId, secretKey, bucket, region } = this.cosConfig 21 | this.cos = new COS({ 22 | SecretId: secretId, 23 | SecretKey: secretKey, 24 | FileParallelLimit: 3, // 控制文件上传并发数 25 | ChunkParallelLimit: 8, // 控制单个文件下分片上传并发数 26 | ChunkSize: 1024 * 1024 * 8, // 控制分片大小 27 | Proxy: '', 28 | Protocol: 'https:', 29 | FollowRedirect: false 30 | }) 31 | this.bucket = bucket 32 | this.region = region 33 | 34 | // TODO: 检查配置的逻辑应该在构造函数中执行 35 | } 36 | 37 | private checkConfig() { 38 | if (!this.bucket || !this.region) { 39 | throw new Error('COS bucket or region is empty!') 40 | } 41 | } 42 | 43 | uploadFile(file: Express.Multer.File) { 44 | this.checkConfig() 45 | this.cos.uploadFile( 46 | { 47 | Bucket: this.bucket!, 48 | Region: this.region!, 49 | Key: file.filename, 50 | FilePath: file.path, 51 | SliceSize: this.MIN_SLICE_SIZE, 52 | onTaskReady: (taskId: string) => { 53 | console.log(taskId) 54 | }, 55 | onProgress: (progressData) => { 56 | console.log(JSON.stringify(progressData)) 57 | }, 58 | onFileFinish: (err, data, options) => { 59 | console.log(`${options.Key}上传${err ? '失败' : '完成'}`) 60 | } 61 | }, 62 | (err, data) => { 63 | console.log('Error:', err) 64 | console.log('Data:', data) 65 | } 66 | ) 67 | } 68 | 69 | uploadFiles(files: Express.Multer.File[]) { 70 | this.checkConfig() 71 | if (!Array.isArray(files)) { 72 | throw new BadRequestException('No files to upload!') 73 | } 74 | console.log(files) 75 | const filesResult: COS.UploadFileItemParams[] = files.map((file) => ({ 76 | Bucket: this.bucket!, 77 | Region: this.region!, 78 | Key: file.filename, 79 | FilePath: file.path, 80 | SliceSize: this.MIN_SLICE_SIZE, 81 | onTaskReady: (taskId: string) => { 82 | console.log(taskId) 83 | } 84 | })) 85 | this.cos.uploadFiles( 86 | { 87 | files: filesResult, 88 | SliceSize: 1024 * 1024 * 10, 89 | onProgress: (progressData) => { 90 | console.log(JSON.stringify(progressData)) 91 | }, 92 | onFileFinish: (err, data) => { 93 | console.log('Error:', err) 94 | console.log('Data:', data) 95 | } 96 | }, 97 | (err, data) => { 98 | console.log(err ?? data) 99 | } 100 | ) 101 | } 102 | 103 | uploadToCos(files: Express.Multer.File[]): FileVo[] { 104 | this.uploadFiles(files) 105 | const filesResult = 106 | files?.map((file) => { 107 | const { fieldname, filename, mimetype, size, originalname } = file 108 | const path = file.path.replaceAll(sep, '/') 109 | // TODO: 使用日志记录 110 | console.log({ 111 | path, 112 | fieldname, 113 | filename, 114 | originalname, 115 | mimetype, 116 | size 117 | }) 118 | return new FileVo({ 119 | path, 120 | fieldname, 121 | filename, 122 | originalname, 123 | mimetype, 124 | size 125 | }) 126 | }) ?? [] 127 | return filesResult 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/shared/email/email.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { EmailService } from './email.service' 4 | 5 | @Global() 6 | @Module({ 7 | providers: [EmailService], 8 | exports: [EmailService] 9 | }) 10 | export class EmailModule {} 11 | -------------------------------------------------------------------------------- /src/shared/email/email.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common' 2 | import { ConfigType } from '@nestjs/config' 3 | import type { Transporter } from 'nodemailer' 4 | import { createTransport } from 'nodemailer' 5 | import type Mail from 'nodemailer/lib/mailer' 6 | 7 | import { AppConfig, NodemailerConfig } from '@/configs' 8 | 9 | @Injectable() 10 | export class EmailService { 11 | private transporter: Transporter 12 | 13 | constructor( 14 | @Inject(AppConfig.KEY) private readonly appConfig: ConfigType, 15 | @Inject(NodemailerConfig.KEY) 16 | private readonly nodemailerConfig: ConfigType 17 | ) { 18 | const { host, port, auth } = nodemailerConfig 19 | const { user, pass } = auth 20 | this.transporter = createTransport({ 21 | host, 22 | port, 23 | secure: true, 24 | auth: { 25 | user, 26 | pass 27 | } 28 | }) 29 | } 30 | 31 | async sendMail(options: Mail.Options) { 32 | await this.transporter.sendMail({ 33 | ...options, 34 | from: { 35 | name: this.appConfig.name, 36 | address: this.nodemailerConfig.auth.user 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/shared/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { CustomLogger } from './logger.service' 4 | 5 | @Global() 6 | @Module({ 7 | providers: [CustomLogger], 8 | exports: [CustomLogger] 9 | }) 10 | export class LoggerModule {} 11 | -------------------------------------------------------------------------------- /src/shared/logger/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { stdout } from 'node:process' 2 | 3 | import { Inject, Injectable, type LoggerService, Optional } from '@nestjs/common' 4 | import { ConfigType } from '@nestjs/config' 5 | import dayjs from 'dayjs' 6 | import pc from 'picocolors' 7 | 8 | import { AppConfig } from '@/configs' 9 | 10 | type LogLevel = 'log' | 'error' | 'warn' | 'debug' | 'verbose' | 'fatal' 11 | 12 | @Injectable() 13 | export class CustomLogger implements LoggerService { 14 | constructor( 15 | @Optional() 16 | @Inject(AppConfig.KEY) 17 | private readonly appConfig?: ConfigType 18 | ) {} 19 | 20 | private lastTimestamp: number = Date.now() 21 | 22 | private getTimestampDiff() { 23 | const now = Date.now() 24 | const result = now - this.lastTimestamp 25 | this.lastTimestamp = now 26 | return `${result}ms` 27 | } 28 | 29 | log(message: any, context?: string, ...optionalParams: any[]) { 30 | this.print('log', message, context, ...optionalParams) 31 | } 32 | 33 | error(message: any, context?: string, ...optionalParams: any[]) { 34 | this.print('error', message, context, ...optionalParams) 35 | } 36 | 37 | warn(message: any, context?: string, ...optionalParams: any[]) { 38 | this.print('warn', message, context, ...optionalParams) 39 | } 40 | 41 | debug(message: any, context?: string, ...optionalParams: any[]) { 42 | this.print('debug', message, context, ...optionalParams) 43 | } 44 | 45 | verbose(message: any, context?: string, ...optionalParams: any[]) { 46 | this.print('verbose', message, context, ...optionalParams) 47 | } 48 | 49 | fatal(message: any, context?: string, ...optionalParams: any[]) { 50 | this.print('fatal', message, context, ...optionalParams) 51 | } 52 | 53 | private print(level: LogLevel, message: any, context?: string, ...optionalParams: any[]) { 54 | const levelColor = this.getColorByLogLevel(level) 55 | const printLogLevel = pc.italic(levelColor(`${level.toUpperCase()}`)) 56 | const printCurrentTime = `- ${pc.dim(dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss'))}` 57 | const printContext = context 58 | ? pc.blue(`[${context}]`) 59 | : pc.blue(`[${this.appConfig?.name || '--'}]`) 60 | const printMessage = levelColor(message) 61 | const printParams = optionalParams.map((param) => pc.green(param)) 62 | const printTimestampDiff = pc.dim(`- ${this.getTimestampDiff()}\n`) 63 | stdout.write(printLogLevel) 64 | stdout.write(' ') 65 | stdout.write(printCurrentTime) 66 | stdout.write(' ') 67 | stdout.write(printContext) 68 | stdout.write(' ') 69 | stdout.write(printMessage) 70 | stdout.write(' ') 71 | stdout.write(printParams.join(' ')) 72 | stdout.write(printTimestampDiff) 73 | } 74 | 75 | private getColorByLogLevel(level: LogLevel) { 76 | switch (level) { 77 | case 'error': 78 | return pc.red 79 | case 'warn': 80 | return pc.yellow 81 | case 'debug': 82 | return pc.blue 83 | case 'verbose': 84 | return pc.gray 85 | case 'fatal': 86 | return pc.red 87 | case 'log': 88 | default: 89 | return pc.green 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/shared/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { PrismaService } from './prisma.service' 4 | 5 | @Global() 6 | @Module({ 7 | providers: [PrismaService], 8 | exports: [PrismaService] 9 | }) 10 | export class PrismaModule {} 11 | -------------------------------------------------------------------------------- /src/shared/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { performance } from 'node:perf_hooks' 3 | import process from 'node:process' 4 | import util from 'node:util' 5 | 6 | import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' 7 | import { Inject, Injectable } from '@nestjs/common' 8 | import { ConfigType } from '@nestjs/config' 9 | import { PrismaClient } from '@prisma/client' 10 | 11 | import { DevConfig } from '@/configs' 12 | 13 | @Injectable() 14 | export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { 15 | constructor(@Inject(DevConfig.KEY) private readonly devConfig: ConfigType) { 16 | super({ 17 | log: [], 18 | errorFormat: 'pretty' 19 | }) 20 | } 21 | 22 | /** 23 | * NOTE: 使用 OnModuleInit 来确保在应用程序启动时连接到数据库 24 | * 不使用 OnModuleInit 将进入懒加载模式,PrismaClient 将在第一次使用时连接到数据库 25 | */ 26 | async onModuleInit() { 27 | await this.$connect() 28 | 29 | const { enablePrismaLog } = this.devConfig 30 | 31 | // Prisma 扩展 32 | Object.assign( 33 | this, 34 | this.$extends({ 35 | query: { 36 | // 日志 37 | async $allOperations({ operation, model, args, query }) { 38 | const result = await query(args) 39 | if (!enablePrismaLog) { 40 | return result 41 | } 42 | const start = performance.now() 43 | const end = performance.now() 44 | const time = end - start 45 | process.stdout.write('------------ Prisma parameters: ------------\n') 46 | console.log( 47 | util.inspect( 48 | { model, operation, args, time }, 49 | { showHidden: false, depth: null, colors: true } 50 | ) 51 | ) 52 | process.stdout.write('------------ Prisma results: ------------\n') 53 | console.log(result) 54 | return result 55 | }, 56 | $allModels: { 57 | // 查询过滤软删除, deletedAt 是默认的软删除字段 58 | // async findFirst({ args, query }) { 59 | // args.where = { 60 | // deletedAt: null, 61 | // ...args.where 62 | // } 63 | // return query(args) 64 | // }, 65 | // async findFirstOrThrow({ args, query }) { 66 | // args.where = { 67 | // deletedAt: null, 68 | // ...args.where 69 | // } 70 | // return query(args) 71 | // }, 72 | // async findUnique({ args, query }) { 73 | // args.where = { 74 | // deletedAt: null, 75 | // ...args.where 76 | // } 77 | // return query(args) 78 | // }, 79 | // async findUniqueOrThrow({ args, query }) { 80 | // args.where = { 81 | // deletedAt: null, 82 | // ...args.where 83 | // } 84 | // return query(args) 85 | // }, 86 | // async findMany({ args, query }) { 87 | // args.where = { 88 | // deletedAt: null, 89 | // ...args.where 90 | // } 91 | // return query(args) 92 | // }, 93 | // async count({ args, query }) { 94 | // args.where = { 95 | // deletedAt: null, 96 | // ...args.where 97 | // } 98 | // return query(args) 99 | // } 100 | } 101 | } 102 | }) 103 | ) 104 | } 105 | 106 | // NOTE: 使用 OnModuleDestroy 来确保在应用程序关闭时断开数据库连接 107 | async onModuleDestroy() { 108 | await this.$disconnect() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/shared/redis/cache-key.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class CacheKeyService { 5 | /** 6 | * Jwt Metadata 缓存键 7 | */ 8 | private readonly JWT_METADATA_CACHE_KEY_PREFIX = 'jwt_metadata' 9 | 10 | getJwtMetadataCacheKey(pattern: string) { 11 | if (!pattern) { 12 | throw new Error('Pattern is required!') 13 | } 14 | return `${this.JWT_METADATA_CACHE_KEY_PREFIX}:${pattern}` 15 | } 16 | 17 | /** 18 | * 用户信息缓存键 19 | */ 20 | private readonly USER_CACHE_KEY_PREFIX = 'user' 21 | 22 | getUserCacheKey(pattern: number | string) { 23 | if (!pattern) { 24 | throw new Error('Pattern is required!') 25 | } 26 | return `${this.USER_CACHE_KEY_PREFIX}:${pattern}` 27 | } 28 | 29 | /** 30 | * 字典缓存键 31 | */ 32 | private readonly DICTIONARY_CACHE_KEY_PREFIX = 'dictionary' 33 | 34 | getDictionaryCacheKey(pattern: number | string) { 35 | if (!pattern) { 36 | throw new Error('Pattern is required!') 37 | } 38 | return `${this.DICTIONARY_CACHE_KEY_PREFIX}:${pattern}` 39 | } 40 | 41 | /** 42 | * 字典项缓存键 43 | */ 44 | private readonly DICTIONARY_ITEM_CACHE_KEY_PREFIX = 'dictionary_item' 45 | 46 | getDictionaryItemCacheKey(pattern: number | string) { 47 | if (!pattern) { 48 | throw new Error('Pattern is required!') 49 | } 50 | return `${this.DICTIONARY_ITEM_CACHE_KEY_PREFIX}:${pattern}` 51 | } 52 | 53 | /** 54 | * 设置缓存键 55 | */ 56 | private readonly SETTING_CACHE_KEY_PREFIX = 'setting' 57 | 58 | getSettingCacheKey(pattern: number | string) { 59 | if (!pattern) { 60 | throw new Error('Pattern is required!') 61 | } 62 | return `${this.SETTING_CACHE_KEY_PREFIX}:${pattern}` 63 | } 64 | 65 | /** 66 | * 在线用户信息缓存键 67 | */ 68 | private readonly ONLINE_USER_CACHE_KEY_PREFIX = 'online_user' 69 | 70 | getOnlineUserCacheKey(pattern: number | string) { 71 | if (!pattern) { 72 | throw new Error('Pattern is required!') 73 | } 74 | return `${this.ONLINE_USER_CACHE_KEY_PREFIX}:${pattern}` 75 | } 76 | 77 | /** 78 | * 多语言资源缓存键 79 | */ 80 | private readonly LOCALES_CACHE_KEY_PREFIX = 'locales' 81 | 82 | getLocalesCacheKey(pattern: string) { 83 | if (!pattern) { 84 | throw new Error('Pattern is required!') 85 | } 86 | return `${this.LOCALES_CACHE_KEY_PREFIX}:${pattern}` 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/shared/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | import { ConfigService } from '@nestjs/config' 3 | import { createClient } from 'redis' 4 | 5 | import { REDIS_CLIENT } from '@/constants' 6 | 7 | import { CacheKeyService } from './cache-key.service' 8 | import { RedisService } from './redis.service' 9 | 10 | @Global() 11 | @Module({ 12 | providers: [ 13 | RedisService, 14 | CacheKeyService, 15 | { 16 | provide: REDIS_CLIENT, 17 | async useFactory(configService: ConfigService) { 18 | const client = createClient({ 19 | socket: { 20 | host: configService.get('REDIS_HOST'), 21 | port: configService.get('REDIS_PORT') 22 | }, 23 | username: configService.get('REDIS_USERNAME'), 24 | password: configService.get('REDIS_PASSWORD'), 25 | database: configService.get('REDIS_DATABASE') 26 | }) 27 | await client.connect() 28 | return client 29 | }, 30 | inject: [ConfigService] 31 | } 32 | ], 33 | exports: [RedisService, CacheKeyService] 34 | }) 35 | export class RedisModule {} 36 | -------------------------------------------------------------------------------- /src/shared/redis/redis.service.ts: -------------------------------------------------------------------------------- 1 | import type { OnModuleDestroy } from '@nestjs/common' 2 | import { Inject, Injectable } from '@nestjs/common' 3 | import { RedisClientType } from 'redis' 4 | 5 | import { REDIS_CLIENT } from '@/constants' 6 | 7 | @Injectable() 8 | export class RedisService implements OnModuleDestroy { 9 | constructor(@Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType) {} 10 | 11 | // 数据默认缓存时间 12 | readonly DATA_DEFAULT_TTL = 60 * 60 13 | 14 | // 在线用户缓存时间 15 | readonly ONLINE_USER_TTL = 60 * 5 16 | 17 | async get(key: string) { 18 | const result = await this.redisClient.get(key) 19 | return result 20 | } 21 | 22 | async set(key: string, value: string | number, ttl?: number) { 23 | await this.redisClient.set(key, value) 24 | 25 | if (ttl) { 26 | await this.redisClient.expire(key, ttl) 27 | } 28 | } 29 | 30 | // 是否存在缓存 31 | async exists(key: string) { 32 | return !!(await this.redisClient.exists(key)) 33 | } 34 | 35 | // 设置 TTL 36 | async expire(key: string, ttl: number) { 37 | await this.redisClient.expire(key, ttl) 38 | } 39 | 40 | // 删除缓存 41 | async del(key: string | string[]) { 42 | await this.redisClient.del(key) 43 | } 44 | 45 | // 获取缓存 keys 46 | async keys(pattern: string) { 47 | const result = await this.redisClient.keys(pattern) 48 | return result 49 | } 50 | 51 | // 批量获取缓存 52 | async mGet(keys: string[]) { 53 | const result = await this.redisClient.mGet(keys) 54 | return result 55 | } 56 | 57 | // 批量获取 JSON 缓存 58 | async mGetToJSON(keys: string[]): Promise { 59 | const values = await this.redisClient.mGet(keys) 60 | return values.map((i) => (i ? JSON.parse(i) : null)) 61 | } 62 | 63 | // 设置 JSON 缓存 64 | async jsonSet(key: string, value: any, ttl?: number) { 65 | await this.redisClient.set(key, JSON.stringify(value)) 66 | 67 | if (ttl) { 68 | await this.redisClient.expire(key, ttl) 69 | } 70 | } 71 | 72 | // 获取 JSON 缓存 73 | async jsonGet(key: string): Promise { 74 | const value = await this.redisClient.get(key) 75 | return value ? JSON.parse(value) : null 76 | } 77 | 78 | // 获取 Hash 缓存 79 | async hGet(key: string, field: string): Promise { 80 | const result = await this.redisClient.hGet(key, field) 81 | return result ? JSON.parse(result) : null 82 | } 83 | 84 | // 设置 Hash 缓存 85 | async hSet(key: string, field: string, value: unknown) { 86 | await this.redisClient.hSet(key, field, JSON.stringify(value)) 87 | } 88 | 89 | // 删除 Hash 缓存 90 | async hDel(key: string, field: string) { 91 | await this.redisClient.hDel(key, field) 92 | } 93 | 94 | // 获取 Hash 对象缓存 95 | async hGetAll(key: string): Promise { 96 | if (!(await this.exists(key))) { 97 | return null 98 | } 99 | const result = await this.redisClient.hGetAll(key) 100 | 101 | const parsedResult: Record = {} 102 | 103 | Object.entries(result).forEach(([field, value]) => { 104 | parsedResult[field] = JSON.parse(value) 105 | }) 106 | 107 | return parsedResult as T 108 | } 109 | 110 | // 设置 Hash 对象缓存 111 | async hSetObj(key: string, obj: Record, ttl?: number) { 112 | const hSetPromises = Object.keys(obj).map((field) => 113 | this.redisClient.hSet(key, field, JSON.stringify(obj[field])) 114 | ) 115 | 116 | await Promise.all(hSetPromises) 117 | 118 | if (ttl) { 119 | await this.redisClient.expire(key, ttl) 120 | } 121 | } 122 | 123 | // 设置 Set 缓存 124 | async sAdd(key: string, value: string) { 125 | await this.redisClient.sAdd(key, value) 126 | } 127 | 128 | // 获取 Set 缓存 129 | async sMembers(key: string) { 130 | const result = await this.redisClient.sMembers(key) 131 | return result 132 | } 133 | 134 | // 判断 Set 缓存是否存在 135 | async sIsMember(key: string, value: string) { 136 | return !!(await this.redisClient.sIsMember(key, value)) 137 | } 138 | 139 | // 删除 Set 缓存 140 | async sRem(key: string, value: string) { 141 | await this.redisClient.sRem(key, value) 142 | } 143 | 144 | // 获取 Set 缓存长度 145 | async sCard(key: string) { 146 | const result = await this.redisClient.sCard(key) 147 | return result 148 | } 149 | 150 | // 关闭连接 151 | async onModuleDestroy() { 152 | await this.redisClient.quit() 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/utils/generator.util.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid' 2 | 3 | export class GeneratorUtils { 4 | /** 5 | * 生成 uuid 6 | */ 7 | static generateUuid(): string { 8 | return v4() 9 | } 10 | 11 | /** 12 | * 生成文件名 13 | * @param ext 文件后缀 14 | */ 15 | static generateFileName(ext: string): string { 16 | return `${this.generateUuid()}.${ext}` 17 | } 18 | 19 | /** 20 | * 生成验证码 21 | */ 22 | static generateVerificationCode(): string { 23 | return Math.floor(1000 + Math.random() * 9000).toString() 24 | } 25 | 26 | /** 27 | * 生成密码 28 | */ 29 | static generatePassword(): string { 30 | const lowercase = 'abcdefghijklmnopqrstuvwxyz' 31 | const uppercase = lowercase.toUpperCase() 32 | const numbers = '0123456789' 33 | let text = '' 34 | for (let i = 0; i < 4; i += 1) { 35 | text += uppercase.charAt(Math.floor(Math.random() * uppercase.length)) 36 | text += lowercase.charAt(Math.floor(Math.random() * lowercase.length)) 37 | text += numbers.charAt(Math.floor(Math.random() * numbers.length)) 38 | } 39 | return text 40 | } 41 | 42 | /** 43 | * 生成随机字符串 44 | * @param length 随机字符串长度 45 | */ 46 | static generateRandomString(length: number): string { 47 | return Math.random() 48 | .toString(36) 49 | .replaceAll(/[^\dA-Za-z]+/g, '') 50 | .slice(0, Math.max(0, length)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/i18n.util.ts: -------------------------------------------------------------------------------- 1 | import { i18nValidationMessage } from 'nestjs-i18n' 2 | 3 | import type { I18nTranslations } from '@/generated/i18n.generated' 4 | 5 | export class I18nUtils { 6 | static t: typeof i18nValidationMessage = i18nValidationMessage 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generator.util' 2 | export * from './i18n.util' 3 | export * from './user-agent.util' 4 | -------------------------------------------------------------------------------- /src/utils/user-agent.util.ts: -------------------------------------------------------------------------------- 1 | import { UAParser } from 'ua-parser-js' 2 | 3 | export class UserAgentUtil { 4 | static parseUserAgent(userAgent?: string) { 5 | const uaParser = new UAParser(userAgent) 6 | return { 7 | userAgent: uaParser.getUA(), 8 | browser: this.parseBrowser(uaParser.getBrowser()), 9 | os: this.parseOS(uaParser.getOS()), 10 | device: this.parseDevice(uaParser.getDevice()), 11 | engine: this.parseEngine(uaParser.getEngine()), 12 | cpu: this.parseCPU(uaParser.getCPU()) 13 | } 14 | } 15 | 16 | static parseBrowser(browser?: UAParser.IBrowser) { 17 | const { name, version, major } = browser || {} 18 | let result = '' 19 | if (name) { 20 | result += name 21 | } 22 | if (version) { 23 | result += ` ${version}` 24 | } 25 | if (major) { 26 | result += ` ${major}` 27 | } 28 | return result 29 | } 30 | 31 | static parseOS(os?: UAParser.IOS) { 32 | const { name, version } = os || {} 33 | let result = '' 34 | if (name) { 35 | result += name 36 | } 37 | if (version) { 38 | result += ` ${version}` 39 | } 40 | return result 41 | } 42 | 43 | static parseDevice(device?: UAParser.IDevice) { 44 | const { type, vendor, model } = device || {} 45 | let result = '' 46 | if (type) { 47 | result += type 48 | } 49 | if (vendor) { 50 | result += ` ${vendor}` 51 | } 52 | if (model) { 53 | result += ` ${model}` 54 | } 55 | return result 56 | } 57 | 58 | static parseEngine(engine?: UAParser.IEngine) { 59 | const { name, version } = engine || {} 60 | let result = '' 61 | if (name) { 62 | result += name 63 | } 64 | if (version) { 65 | result += ` ${version}` 66 | } 67 | return result 68 | } 69 | 70 | static parseCPU(cpu?: UAParser.ICPU) { 71 | const { architecture } = cpu || {} 72 | let result = '' 73 | if (architecture) { 74 | result += architecture 75 | } 76 | return result 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import type { INestApplication } from '@nestjs/common' 2 | import type { TestingModule } from '@nestjs/testing' 3 | import { Test } from '@nestjs/testing' 4 | import request from 'supertest' 5 | 6 | import { AppModule } from '../src/modules/app.module' 7 | 8 | describe('AppController (e2e)', () => { 9 | let app: INestApplication 10 | 11 | beforeEach(async () => { 12 | const moduleFixture: TestingModule = await Test.createTestingModule({ 13 | imports: [AppModule] 14 | }).compile() 15 | 16 | app = moduleFixture.createNestApplication() 17 | await app.init() 18 | }) 19 | 20 | // TODO: Skip AuthGuard for testing 21 | it('/ (GET)', () => request(app.getHttpServer()).get('/').expect(401)) 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 | "moduleNameMapper": { 10 | "@/(.*)": "/src/$1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | 4 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts", "test/**/*.ts", "prisma/seed.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@/*": ["src/*"] 15 | }, 16 | "incremental": true, 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "noImplicitAny": true, 21 | "strictBindCallApply": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "strictPropertyInitialization": false, 25 | "esModuleInterop": true, 26 | "moduleResolution": "Node", 27 | "resolveJsonModule": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= title 5 | p #{title} is an opinionated Nest boilerplate. 6 | span Powered by Bruce Song 7 | -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= title 5 | link(rel='stylesheet', href='/stylesheets/style.css') 6 | body 7 | block content 8 | --------------------------------------------------------------------------------