本地oa-backup
This commit is contained in:
commit
f3b09bbed4
|
@ -0,0 +1,94 @@
|
|||
// 请使用npm run c提交代码。遵循代码提交规范
|
||||
module.exports = {
|
||||
types: [
|
||||
{ value: 'feat', name: '功能: ✨ 新增功能', emoji: ':sparkles:' },
|
||||
{ value: 'fix', name: '修复: 🐛 修复缺陷', emoji: ':bug:' },
|
||||
{ value: 'docs', name: '文档: 📝 文档变更', emoji: ':memo:' },
|
||||
{
|
||||
value: 'style',
|
||||
name: '格式: 🌈 代码格式(不影响功能,例如空格、分号等格式修正)',
|
||||
emoji: ':lipstick:',
|
||||
},
|
||||
{
|
||||
value: 'refactor',
|
||||
name: '重构: 🔄 代码重构(不包括 bug 修复、功能新增)',
|
||||
emoji: ':recycle:',
|
||||
},
|
||||
{ value: 'perf', name: '性能: 🚀 性能优化', emoji: ':zap:' },
|
||||
{
|
||||
value: 'test',
|
||||
name: '测试: 🧪 添加疏漏测试或已有测试改动',
|
||||
emoji: ':white_check_mark:',
|
||||
},
|
||||
{
|
||||
value: 'build',
|
||||
name: '构建: 📦️ 构建流程、外部依赖变更(如升级 npm 包、修改 vite 配置等)',
|
||||
emoji: ':package:',
|
||||
},
|
||||
{
|
||||
value: 'ci',
|
||||
name: '集成: ⚙️ 修改 CI 配置、脚本',
|
||||
emoji: ':ferris_wheel:',
|
||||
},
|
||||
{ value: 'revert', name: '回退: ↩️ 回滚 commit', emoji: ':rewind:' },
|
||||
{
|
||||
value: 'chore',
|
||||
name: '其他: 🛠️ 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)',
|
||||
emoji: ':hammer:',
|
||||
},
|
||||
],
|
||||
useEmoji: true,
|
||||
emojiAlign: 'center',
|
||||
useAI: false,
|
||||
aiNumber: 1,
|
||||
themeColorCode: '',
|
||||
scopes: [],
|
||||
allowCustomScopes: true,
|
||||
allowEmptyScopes: true,
|
||||
customScopesAlign: 'bottom',
|
||||
customScopesAlias: 'custom',
|
||||
emptyScopesAlias: 'empty',
|
||||
upperCaseSubject: false,
|
||||
markBreakingChangeMode: false,
|
||||
breaklineNumber: 100,
|
||||
breaklineChar: '|',
|
||||
issuePrefixes: [
|
||||
{ value: 'closed', name: 'closed: ISSUES has been processed' },
|
||||
],
|
||||
customIssuePrefixAlign: 'top',
|
||||
emptyIssuePrefixAlias: 'skip',
|
||||
customIssuePrefixAlias: 'custom',
|
||||
allowCustomIssuePrefix: true,
|
||||
allowEmptyIssuePrefix: true,
|
||||
confirmColorize: true,
|
||||
maxHeaderLength: Infinity,
|
||||
maxSubjectLength: Infinity,
|
||||
minSubjectLength: 0,
|
||||
scopeOverrides: undefined,
|
||||
defaultBody: '',
|
||||
defaultIssues: '',
|
||||
defaultScope: '',
|
||||
defaultSubject: '',
|
||||
messages: {
|
||||
type: '选择一种你期望的提交类型(type):',
|
||||
// scope: '选择一个更改的范围(scope) (可选):',
|
||||
// used if allowCustomScopes is true
|
||||
// customScope: 'Denote the SCOPE of this change:',
|
||||
subject: '输入本次commit记录说明:\n',
|
||||
// body: '长说明,使用"|"换行(可选):\n',
|
||||
// breaking: '非兼容性说明 (可选):\n',
|
||||
// footer: '关联关闭的issue,例如:#31, #34(可选):\n',
|
||||
confirmCommit: '确定提交说明?',
|
||||
},
|
||||
skipQuestions: ['scope', 'body', 'breaking', 'footer'],
|
||||
allowBreakingChanges: [
|
||||
'fix',
|
||||
'feat',
|
||||
'update',
|
||||
'refactor',
|
||||
'perf',
|
||||
'build',
|
||||
'revert',
|
||||
],
|
||||
subjectLimit: 500, // 提交长度限制500
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
# package-lock.json
|
||||
# yarn.lock
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Code
|
||||
src/config/config.development.*
|
||||
docs/*
|
||||
# sql/*
|
||||
test/*
|
||||
README.md
|
||||
|
||||
# Dev data
|
||||
/__data/
|
|
@ -0,0 +1,40 @@
|
|||
# app
|
||||
APP_NAME = Huaxin OA
|
||||
APP_PORT = 8001
|
||||
APP_BASE_URL = http://localhost:${APP_PORT}
|
||||
APP_LOCALE = zh-CN
|
||||
|
||||
# cluster
|
||||
CPU_LEN = 1
|
||||
|
||||
# logger
|
||||
LOGGER_LEVEL = verbose
|
||||
LOGGER_MAX_FILES = 31
|
||||
|
||||
TZ = Asia/Shanghai
|
||||
|
||||
# OSS(minio)
|
||||
OSS_ACCESSKEY=8Zttvx4ZbF2ikFRb
|
||||
OSS_SECRETKEY=SCgOJEJXM5vMNQL4fF8opXA1wmpACRfw
|
||||
OSS_PORT=8021
|
||||
OSS_DOMAIN=144.123.43.138
|
||||
OSS_DOMAIN_USE_SSL=false
|
||||
OSS_BUCKET=tes1
|
||||
OSS_ZONE=Zone_z2 # Zone_as0 | Zone_na0 | Zone_z0 | Zone_z1 | Zone_z2
|
||||
OSS_ACCESS_TYPE=public # or private
|
||||
|
||||
|
||||
|
||||
DB_HOST = host.docker.internal
|
||||
DB_PORT = 13307
|
||||
DB_DATABASE = hxoa
|
||||
DB_USERNAME = root
|
||||
DB_PASSWORD = huaxin123
|
||||
DB_SYNCHRONIZE = false
|
||||
DB_LOGGING = ["error"]
|
||||
|
||||
REDIS_PORT = 6379
|
||||
REDIS_HOST = host.docker.internal
|
||||
REDIS_PASSWORD = 123456
|
||||
REDIS_DB = 0
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# logger
|
||||
LOGGER_LEVEL = debug
|
||||
|
||||
# security
|
||||
JWT_SECRET = admin!@#123
|
||||
JWT_EXPIRE = 86400 # 单位秒
|
||||
REFRESH_TOKEN_SECRET = admin!@#123
|
||||
REFRESH_TOKEN_EXPIRE = 2592000
|
||||
|
||||
# swagger
|
||||
SWAGGER_ENABLE = true
|
||||
SWAGGER_PATH = api-docs
|
||||
SWAGGER_VERSION = 1.0
|
||||
|
||||
# db
|
||||
DB_HOST = localhost
|
||||
DB_PORT = 13307
|
||||
DB_DATABASE = hxoa
|
||||
DB_USERNAME = root
|
||||
DB_PASSWORD = huaxin123
|
||||
DB_SYNCHRONIZE = true
|
||||
DB_LOGGING = "all"
|
||||
|
||||
# redis
|
||||
REDIS_PORT = 6379
|
||||
REDIS_HOST = localhost
|
||||
REDIS_PASSWORD = 123456
|
||||
REDIS_DB = 0
|
||||
|
||||
# smtp
|
||||
SMTP_HOST = smtp.163.com
|
||||
SMTP_PORT = 465
|
||||
SMTP_USER = nest_admin@163.com
|
||||
SMTP_PASS = VIPLLOIPMETTROYU
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# logger
|
||||
LOGGER_LEVEL = debug
|
||||
|
||||
# security
|
||||
JWT_SECRET = admin!@#123
|
||||
JWT_EXPIRE = 86400 # 单位秒
|
||||
REFRESH_TOKEN_SECRET = admin!@#123
|
||||
REFRESH_TOKEN_EXPIRE = 2592000
|
||||
|
||||
# swagger
|
||||
SWAGGER_ENABLE = true
|
||||
SWAGGER_PATH = api-docs
|
||||
SWAGGER_VERSION = 1.0
|
||||
|
||||
# db
|
||||
DB_HOST = host.docker.internal
|
||||
DB_PORT = 13307
|
||||
DB_DATABASE = hxoa
|
||||
DB_USERNAME = root
|
||||
DB_PASSWORD = huaxin123
|
||||
DB_SYNCHRONIZE = false
|
||||
DB_LOGGING = ["error"]
|
||||
|
||||
# redis
|
||||
REDIS_PORT = 6379
|
||||
REDIS_HOST = host.docker.internal
|
||||
REDIS_PASSWORD = 123456
|
||||
REDIS_DB = 0
|
||||
|
||||
# smtp
|
||||
SMTP_HOST = smtp.163.com
|
||||
SMTP_PORT = 465
|
||||
SMTP_USER = nest_admin@163.com
|
||||
SMTP_PASS = VIPLLOIPMETTROYU
|
||||
|
||||
# 是否为演示模式(在演示模式下,会拦截除 GET 方法以外的所有请求)
|
||||
IS_DEMO = false
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
dist/
|
||||
config
|
||||
build/
|
||||
.eslintrc.js
|
||||
package.json
|
||||
tsconfig**.json
|
||||
.vscode/
|
|
@ -0,0 +1,22 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin', 'prettier'],
|
||||
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-var-requires': 0,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
# https://docs.github.com/cn/get-started/getting-started-with-git/configuring-git-to-handle-line-endings
|
||||
|
||||
# Automatically normalize line endings (to LF) for all text-based files.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
*.{cmd,[cC][mM][dD]} text eol=crlf
|
||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.{ico,png,jpg,jpeg,gif,webp,svg,woff,woff2} binary
|
|
@ -0,0 +1,78 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
*-dist
|
||||
.cache
|
||||
.history
|
||||
.vercel/
|
||||
|
||||
.turbo
|
||||
.local
|
||||
|
||||
# local env files
|
||||
#.env.development
|
||||
#.env.production
|
||||
.env.local
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
# .vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
.nestjs_repl_history
|
||||
out
|
||||
|
||||
# temp data
|
||||
__data
|
||||
# 我想把upload文件夹传上去
|
||||
/public/upload/*
|
||||
!/public/upload/.gitkeep
|
||||
types/env.d.ts
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npm run commitlint
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
#推送之前运行eslint检查
|
||||
npx lint-staged
|
||||
##推送之前运行单元测试检查
|
||||
#npm run test:unit
|
|
@ -0,0 +1,6 @@
|
|||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
|
||||
# 使用淘宝镜像源
|
||||
registry = https://registry.npmmirror.com
|
||||
# registry = https://registry.npmjs.org
|
|
@ -0,0 +1,11 @@
|
|||
/dist/*
|
||||
.local
|
||||
.output.js
|
||||
/node_modules/**
|
||||
|
||||
**/*.svg
|
||||
**/*.sh
|
||||
|
||||
/public/*
|
||||
test/**/*
|
||||
/.vscode/*
|
|
@ -0,0 +1,11 @@
|
|||
module.exports = {
|
||||
printWidth: 100, // 每行代码长度(默认80)
|
||||
tabWidth: 2, // 每个tab相当于多少个空格(默认2)
|
||||
useTabs: false, // 是否使用tab进行缩进(默认false)
|
||||
singleQuote: true, // 使用单引号(默认false)
|
||||
semi: true, // 声明结尾使用分号(默认true)
|
||||
trailingComma: 'none', // 多行使用拖尾逗号(默认none)
|
||||
bracketSpacing: true, // 对象字面量的大括号间使用空格(默认true)
|
||||
arrowParens: 'avoid', // 只有一个参数的箭头函数的参数是否带圆括号(默认avoid)
|
||||
endOfLine: 'auto', // 文件换行格式 LF/CRLF
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
//发布应用 生成commit日志记录。
|
||||
|
||||
module.exports = {
|
||||
types: [
|
||||
{ type: 'feat', section: '✨ Features | 新功能' },
|
||||
{ type: 'fix', section: '🐛 Bug Fixes | Bug 修复' },
|
||||
{ type: 'init', section: '📦️ Init | 初始化' },
|
||||
{ type: 'docs', section: '📝 Documentation | 文档' },
|
||||
{ type: 'style', section: '🌈 Styles | 风格' },
|
||||
{ type: 'refactor', section: '🔄 Code Refactoring | 代码重构' },
|
||||
{ type: 'perf', section: '🚀 Performance Improvements | 性能优化' },
|
||||
{ type: 'test', section: '🧪 Tests | 测试' },
|
||||
{ type: 'revert', section: '↩️ Revert | 回退' },
|
||||
{ type: 'build', section: '📦️ Build System | 打包构建' },
|
||||
{ type: 'update', section: '⚙️ update | 构建/工程依赖/工具升级' },
|
||||
{ type: 'tool', section: '🛠️ tool | 工具升级' },
|
||||
{ type: 'ci', section: '⚙️ Continuous Integration | CI 配置' },
|
||||
],
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Nest Framework",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "start:debug", "--", "--inspect-brk"],
|
||||
"autoAttachChildProcesses": true,
|
||||
"restart": true,
|
||||
"sourceMaps": true,
|
||||
"stopOnEntry": false,
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
# 遇到网络问题可以配置镜像加速:https://gist.github.com/y0ngb1n/7e8f16af3242c7815e7ca2f0833d3ea6
|
||||
# FROM 表示设置要制作的镜像基于哪个镜像,FROM指令必须是整个Dockerfile的第一个指令,如果指定的镜像不存在默认会自动从Docker Hub上下载。
|
||||
# 指定我们的基础镜像是node,latest表示版本是最新, 如果要求空间极致,可以选择lts-alpine
|
||||
# 使用 as 来为某一阶段命名
|
||||
FROM node:20-slim AS base
|
||||
|
||||
ENV PROJECT_DIR=/huaxin-admin \
|
||||
DB_HOST=mysql \
|
||||
APP_PORT=8001 \
|
||||
PNPM_HOME="/pnpm" \
|
||||
PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
|
||||
RUN corepack enable \
|
||||
&& yarn global add pm2
|
||||
|
||||
# WORKDIR指令用于设置Dockerfile中的RUN、CMD和ENTRYPOINT指令执行命令的工作目录(默认为/目录),该指令在Dockerfile文件中可以出现多次,
|
||||
# 如果使用相对路径则为相对于WORKDIR上一次的值,
|
||||
# 例如WORKDIR /data,WORKDIR logs,RUN pwd最终输出的当前目录是/data/logs。
|
||||
# cd 到 /huaxin-admin
|
||||
WORKDIR $PROJECT_DIR
|
||||
COPY ./ $PROJECT_DIR
|
||||
RUN chmod +x ./wait-for-it.sh
|
||||
|
||||
# set timezone
|
||||
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo 'Asia/Shanghai' > /etc/timezone
|
||||
|
||||
# see https://pnpm.io/docker
|
||||
FROM base AS prod-deps
|
||||
RUN pnpm config set registry https://registry.npmmirror.com
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
RUN pnpm config set registry https://registry.npmmirror.com
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm run build
|
||||
|
||||
|
||||
# mirror acceleration
|
||||
# RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# RUN npm config rm proxy && npm config rm https-proxy
|
||||
|
||||
FROM base
|
||||
COPY --from=prod-deps $PROJECT_DIR/node_modules $PROJECT_DIR/node_modules
|
||||
COPY --from=build $PROJECT_DIR/dist $PROJECT_DIR/dist
|
||||
|
||||
# EXPOSE port
|
||||
EXPOSE $APP_PORT
|
||||
|
||||
# 容器启动时执行的命令,类似npm run start
|
||||
# CMD ["pnpm", "start:prod"]
|
||||
# CMD ["pm2-runtime", "ecosystem.config.js"]
|
||||
ENTRYPOINT ./wait-for-it.sh $DB_HOST:$DB_PORT -- pm2-runtime ecosystem.config.js
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024-present Louis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,110 @@
|
|||
|
||||
## 环境要求
|
||||
|
||||
- `nodejs` `16.20.2`+
|
||||
- `docker` `20.x`+ ,其中 `docker compose`版本需要 `2.17.0`+
|
||||
- `mysql` `8.x`+
|
||||
- 使用 [`pnpm`](https://pnpm.io/zh/) 包管理器安装项目依赖
|
||||
|
||||
|
||||
| 账号 | 密码 | 权限 |
|
||||
| :-------: | :----: | :--------: |
|
||||
| admin | a123456 | 超级管理员 |
|
||||
|
||||
|
||||
## 本地开发
|
||||
|
||||
- 【可选】如果你是新手,还不太会搭建`mysql/redis`,你可以使用 `Docker` 启动指定服务供本地开发时使用, 例如:
|
||||
|
||||
```bash
|
||||
# 启动MySql服务
|
||||
docker compose --env-file .env --env-file .env.development run -d --service-ports mysql
|
||||
# 启动Redis服务
|
||||
docker compose --env-file .env --env-file .env.development run -d --service-ports redis
|
||||
```
|
||||
|
||||
- 安装依赖
|
||||
|
||||
```bash
|
||||
|
||||
pnpm install
|
||||
|
||||
```
|
||||
|
||||
- 运行
|
||||
启动成功后,通过 <http://localhost:7001/api-docs/> 访问。
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- 打包
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
2.使用docker运行
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
停止并删除所有容器
|
||||
|
||||
```bash
|
||||
pnpm docker:down
|
||||
# or
|
||||
docker compose --env-file .env --env-file .env.production down
|
||||
```
|
||||
|
||||
删除镜像
|
||||
|
||||
```bash
|
||||
pnpm docker:rmi
|
||||
# or
|
||||
docker rmi huaxin-admin-server:stable
|
||||
```
|
||||
|
||||
查看实时日志输出
|
||||
|
||||
```bash
|
||||
pnpm docker:logs
|
||||
# or
|
||||
docker-compose --env-file .env --env-file .env.production logs -f
|
||||
|
||||
```
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
1. 更新数据库(或初始化数据)
|
||||
|
||||
```bash
|
||||
pnpm migration:run
|
||||
```
|
||||
|
||||
2. 生成迁移
|
||||
|
||||
```bash
|
||||
pnpm migration:generate
|
||||
```
|
||||
|
||||
3. 回滚到最后一次更新
|
||||
|
||||
```bash
|
||||
pnpm migration:revert
|
||||
```
|
||||
4.执行sql覆盖docker中的数据库
|
||||
|
||||
```bash
|
||||
docker exec -i huaxin-admin-mysql mysql -h 127.0.0.1 -u root -phuaxin123 hxoa < huaxinoa0327.sql
|
||||
```
|
||||
|
||||
更多细节,请移步至[官方文档](https://typeorm.io/migrations)
|
||||
|
||||
> [!TIP]
|
||||
> 如果你的`实体类`或`数据库配置`有更新,请执行`npm run build`后再进行数据库迁移相关操作。
|
||||
|
||||
### 部署
|
||||
chmod +x deploy.sh
|
||||
./deploy.sh
|
|
@ -0,0 +1,24 @@
|
|||
// 请使用npm run c/yarn c提交代码。
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'feat', // 新增功能
|
||||
'fix', // 修复缺陷
|
||||
'docs', // 文档变更
|
||||
'style', // 代码格式(不影响功能,例如空格、分号等格式修正)
|
||||
'refactor', // 代码重构(不包括 bug 修复、功能新增)
|
||||
'perf', // 性能优化
|
||||
'test', // 添加疏漏测试或已有测试改动
|
||||
'build', // 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等)
|
||||
'ci', // 修改 CI 配置、脚本
|
||||
'revert', // 回滚 commit
|
||||
'chore', // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)
|
||||
],
|
||||
],
|
||||
'subject-case': [0], // subject大小写不做校验
|
||||
},
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash
|
||||
|
||||
# 拉取最新的代码
|
||||
|
||||
git config core.fileMode false
|
||||
|
||||
git pull
|
||||
|
||||
docker-compose --env-file .env --env-file .env.production up -d --build
|
|
@ -0,0 +1,68 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: huaxin-admin-mysql
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
- .env.production
|
||||
environment:
|
||||
- MYSQL_HOST=${DB_HOST}
|
||||
- MYSQL_PORT=${DB_PORT}
|
||||
- MYSQL_DATABASE=${DB_DATABASE}
|
||||
- MYSQL_USERNAME=${DB_USERNAME}
|
||||
- MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
|
||||
ports:
|
||||
- '${DB_PORT}:3306'
|
||||
command:
|
||||
mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
|
||||
volumes:
|
||||
- ./__data/mysql/:/var/lib/mysql/ # ./__data/mysql/ 路径可以替换成自己的路径
|
||||
- ./init_data/sql/:/docker-entrypoint-initdb.d/ # 初始化的脚本,若 ./__data/mysql/ 文件夹存在数据,则不会执行初始化脚本
|
||||
networks:
|
||||
- huaxin_admin_net
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: huaxin-admin-redis
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
- .env.production
|
||||
ports:
|
||||
- '${REDIS_PORT}:6379'
|
||||
command: >
|
||||
--requirepass ${REDIS_PASSWORD}
|
||||
networks:
|
||||
- huaxin_admin_net
|
||||
|
||||
huaxin-admin-server:
|
||||
# build: 从当前路径构建镜像
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: huaxin-admin-server
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
- .env.production
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
ports:
|
||||
- '${APP_PORT}:${APP_PORT}'
|
||||
volumes:
|
||||
# 将容器中huaxin-admin/dist文件夹映射到本地的dist文件夹
|
||||
- ./public:/huaxin-admin/public
|
||||
# 当前服务启动之前先要把depends_on指定的服务启动起来才行
|
||||
depends_on:
|
||||
- mysql
|
||||
- redis
|
||||
networks:
|
||||
- huaxin_admin_net
|
||||
|
||||
networks:
|
||||
huaxin_admin_net:
|
||||
name: huaxin_admin_net
|
|
@ -0,0 +1,22 @@
|
|||
const { cpus } = require('os');
|
||||
|
||||
const cpuLen = cpus().length;
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'huaxin-admin',
|
||||
script: './dist/main.js',
|
||||
autorestart: true,
|
||||
exec_mode: 'cluster',
|
||||
watch: false,
|
||||
instances: process.env.CPU_LEN ?? cpuLen,
|
||||
max_memory_restart: '520M',
|
||||
args: '',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: process.env.APP_PORT
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
const antfu = require('@antfu/eslint-config').default
|
||||
|
||||
module.exports = antfu({
|
||||
stylistic: {
|
||||
indent: 2,
|
||||
quotes: 'single',
|
||||
},
|
||||
typescript: true,
|
||||
}, {
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'unused-imports/no-unused-vars': 'off',
|
||||
'unused-imports/no-unused-imports': 2,
|
||||
|
||||
'ts/consistent-type-imports': 'off',
|
||||
'node/prefer-global/process': 'off',
|
||||
'node/prefer-global/buffer': 'off',
|
||||
|
||||
'import/order': [
|
||||
2,
|
||||
{
|
||||
'pathGroups': [
|
||||
{
|
||||
pattern: '~/**',
|
||||
group: 'external',
|
||||
position: 'after',
|
||||
},
|
||||
],
|
||||
'alphabetize': { order: 'asc', caseInsensitive: false },
|
||||
'newlines-between': 'always-and-inside-groups',
|
||||
'warnOnUnassignedImports': true,
|
||||
},
|
||||
],
|
||||
|
||||
},
|
||||
})
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,14 @@
|
|||
const Minio = require('minio');
|
||||
|
||||
const minioClient = new Minio.Client({
|
||||
endPoint: '144.123.43.138',
|
||||
port: 8021,
|
||||
useSSL: false,
|
||||
accessKey: '8Zttvx4ZbF2ikFRb',
|
||||
secretKey: 'SCgOJEJXM5vMNQL4fF8opXA1wmpACRfw'
|
||||
});
|
||||
|
||||
minioClient.listBuckets((err, buckets) => {
|
||||
if (err) return console.log(err);
|
||||
console.log('Buckets:', buckets);
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{ "include": "assets/**/*", "outDir": "dist", "watchAssets": true }
|
||||
],
|
||||
"plugins": [{
|
||||
"name": "@nestjs/swagger",
|
||||
"options": {
|
||||
"introspectComments": true
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
{
|
||||
"name": "huaxin-admin",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@8.10.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"pnpm": ">=8.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "npm run gen-env-types",
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"dev": "npm run start",
|
||||
"dev:debug": "npm run start:debug",
|
||||
"repl": "npm run start -- --entryFile repl",
|
||||
"bundle": "rimraf out && npm run build && ncc build dist/main.js -o out -m -t && chmod +x out/index.js",
|
||||
"start": "cross-env NODE_ENV=development nest start -w --path tsconfig.json",
|
||||
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
|
||||
"start:prod": "cross-env NODE_ENV=production node dist/main",
|
||||
"prod": "cross-env NODE_ENV=production pm2-runtime start ecosystem.config.js",
|
||||
"prod:pm2": "cross-env NODE_ENV=production pm2 restart ecosystem.config.js",
|
||||
"prod:stop": "pm2 stop ecosystem.config.js",
|
||||
"prod:debug": "cross-env NODE_ENV=production nest start --debug --watch",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"doc": "compodoc -p tsconfig.json -s",
|
||||
"gen-env-types": "npx tsx scripts/genEnvTypes.ts",
|
||||
"typeorm": "NODE_ENV=development typeorm-ts-node-esm -d ./dist/config/database.config.js",
|
||||
"migration:create": "npm run typeorm migration:create ./src/migrations/initData",
|
||||
"migration:generate": "npm run typeorm migration:generate ./src/migrations/update-table_$(echo $npm_package_version | sed 's/\\./_/g')",
|
||||
"migration:run": "npm run typeorm -- migration:run",
|
||||
"migration:revert": "npm run typeorm -- migration:revert",
|
||||
"cleanlog": "rimraf logs",
|
||||
"docker:build:dev": "docker compose --env-file .env --env-file .env.development up --build",
|
||||
"docker:build": "docker compose --env-file .env --env-file .env.production up --build",
|
||||
"docker:up": "docker compose --env-file .env --env-file .env.production up -d --no-build",
|
||||
"docker:down": "docker compose --env-file .env --env-file .env.production down",
|
||||
"docker:rmi": "docker compose --env-file .env --env-file .env.production stop huaxin-admin-server && docker container rm huaxin-admin-server && docker rmi huaxin-admin-server",
|
||||
"docker:logs": "docker compose --env-file .env --env-file .env.production logs -f",
|
||||
"c": "git add . && git cz && git push",
|
||||
"release": "standard-version",
|
||||
"commitlint": "commitlint --config commitlint.config.cjs -e -V",
|
||||
"format": "prettier --write \"src/**/*.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^9.3.1",
|
||||
"@fastify/multipart": "^8.1.0",
|
||||
"@fastify/static": "^7.0.1",
|
||||
"@liaoliaots/nestjs-redis": "^9.0.5",
|
||||
"@nestjs-modules/mailer": "^1.10.3",
|
||||
"@nestjs/axios": "^3.0.2",
|
||||
"@nestjs/bull": "^10.1.0",
|
||||
"@nestjs/cache-manager": "^2.2.1",
|
||||
"@nestjs/common": "^10.3.3",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.3.3",
|
||||
"@nestjs/event-emitter": "^2.0.4",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-fastify": "^10.3.3",
|
||||
"@nestjs/platform-socket.io": "^10.3.3",
|
||||
"@nestjs/schedule": "^4.0.1",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/terminus": "^10.2.2",
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"@nestjs/websockets": "^10.3.3",
|
||||
"@socket.io/redis-adapter": "^8.2.1",
|
||||
"@socket.io/redis-emitter": "^5.1.0",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"axios": "^1.6.7",
|
||||
"bull": "^4.12.2",
|
||||
"cache-manager": "^5.4.0",
|
||||
"cache-manager-ioredis-yet": "^1.2.2",
|
||||
"chalk": "^5.3.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cron": "^3.1.6",
|
||||
"cron-parser": "^4.9.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dotenv": "16.4.4",
|
||||
"exceljs": "^4.4.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"mathjs": "^12.4.0",
|
||||
"mysql2": "^3.9.1",
|
||||
"nanoid": "^3.3.7",
|
||||
"nestjs-minio": "^2.5.4",
|
||||
"nodemailer": "^6.9.9",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pinyin": "3",
|
||||
"qiniu": "^7.11.0",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rimraf": "^5.0.5",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.7.4",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"systeminformation": "^5.22.0",
|
||||
"typeorm": "0.3.17",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"winston": "^3.11.0",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@compodoc/compodoc": "^1.1.23",
|
||||
"@nestjs/cli": "^10.3.2",
|
||||
"@nestjs/schematics": "^10.1.1",
|
||||
"@nestjs/testing": "^10.3.2",
|
||||
"@types/cache-manager": "^4.0.6",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.11.16",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"cliui": "^8.0.1",
|
||||
"commitizen": "^4.3.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"cz-customizable": "^7.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"husky": "^8.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "~3.2.5",
|
||||
"source-map-support": "^0.5.21",
|
||||
"standard-version": "^9.5.0",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "cz-customizable"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": [
|
||||
"npm run lint"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,49 @@
|
|||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
const directoryPath = path.resolve(__dirname, '..')
|
||||
|
||||
const targets = ['.env', `.env.${process.env.NODE_ENV || 'development'}`]
|
||||
|
||||
const envObj = targets.reduce((prev, file) => {
|
||||
const result = dotenv.parse(fs.readFileSync(path.join(directoryPath, file)))
|
||||
return { ...prev, ...result }
|
||||
}, {})
|
||||
|
||||
const envType = Object.entries<string>(envObj).reduce((prev, [key, value]) => {
|
||||
return `${prev}
|
||||
${key}: '${value}';`
|
||||
}, '').trim()
|
||||
|
||||
fs.writeFile(path.join(directoryPath, 'types/env.d.ts'), `
|
||||
// generate by ./scripts/generateEnvTypes.ts
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
${envType}
|
||||
}
|
||||
}
|
||||
}
|
||||
export {};
|
||||
`, (err) => {
|
||||
if (err)
|
||||
console.log('生成 env.d.ts 文件失败')
|
||||
else
|
||||
console.log('成功生成 env.d.ts 文件')
|
||||
})
|
||||
|
||||
// console.log('envObj:', envObj)
|
||||
|
||||
function formatValue(value) {
|
||||
let _value
|
||||
try {
|
||||
const res = JSON.parse(value)
|
||||
_value = typeof res === 'object' ? value : res
|
||||
}
|
||||
catch (error) {
|
||||
_value = `'${value}'`
|
||||
}
|
||||
return _value
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { exec } from 'node:child_process'
|
||||
|
||||
import { CronJob } from 'cron'
|
||||
|
||||
/** 此文件仅供演示时使用 */
|
||||
|
||||
const runMigrationGenerate = async function () {
|
||||
exec('npm run migration:revert && npm run migration:run', (error, stdout, stderr) => {
|
||||
if (!error)
|
||||
console.log('操作成功', error)
|
||||
|
||||
else
|
||||
console.log('操作失败', error)
|
||||
})
|
||||
}
|
||||
|
||||
const job = CronJob.from({
|
||||
/** 每天凌晨 4.30 恢复初始数据 */
|
||||
cronTime: '30 4 * * *',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
start: true,
|
||||
onTick() {
|
||||
runMigrationGenerate()
|
||||
console.log('Task executed daily at 4.30 AM:', new Date().toLocaleTimeString())
|
||||
},
|
||||
})
|
|
@ -0,0 +1,98 @@
|
|||
import { ClassSerializerInterceptor, Module } from '@nestjs/common';
|
||||
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
|
||||
import config from '~/config';
|
||||
import { SharedModule } from '~/shared/shared.module';
|
||||
|
||||
import { AllExceptionsFilter } from './common/filters/any-exception.filter';
|
||||
|
||||
import { IdempotenceInterceptor } from './common/interceptors/idempotence.interceptor';
|
||||
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from './modules/auth/guards/rbac.guard';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { NetdiskModule } from './modules/netdisk/netdisk.module';
|
||||
import { SseModule } from './modules/sse/sse.module';
|
||||
import { SystemModule } from './modules/system/system.module';
|
||||
import { TasksModule } from './modules/tasks/tasks.module';
|
||||
import { TodoModule } from './modules/todo/todo.module';
|
||||
import { ToolsModule } from './modules/tools/tools.module';
|
||||
import { DatabaseModule } from './shared/database/database.module';
|
||||
|
||||
import { SocketModule } from './socket/socket.module';
|
||||
import { ContractModule } from './modules/contract/contract.module';
|
||||
import { MaterialsInventoryModule } from './modules/materials_inventory/materials_inventory.module';
|
||||
import { CompanyModule } from './modules/company/company.module';
|
||||
import { ProductModule } from './modules/product/product.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
import { VehicleUsageModule } from './modules/vehicle_usage/vehicle_usage.module';
|
||||
import { SaleQuotationModule } from './modules/sale_quotation/sale_quotation.module';
|
||||
import { DomainModule } from './modules/domian/domain.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
expandVariables: true,
|
||||
// 指定多个 env 文件时,第一个优先级最高
|
||||
envFilePath: ['.env.local', `.env.${process.env.NODE_ENV}`, '.env'],
|
||||
load: [...Object.values(config)]
|
||||
}),
|
||||
SharedModule,
|
||||
DatabaseModule,
|
||||
|
||||
AuthModule,
|
||||
SystemModule,
|
||||
TasksModule.forRoot(),
|
||||
ToolsModule,
|
||||
SocketModule,
|
||||
HealthModule,
|
||||
SseModule,
|
||||
NetdiskModule,
|
||||
|
||||
// biz
|
||||
|
||||
// end biz
|
||||
|
||||
TodoModule,
|
||||
// 合同模块
|
||||
ContractModule,
|
||||
|
||||
// 原材料库存
|
||||
MaterialsInventoryModule,
|
||||
|
||||
// 公司管理
|
||||
CompanyModule,
|
||||
|
||||
// 产品管理
|
||||
ProductModule,
|
||||
|
||||
// 项目管理
|
||||
ProjectModule,
|
||||
|
||||
// 车辆管理
|
||||
VehicleUsageModule,
|
||||
|
||||
//报价管理
|
||||
SaleQuotationModule,
|
||||
//域
|
||||
DomainModule
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
|
||||
{ provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useFactory: () => new TimeoutInterceptor(15 * 1000) },
|
||||
{ provide: APP_INTERCEPTOR, useClass: IdempotenceInterceptor },
|
||||
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: RbacGuard }
|
||||
],
|
||||
controllers: []
|
||||
})
|
||||
export class AppModule {}
|
|
@ -0,0 +1,4 @@
|
|||
<p>你的验证码是:</p>
|
||||
<h2>{{code}}</h2>
|
||||
<p>该验证码 10 分钟内有效,请勿将验证码告知给他人!</p>
|
||||
<font color='grey'>本邮件由系统自动发出,请勿回复。</font>
|
|
@ -0,0 +1,5 @@
|
|||
<p>Your verification code is:</p>
|
||||
<h1>{{verificationCode}}</h1>
|
||||
<p>This code will expire in 10 minutes.</p>
|
||||
<font color='grey'>This email is sent automatically by the system, please do not
|
||||
reply.</font>
|
|
@ -0,0 +1,45 @@
|
|||
import FastifyCookie from '@fastify/cookie';
|
||||
import FastifyMultipart from '@fastify/multipart';
|
||||
import { FastifyAdapter } from '@nestjs/platform-fastify';
|
||||
|
||||
const app: FastifyAdapter = new FastifyAdapter({
|
||||
trustProxy: true,
|
||||
logger: false
|
||||
// forceCloseConnections: true,
|
||||
});
|
||||
export { app as fastifyApp };
|
||||
|
||||
app.register(FastifyMultipart, {
|
||||
attachFieldsToBody:true,
|
||||
limits: {
|
||||
fields: 10, // Max number of non-file fields
|
||||
fileSize: 1024 * 1024 * 50, // limit size 50M
|
||||
files: 5 // Max number of file fields
|
||||
}
|
||||
});
|
||||
|
||||
app.register(FastifyCookie, {
|
||||
secret: 'cookie-secret' // 这个 secret 不太重要,不存鉴权相关,无关紧要
|
||||
});
|
||||
|
||||
app.getInstance().addHook('onRequest', (request, reply, done) => {
|
||||
// set undefined origin
|
||||
const { origin } = request.headers;
|
||||
if (!origin) request.headers.origin = request.headers.host;
|
||||
|
||||
// forbidden php
|
||||
|
||||
const { url } = request;
|
||||
|
||||
if (url.endsWith('.php')) {
|
||||
reply.raw.statusMessage =
|
||||
'Eh. PHP is not support on this machine. Yep, I also think PHP is bestest programming language. But for me it is beyond my reach.';
|
||||
|
||||
return reply.code(418).send();
|
||||
}
|
||||
|
||||
// skip favicon request
|
||||
if (url.match(/favicon.ico$/) || url.match(/manifest.json$/)) return reply.code(204).send();
|
||||
|
||||
done();
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
import { INestApplication } from '@nestjs/common';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { createAdapter } from '@socket.io/redis-adapter';
|
||||
|
||||
import { REDIS_PUBSUB } from '~/shared/redis/redis.constant';
|
||||
|
||||
export const RedisIoAdapterKey = 'm-shop-socket';
|
||||
|
||||
export class RedisIoAdapter extends IoAdapter {
|
||||
constructor(private readonly app: INestApplication) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
createIOServer(port: number, options?: any) {
|
||||
const server = super.createIOServer(port, options);
|
||||
|
||||
const { pubClient, subClient } = this.app.get(REDIS_PUBSUB);
|
||||
|
||||
const redisAdapter = createAdapter(pubClient, subClient, {
|
||||
key: RedisIoAdapterKey,
|
||||
requestsTimeout: 10000
|
||||
});
|
||||
server.adapter(redisAdapter);
|
||||
return server;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import { HttpStatus, Type, applyDecorators } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
|
||||
|
||||
import { ResOp } from '~/common/model/response.model';
|
||||
|
||||
const baseTypeNames = ['String', 'Number', 'Boolean'];
|
||||
|
||||
function genBaseProp(type: Type<any>) {
|
||||
if (baseTypeNames.includes(type.name)) return { type: type.name.toLocaleLowerCase() };
|
||||
else return { $ref: getSchemaPath(type) };
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 生成返回结果装饰器
|
||||
*/
|
||||
export function ApiResult<TModel extends Type<any>>({
|
||||
type,
|
||||
isPage,
|
||||
status
|
||||
}: {
|
||||
type?: TModel | TModel[];
|
||||
isPage?: boolean;
|
||||
status?: HttpStatus;
|
||||
}) {
|
||||
let prop = null;
|
||||
|
||||
if (Array.isArray(type)) {
|
||||
if (isPage) {
|
||||
prop = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { $ref: getSchemaPath(type[0]) }
|
||||
},
|
||||
meta: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
itemCount: { type: 'number', default: 0 },
|
||||
totalItems: { type: 'number', default: 0 },
|
||||
itemsPerPage: { type: 'number', default: 0 },
|
||||
totalPages: { type: 'number', default: 0 },
|
||||
currentPage: { type: 'number', default: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
prop = {
|
||||
type: 'array',
|
||||
items: genBaseProp(type[0])
|
||||
};
|
||||
}
|
||||
} else if (type) {
|
||||
prop = genBaseProp(type);
|
||||
} else {
|
||||
prop = { type: 'null', default: null };
|
||||
}
|
||||
|
||||
const model = Array.isArray(type) ? type[0] : type;
|
||||
|
||||
return applyDecorators(
|
||||
ApiExtraModels(model),
|
||||
ApiResponse({
|
||||
status,
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: getSchemaPath(ResOp) },
|
||||
{
|
||||
properties: {
|
||||
data: prop
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const BYPASS_KEY = '__bypass_key__';
|
||||
|
||||
/**
|
||||
* 当不需要转换成基础返回格式时添加该装饰器
|
||||
*/
|
||||
export function Bypass() {
|
||||
return SetMetadata(BYPASS_KEY, true);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export const Cookies = createParamDecorator((data: string, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<FastifyRequest>();
|
||||
return data ? request.cookies?.[data] : request.cookies;
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
import cluster from 'node:cluster';
|
||||
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
|
||||
import { isMainProcess } from '~/global/env';
|
||||
|
||||
export const CronOnce: typeof Cron = (...rest): MethodDecorator => {
|
||||
// If not in cluster mode, and PM2 main worker
|
||||
if (isMainProcess)
|
||||
// eslint-disable-next-line no-useless-call
|
||||
return Cron.call(null, ...rest);
|
||||
|
||||
if (cluster.isWorker && cluster.worker?.id === 1)
|
||||
// eslint-disable-next-line no-useless-call
|
||||
return Cron.call(null, ...rest);
|
||||
|
||||
const returnNothing: MethodDecorator = () => {};
|
||||
return returnNothing;
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional } from 'class-validator';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
/**
|
||||
* 当前域
|
||||
*/
|
||||
export const Domain = createParamDecorator((_, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
return request.headers['sk-domain'] ?? 1;
|
||||
});
|
||||
|
||||
export type SkDomain = number;
|
||||
export class DomainType {
|
||||
@ApiProperty({ description: '所属域' })
|
||||
@IsOptional()
|
||||
domain: SkDomain;
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
import { applyDecorators } from '@nestjs/common';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDate,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength
|
||||
} from 'class-validator';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
import {
|
||||
ToArray,
|
||||
ToBoolean,
|
||||
ToDate,
|
||||
ToLowerCase,
|
||||
ToNumber,
|
||||
ToTrim,
|
||||
ToUpperCase
|
||||
} from './transform.decorator';
|
||||
|
||||
interface IOptionalOptions {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
interface INumberFieldOptions extends IOptionalOptions {
|
||||
each?: boolean;
|
||||
int?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
positive?: boolean;
|
||||
}
|
||||
|
||||
interface IStringFieldOptions extends IOptionalOptions {
|
||||
each?: boolean;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
lowerCase?: boolean;
|
||||
upperCase?: boolean;
|
||||
}
|
||||
|
||||
export function NumberField(options: INumberFieldOptions = {}): PropertyDecorator {
|
||||
const { each, min, max, int, positive, required = true } = options;
|
||||
|
||||
const decorators = [ToNumber()];
|
||||
|
||||
if (each) decorators.push(ToArray());
|
||||
|
||||
if (int) decorators.push(IsInt({ each }));
|
||||
else decorators.push(IsNumber({}, { each }));
|
||||
|
||||
if (isNumber(min)) decorators.push(Min(min, { each }));
|
||||
|
||||
if (isNumber(max)) decorators.push(Max(max, { each }));
|
||||
|
||||
if (positive) decorators.push(IsPositive({ each }));
|
||||
|
||||
if (!required) decorators.push(IsOptional());
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
}
|
||||
|
||||
export function StringField(options: IStringFieldOptions = {}): PropertyDecorator {
|
||||
const { each, minLength, maxLength, lowerCase, upperCase, required = true } = options;
|
||||
|
||||
const decorators = [IsString({ each }), ToTrim()];
|
||||
|
||||
if (each) decorators.push(ToArray());
|
||||
|
||||
if (isNumber(minLength)) decorators.push(MinLength(minLength, { each }));
|
||||
|
||||
if (isNumber(maxLength)) decorators.push(MaxLength(maxLength, { each }));
|
||||
|
||||
if (lowerCase) decorators.push(ToLowerCase());
|
||||
|
||||
if (upperCase) decorators.push(ToUpperCase());
|
||||
|
||||
if (!required) decorators.push(IsOptional());
|
||||
else decorators.push(IsNotEmpty({ each }));
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
}
|
||||
|
||||
export function BooleanField(options: IOptionalOptions = {}): PropertyDecorator {
|
||||
const decorators = [ToBoolean(), IsBoolean()];
|
||||
|
||||
const { required = true } = options;
|
||||
|
||||
if (!required) decorators.push(IsOptional());
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
}
|
||||
|
||||
export function DateField(options: IOptionalOptions = {}): PropertyDecorator {
|
||||
const decorators = [ToDate(), IsDate()];
|
||||
|
||||
const { required = true } = options;
|
||||
|
||||
if (!required) decorators.push(IsOptional());
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import type { ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
import { getIp, getIsMobile } from '~/utils/ip.util';
|
||||
|
||||
|
||||
/**
|
||||
* 快速获取IP
|
||||
*/
|
||||
export const IsMobile = createParamDecorator((_, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
return getIsMobile(request);
|
||||
});
|
||||
|
||||
/**
|
||||
* 快速获取IP
|
||||
*/
|
||||
export const Ip = createParamDecorator((_, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
return getIp(request);
|
||||
});
|
||||
|
||||
/**
|
||||
* 快速获取request path,并不包括url params
|
||||
*/
|
||||
export const Uri = createParamDecorator((_, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
return request.routerPath;
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
import { HttpStatus, NotAcceptableException, Param, ParseIntPipe } from '@nestjs/common';
|
||||
|
||||
export function IdParam() {
|
||||
return Param(
|
||||
'id',
|
||||
new ParseIntPipe({
|
||||
errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE,
|
||||
exceptionFactory: _error => {
|
||||
throw new NotAcceptableException('id 格式不正确');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
import { IdempotenceOption } from '../interceptors/idempotence.interceptor';
|
||||
|
||||
export const HTTP_IDEMPOTENCE_KEY = '__idempotence_key__';
|
||||
export const HTTP_IDEMPOTENCE_OPTIONS = '__idempotence_options__';
|
||||
|
||||
/**
|
||||
* 幂等
|
||||
*/
|
||||
export function Idempotence(options?: IdempotenceOption): MethodDecorator {
|
||||
return function (target, key, descriptor: PropertyDescriptor) {
|
||||
SetMetadata(HTTP_IDEMPOTENCE_OPTIONS, options || {})(descriptor.value);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiSecurity } from '@nestjs/swagger';
|
||||
|
||||
export const API_SECURITY_AUTH = 'auth';
|
||||
|
||||
/**
|
||||
* like to @ApiSecurity('auth')
|
||||
*/
|
||||
export function ApiSecurityAuth(): ClassDecorator & MethodDecorator {
|
||||
return applyDecorators(ApiSecurity(API_SECURITY_AUTH));
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
import { Transform } from 'class-transformer';
|
||||
import { castArray, isArray, isNil, trim } from 'lodash';
|
||||
|
||||
/**
|
||||
* convert string to number
|
||||
*/
|
||||
export function ToNumber(): PropertyDecorator {
|
||||
return Transform(
|
||||
params => {
|
||||
const value = params.value as string[] | string;
|
||||
|
||||
if (isArray(value)) return value.map(v => Number(v));
|
||||
|
||||
return Number(value);
|
||||
},
|
||||
{ toClassOnly: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* convert string to int
|
||||
*/
|
||||
export function ToInt(): PropertyDecorator {
|
||||
return Transform(
|
||||
params => {
|
||||
const value = params.value as string[] | string;
|
||||
|
||||
if (isArray(value)) return value.map(v => Number.parseInt(v));
|
||||
|
||||
return Number.parseInt(value);
|
||||
},
|
||||
{ toClassOnly: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* convert string to boolean
|
||||
*/
|
||||
export function ToBoolean(): PropertyDecorator {
|
||||
return Transform(
|
||||
params => {
|
||||
switch (params.value) {
|
||||
case 'true':
|
||||
return true;
|
||||
case 'false':
|
||||
return false;
|
||||
default:
|
||||
return params.value;
|
||||
}
|
||||
},
|
||||
{ toClassOnly: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* convert string to Date
|
||||
*/
|
||||
export function ToDate(): PropertyDecorator {
|
||||
return Transform(
|
||||
params => {
|
||||
const { value } = params;
|
||||
|
||||
if (!value) return;
|
||||
|
||||
return new Date(value);
|
||||
},
|
||||
{ toClassOnly: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* transforms to array, specially for query params
|
||||
*/
|
||||
export function ToArray(): PropertyDecorator {
|
||||
return Transform(
|
||||
params => {
|
||||
const { value } = params;
|
||||
|
||||
if (isNil(value)) return [];
|
||||
|
||||
return castArray(value);
|
||||
},
|
||||
{ toClassOnly: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* trim spaces from start and end, replace multiple spaces with one.
|
||||
*/
|
||||
export function ToTrim(): PropertyDecorator {
|
||||
return Transform(
|
||||
params => {
|
||||
const value = params.value as string[] | string;
|
||||
|
||||
if (isArray(value)) return value.map(v => trim(v));
|
||||
|
||||
return trim(value);
|
||||
},
|
||||
{ toClassOnly: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* lowercase value
|
||||
*/
|
||||
export function ToLowerCase(): PropertyDecorator {
|
||||
return Transform(
|
||||
params => {
|
||||
const value = params.value as string[] | string;
|
||||
|
||||
if (!value) return;
|
||||
|
||||
if (isArray(value)) return value.map(v => v.toLowerCase());
|
||||
|
||||
return value.toLowerCase();
|
||||
},
|
||||
{ toClassOnly: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* uppercase value
|
||||
*/
|
||||
export function ToUpperCase(): PropertyDecorator {
|
||||
return Transform(
|
||||
params => {
|
||||
const value = params.value as string[] | string;
|
||||
|
||||
if (!value) return;
|
||||
|
||||
if (isArray(value)) return value.map(v => v.toUpperCase());
|
||||
|
||||
return value.toUpperCase();
|
||||
},
|
||||
{ toClassOnly: true }
|
||||
);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, Transform } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class CursorDto<T = any> {
|
||||
@ApiProperty({ minimum: 0, default: 0 })
|
||||
@Min(0)
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@IsOptional({ always: true })
|
||||
@Transform(({ value: val }) => (val ? Number.parseInt(val) : 0), {
|
||||
toClassOnly: true
|
||||
})
|
||||
cursor?: number;
|
||||
|
||||
@ApiProperty({ minimum: 1, maximum: 100, default: 10 })
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsInt()
|
||||
@IsOptional({ always: true })
|
||||
@Expose()
|
||||
@Transform(({ value: val }) => (val ? Number.parseInt(val) : 10), {
|
||||
toClassOnly: true
|
||||
})
|
||||
limit?: number;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { IsDefined, IsNotEmpty, IsNumber } from 'class-validator';
|
||||
|
||||
export class BatchDeleteDto {
|
||||
@IsDefined()
|
||||
@IsNotEmpty()
|
||||
@IsNumber({}, { each: true })
|
||||
ids: number[];
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { IsNumber } from 'class-validator';
|
||||
|
||||
export class IdDto {
|
||||
@IsNumber()
|
||||
id: number;
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, Transform } from 'class-transformer';
|
||||
import { Allow, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export enum Order {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC'
|
||||
}
|
||||
|
||||
export class PagerDto<T = any> {
|
||||
@ApiProperty({ minimum: 1, default: 1 })
|
||||
@Min(1)
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@IsOptional({ always: true })
|
||||
@Transform(({ value: val }) => (val ? Number.parseInt(val) : 1), {
|
||||
toClassOnly: true
|
||||
})
|
||||
page?: number;
|
||||
|
||||
@ApiProperty({ minimum: 1, maximum: 100, default: 10 })
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsInt()
|
||||
@IsOptional({ always: true })
|
||||
@Expose()
|
||||
@Transform(({ value: val }) => (val ? Number.parseInt(val) : 10), {
|
||||
toClassOnly: true
|
||||
})
|
||||
pageSize?: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
field?: string; // | keyof T
|
||||
|
||||
@ApiProperty({ enum: Order })
|
||||
@IsEnum(Order)
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === 'asc' ? Order.ASC : Order.DESC))
|
||||
order?: Order;
|
||||
|
||||
@Allow()
|
||||
_t?: number;
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { ApiHideProperty, ApiProperty } from '@nestjs/swagger';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
VirtualColumn
|
||||
} from 'typeorm';
|
||||
|
||||
// 如果觉得前端转换时间太麻烦,并且不考虑通用性的话,可以在服务端进行转换,eg: @UpdateDateColumn({ name: 'updated_at', transformer })
|
||||
// const transformer: ValueTransformer = {
|
||||
// to(value) {
|
||||
// return value
|
||||
// },
|
||||
// from(value) {
|
||||
// return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
// },
|
||||
// }
|
||||
|
||||
export abstract class CommonEntity extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
}
|
||||
|
||||
export abstract class CompleteEntity extends CommonEntity {
|
||||
@ApiHideProperty()
|
||||
@Exclude()
|
||||
@Column({ name: 'create_by', update: false, comment: '创建者' })
|
||||
createBy: number;
|
||||
|
||||
@ApiHideProperty()
|
||||
@Exclude()
|
||||
@Column({ name: 'update_by', comment: '更新者' })
|
||||
updateBy: number;
|
||||
|
||||
/**
|
||||
* 不会保存到数据库中的虚拟列,数据量大时可能会有性能问题,有性能要求请考虑在 service 层手动实现
|
||||
* @see https://typeorm.io/decorator-reference#virtualcolumn
|
||||
*/
|
||||
@ApiProperty({ description: '创建者' })
|
||||
@VirtualColumn({ query: alias => `SELECT username FROM sys_user WHERE id = ${alias}.create_by` })
|
||||
creator: string;
|
||||
|
||||
@ApiProperty({ description: '更新者' })
|
||||
@VirtualColumn({ query: alias => `SELECT username FROM sys_user WHERE id = ${alias}.update_by` })
|
||||
updater: string;
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
import { ErrorEnum } from '~/constants/error-code.constant';
|
||||
import { RESPONSE_SUCCESS_CODE } from '~/constants/response.constant';
|
||||
|
||||
export class BusinessException extends HttpException {
|
||||
private errorCode: number;
|
||||
|
||||
constructor(error: ErrorEnum | string) {
|
||||
// 如果是非 ErrorEnum
|
||||
if (!error.includes(':')) {
|
||||
super(
|
||||
HttpException.createBody({
|
||||
code: RESPONSE_SUCCESS_CODE,
|
||||
message: error
|
||||
}),
|
||||
HttpStatus.OK
|
||||
);
|
||||
this.errorCode = RESPONSE_SUCCESS_CODE;
|
||||
return;
|
||||
}
|
||||
|
||||
const [code, message] = error.split(':');
|
||||
super(
|
||||
HttpException.createBody({
|
||||
code,
|
||||
message
|
||||
}),
|
||||
HttpStatus.BAD_REQUEST
|
||||
);
|
||||
|
||||
this.errorCode = Number(code);
|
||||
}
|
||||
|
||||
getErrorCode(): number {
|
||||
return this.errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
export { BusinessException as BizException };
|
|
@ -0,0 +1,10 @@
|
|||
import { NotFoundException } from '@nestjs/common';
|
||||
import { sample } from 'lodash';
|
||||
|
||||
export const NotFoundMessage = ['404, Not Found'];
|
||||
|
||||
export class CannotFindException extends NotFoundException {
|
||||
constructor() {
|
||||
super(sample(NotFoundMessage));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { HttpException } from '@nestjs/common';
|
||||
import { WsException } from '@nestjs/websockets';
|
||||
|
||||
import { ErrorEnum } from '~/constants/error-code.constant';
|
||||
|
||||
export class SocketException extends WsException {
|
||||
private errorCode: number;
|
||||
|
||||
constructor(message: string);
|
||||
constructor(error: ErrorEnum);
|
||||
constructor(...args: any) {
|
||||
const error = args[0];
|
||||
if (typeof error === 'string') {
|
||||
super(
|
||||
HttpException.createBody({
|
||||
code: 0,
|
||||
message: error
|
||||
})
|
||||
);
|
||||
this.errorCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const [code, message] = error.split(':');
|
||||
super(
|
||||
HttpException.createBody({
|
||||
code,
|
||||
message
|
||||
})
|
||||
);
|
||||
|
||||
this.errorCode = Number(code);
|
||||
}
|
||||
|
||||
getErrorCode(): number {
|
||||
return this.errorCode;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger
|
||||
} from '@nestjs/common';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { BusinessException } from '~/common/exceptions/biz.exception';
|
||||
import { ErrorEnum } from '~/constants/error-code.constant';
|
||||
|
||||
import { isDev } from '~/global/env';
|
||||
|
||||
interface myError {
|
||||
readonly status: number;
|
||||
readonly statusCode?: number;
|
||||
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
|
||||
constructor() {
|
||||
this.registerCatchAllExceptionsHook();
|
||||
}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
const response = ctx.getResponse<FastifyReply>();
|
||||
|
||||
const url = request.raw.url!;
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: (exception as myError)?.status ||
|
||||
(exception as myError)?.statusCode ||
|
||||
HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
let message =
|
||||
(exception as any)?.response?.message || (exception as myError)?.message || `${exception}`;
|
||||
|
||||
// 系统内部错误时
|
||||
if (status === HttpStatus.INTERNAL_SERVER_ERROR && !(exception instanceof BusinessException)) {
|
||||
Logger.error(exception, undefined, 'Catch');
|
||||
|
||||
// 生产环境下隐藏错误信息
|
||||
if (!isDev) message = ErrorEnum.SERVER_ERROR?.split(':')[1];
|
||||
} else {
|
||||
this.logger.warn(`错误信息:(${status}) ${message} Path: ${decodeURI(url)}`);
|
||||
}
|
||||
|
||||
const apiErrorCode: number =
|
||||
exception instanceof BusinessException ? exception.getErrorCode() : status;
|
||||
|
||||
// 返回基础响应结果
|
||||
const resBody: IBaseResponse = {
|
||||
code: apiErrorCode,
|
||||
message,
|
||||
data: null
|
||||
};
|
||||
|
||||
response.status(status).send(resBody);
|
||||
}
|
||||
|
||||
registerCatchAllExceptionsHook() {
|
||||
process.on('unhandledRejection', reason => {
|
||||
console.error('unhandledRejection: ', reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', err => {
|
||||
console.error('uncaughtException: ', err);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
|
||||
|
||||
import { ConflictException, Injectable, SetMetadata } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { catchError, tap } from 'rxjs';
|
||||
|
||||
import { CacheService } from '~/shared/redis/cache.service';
|
||||
import { hashString } from '~/utils';
|
||||
import { getIp } from '~/utils/ip.util';
|
||||
import { getRedisKey } from '~/utils/redis.util';
|
||||
|
||||
import {
|
||||
HTTP_IDEMPOTENCE_KEY,
|
||||
HTTP_IDEMPOTENCE_OPTIONS
|
||||
} from '../decorators/idempotence.decorator';
|
||||
|
||||
const IdempotenceHeaderKey = 'x-idempotence';
|
||||
|
||||
export interface IdempotenceOption {
|
||||
errorMessage?: string;
|
||||
pendingMessage?: string;
|
||||
|
||||
/**
|
||||
* 如果重复请求的话,手动处理异常
|
||||
*/
|
||||
handler?: (req: FastifyRequest) => any;
|
||||
|
||||
/**
|
||||
* 记录重复请求的时间
|
||||
* @default 60
|
||||
*/
|
||||
expired?: number;
|
||||
|
||||
/**
|
||||
* 如果 header 没有幂等 key,根据 request 生成 key,如何生成这个 key 的方法
|
||||
*/
|
||||
generateKey?: (req: FastifyRequest) => string;
|
||||
|
||||
/**
|
||||
* 仅读取 header 的 key,不自动生成
|
||||
* @default false
|
||||
*/
|
||||
disableGenerateKey?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IdempotenceInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly cacheService: CacheService
|
||||
) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
|
||||
// skip Get 请求
|
||||
if (request.method.toUpperCase() === 'GET') return next.handle();
|
||||
|
||||
const handler = context.getHandler();
|
||||
const options: IdempotenceOption | undefined = this.reflector.get(
|
||||
HTTP_IDEMPOTENCE_OPTIONS,
|
||||
handler
|
||||
);
|
||||
|
||||
if (!options) return next.handle();
|
||||
|
||||
const {
|
||||
errorMessage = '相同请求成功后在 60 秒内只能发送一次',
|
||||
pendingMessage = '相同请求正在处理中...',
|
||||
handler: errorHandler,
|
||||
expired = 60,
|
||||
disableGenerateKey = false
|
||||
} = options;
|
||||
const redis = this.cacheService.getClient();
|
||||
|
||||
const idempotence = request.headers[IdempotenceHeaderKey] as string;
|
||||
const key = disableGenerateKey
|
||||
? undefined
|
||||
: options.generateKey
|
||||
? options.generateKey(request)
|
||||
: this.generateKey(request);
|
||||
|
||||
const idempotenceKey =
|
||||
!!(idempotence || key) && getRedisKey(`idempotence:${idempotence || key}`);
|
||||
|
||||
SetMetadata(HTTP_IDEMPOTENCE_KEY, idempotenceKey)(handler);
|
||||
|
||||
if (idempotenceKey) {
|
||||
const resultValue: '0' | '1' | null = (await redis.get(idempotenceKey)) as any;
|
||||
if (resultValue !== null) {
|
||||
if (errorHandler) return await errorHandler(request);
|
||||
|
||||
const message = {
|
||||
1: errorMessage,
|
||||
0: pendingMessage
|
||||
}[resultValue];
|
||||
throw new ConflictException(message);
|
||||
} else {
|
||||
await redis.set(idempotenceKey, '0', 'EX', expired);
|
||||
}
|
||||
}
|
||||
return next.handle().pipe(
|
||||
tap(async () => {
|
||||
idempotenceKey && (await redis.set(idempotenceKey, '1', 'KEEPTTL'));
|
||||
}),
|
||||
catchError(async err => {
|
||||
if (idempotenceKey) await redis.del(idempotenceKey);
|
||||
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private generateKey(req: FastifyRequest) {
|
||||
const { body, params, query = {}, headers, url } = req;
|
||||
|
||||
const obj = { body, url, params, query } as any;
|
||||
|
||||
const uuid = headers['x-uuid'];
|
||||
if (uuid) {
|
||||
obj.uuid = uuid;
|
||||
} else {
|
||||
const ua = headers['user-agent'];
|
||||
const ip = getIp(req);
|
||||
|
||||
if (!ua && !ip) return undefined;
|
||||
|
||||
Object.assign(obj, { ua, ip });
|
||||
}
|
||||
|
||||
return hashString(JSON.stringify(obj));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private logger = new Logger(LoggingInterceptor.name, { timestamp: false });
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
|
||||
const call$ = next.handle();
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const content = `${request.method} -> ${request.url}`;
|
||||
const isSse = request.headers.accept === 'text/event-stream';
|
||||
this.logger.debug(`+++ 请求:${content}`);
|
||||
const now = Date.now();
|
||||
|
||||
return call$.pipe(
|
||||
tap(() => {
|
||||
if (isSse) return;
|
||||
|
||||
this.logger.debug(`--- 响应:${content}${` +${Date.now() - now}ms`}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
RequestTimeoutException
|
||||
} from '@nestjs/common';
|
||||
import { Observable, TimeoutError, throwError } from 'rxjs';
|
||||
import { catchError, timeout } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class TimeoutInterceptor implements NestInterceptor {
|
||||
constructor(private readonly time: number = 10000) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
timeout(this.time),
|
||||
catchError(err => {
|
||||
if (err instanceof TimeoutError) return throwError(new RequestTimeoutException('请求超时'));
|
||||
|
||||
return throwError(err);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NestInterceptor
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { ResOp } from '~/common/model/response.model';
|
||||
|
||||
import { BYPASS_KEY } from '../decorators/bypass.decorator';
|
||||
|
||||
/**
|
||||
* 统一处理返回接口结果,如果不需要则添加 @Bypass 装饰器
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor implements NestInterceptor {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
|
||||
const bypass = this.reflector.get<boolean>(BYPASS_KEY, context.getHandler());
|
||||
|
||||
if (bypass) return next.handle();
|
||||
|
||||
return next.handle().pipe(
|
||||
map(data => {
|
||||
// if (typeof data === 'undefined') {
|
||||
// context.switchToHttp().getResponse().status(HttpStatus.NO_CONTENT);
|
||||
// return data;
|
||||
// }
|
||||
|
||||
return new ResOp(HttpStatus.OK, data ?? null);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { RESPONSE_SUCCESS_CODE, RESPONSE_SUCCESS_MSG } from '~/constants/response.constant';
|
||||
|
||||
export class ResOp<T = any> {
|
||||
@ApiProperty({ type: 'object' })
|
||||
data?: T;
|
||||
|
||||
@ApiProperty({ type: 'number', default: RESPONSE_SUCCESS_CODE })
|
||||
code: number;
|
||||
|
||||
@ApiProperty({ type: 'string', default: RESPONSE_SUCCESS_MSG })
|
||||
message: string;
|
||||
|
||||
constructor(code: number, data: T, message = RESPONSE_SUCCESS_MSG) {
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
static success<T>(data?: T, message?: string) {
|
||||
return new ResOp(RESPONSE_SUCCESS_CODE, data, message);
|
||||
}
|
||||
|
||||
static error(code: number, message) {
|
||||
return new ResOp(code, {}, message);
|
||||
}
|
||||
}
|
||||
|
||||
export class TreeResult<T> {
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@ApiProperty()
|
||||
parentId: number;
|
||||
|
||||
@ApiProperty()
|
||||
children?: TreeResult<T>[];
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ParseIntPipe implements PipeTransform<string, number> {
|
||||
transform(value: string, metadata: ArgumentMetadata): number {
|
||||
const val = Number.parseInt(value, 10);
|
||||
|
||||
if (Number.isNaN(val)) throw new BadRequestException('id validation failed');
|
||||
|
||||
return val;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { ConfigType, registerAs } from '@nestjs/config';
|
||||
|
||||
import { env, envNumber } from '~/global/env';
|
||||
|
||||
export const appRegToken = 'app';
|
||||
|
||||
export const AppConfig = registerAs(appRegToken, () => ({
|
||||
name: env('APP_NAME'),
|
||||
port: envNumber('APP_PORT', 3000),
|
||||
baseUrl: env('APP_BASE_URL'),
|
||||
globalPrefix: env('GLOBAL_PREFIX', 'api'),
|
||||
locale: env('APP_LOCALE', 'zh-CN'),
|
||||
|
||||
logger: {
|
||||
level: env('LOGGER_LEVEL'),
|
||||
maxFiles: envNumber('LOGGER_MAX_FILES')
|
||||
}
|
||||
}));
|
||||
|
||||
export type IAppConfig = ConfigType<typeof AppConfig>;
|
|
@ -0,0 +1,37 @@
|
|||
import { ConfigType, registerAs } from '@nestjs/config';
|
||||
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
|
||||
import { env, envBoolean, envNumber } from '~/global/env';
|
||||
|
||||
// eslint-disable-next-line import/order
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config({ path: `.env.${process.env.NODE_ENV}` });
|
||||
|
||||
// 当前通过 npm scripts 执行的命令
|
||||
const currentScript = process.env.npm_lifecycle_event;
|
||||
|
||||
const dataSourceOptions: DataSourceOptions = {
|
||||
type: 'mysql',
|
||||
host: env('DB_HOST', '127.0.0.1'),
|
||||
port: envNumber('DB_PORT', 3306),
|
||||
username: env('DB_USERNAME'),
|
||||
password: env('DB_PASSWORD'),
|
||||
database: env('DB_DATABASE'),
|
||||
synchronize: envBoolean('DB_SYNCHRONIZE', false),
|
||||
// 解决通过 pnpm migration:run 初始化数据时,遇到的 SET FOREIGN_KEY_CHECKS = 0; 等语句报错问题, 仅在执行数据迁移操作时设为 true
|
||||
multipleStatements: currentScript === 'typeorm',
|
||||
entities: ['dist/modules/**/*.entity{.ts,.js}'],
|
||||
migrations: ['dist/migrations/*{.ts,.js}'],
|
||||
subscribers: ['dist/modules/**/*.subscriber{.ts,.js}']
|
||||
};
|
||||
export const dbRegToken = 'database';
|
||||
|
||||
export const DatabaseConfig = registerAs(dbRegToken, (): DataSourceOptions => dataSourceOptions);
|
||||
|
||||
export type IDatabaseConfig = ConfigType<typeof DatabaseConfig>;
|
||||
|
||||
const dataSource = new DataSource(dataSourceOptions);
|
||||
|
||||
export default dataSource;
|
|
@ -0,0 +1,37 @@
|
|||
import { AppConfig, IAppConfig, appRegToken } from './app.config';
|
||||
import { DatabaseConfig, IDatabaseConfig, dbRegToken } from './database.config';
|
||||
import { IMailerConfig, MailerConfig, mailerRegToken } from './mailer.config';
|
||||
import { IOssConfig, OssConfig, ossRegToken } from './oss.config';
|
||||
import { IRedisConfig, RedisConfig, redisRegToken } from './redis.config';
|
||||
import { ISecurityConfig, SecurityConfig, securityRegToken } from './security.config';
|
||||
import { ISwaggerConfig, SwaggerConfig, swaggerRegToken } from './swagger.config';
|
||||
|
||||
export * from './app.config';
|
||||
export * from './redis.config';
|
||||
export * from './database.config';
|
||||
export * from './swagger.config';
|
||||
export * from './security.config';
|
||||
export * from './mailer.config';
|
||||
export * from './oss.config';
|
||||
|
||||
export interface AllConfigType {
|
||||
[appRegToken]: IAppConfig;
|
||||
[dbRegToken]: IDatabaseConfig;
|
||||
[mailerRegToken]: IMailerConfig;
|
||||
[redisRegToken]: IRedisConfig;
|
||||
[securityRegToken]: ISecurityConfig;
|
||||
[swaggerRegToken]: ISwaggerConfig;
|
||||
[ossRegToken]: IOssConfig;
|
||||
}
|
||||
|
||||
export type ConfigKeyPaths = RecordNamePaths<AllConfigType>;
|
||||
|
||||
export default {
|
||||
AppConfig,
|
||||
DatabaseConfig,
|
||||
MailerConfig,
|
||||
OssConfig,
|
||||
RedisConfig,
|
||||
SecurityConfig,
|
||||
SwaggerConfig
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
import { ConfigType, registerAs } from '@nestjs/config';
|
||||
|
||||
import { env, envNumber } from '~/global/env';
|
||||
|
||||
export const mailerRegToken = 'mailer';
|
||||
|
||||
export const MailerConfig = registerAs(mailerRegToken, () => ({
|
||||
host: env('SMTP_HOST'),
|
||||
port: envNumber('SMTP_PORT'),
|
||||
ignoreTLS: true,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: env('SMTP_USER'),
|
||||
pass: env('SMTP_PASS')
|
||||
}
|
||||
}));
|
||||
|
||||
export type IMailerConfig = ConfigType<typeof MailerConfig>;
|
|
@ -0,0 +1,34 @@
|
|||
import { ConfigType, registerAs } from '@nestjs/config';
|
||||
import * as qiniu from 'qiniu';
|
||||
|
||||
import { env, envBoolean, envNumber } from '~/global/env';
|
||||
|
||||
function parseZone(zone: string) {
|
||||
switch (zone) {
|
||||
case 'Zone_as0':
|
||||
return qiniu.zone.Zone_as0;
|
||||
case 'Zone_na0':
|
||||
return qiniu.zone.Zone_na0;
|
||||
case 'Zone_z0':
|
||||
return qiniu.zone.Zone_z0;
|
||||
case 'Zone_z1':
|
||||
return qiniu.zone.Zone_z1;
|
||||
case 'Zone_z2':
|
||||
return qiniu.zone.Zone_z2;
|
||||
}
|
||||
}
|
||||
|
||||
export const ossRegToken = 'oss';
|
||||
|
||||
export const OssConfig = registerAs(ossRegToken, () => ({
|
||||
accessKey: env('OSS_ACCESSKEY'),
|
||||
secretKey: env('OSS_SECRETKEY'),
|
||||
domain: env('OSS_DOMAIN'),
|
||||
port: envNumber('OSS_PORT'),
|
||||
useSSL: envBoolean('OSS_USE_SSL'),
|
||||
bucket: env('OSS_BUCKET'),
|
||||
zone: parseZone(env('OSS_ZONE') || 'Zone_z2'),
|
||||
access: (env('OSS_ACCESS_TYPE') as any) || 'public'
|
||||
}));
|
||||
|
||||
export type IOssConfig = ConfigType<typeof OssConfig>;
|
|
@ -0,0 +1,14 @@
|
|||
import { ConfigType, registerAs } from '@nestjs/config';
|
||||
|
||||
import { env, envNumber } from '~/global/env';
|
||||
|
||||
export const redisRegToken = 'redis';
|
||||
|
||||
export const RedisConfig = registerAs(redisRegToken, () => ({
|
||||
host: env('REDIS_HOST', '127.0.0.1'),
|
||||
port: envNumber('REDIS_PORT', 6379),
|
||||
password: env('REDIS_PASSWORD'),
|
||||
db: envNumber('REDIS_DB')
|
||||
}));
|
||||
|
||||
export type IRedisConfig = ConfigType<typeof RedisConfig>;
|
|
@ -0,0 +1,14 @@
|
|||
import { ConfigType, registerAs } from '@nestjs/config';
|
||||
|
||||
import { env, envNumber } from '~/global/env';
|
||||
|
||||
export const securityRegToken = 'security';
|
||||
|
||||
export const SecurityConfig = registerAs(securityRegToken, () => ({
|
||||
jwtSecret: env('JWT_SECRET'),
|
||||
jwtExprire: envNumber('JWT_EXPIRE'),
|
||||
refreshSecret: env('REFRESH_TOKEN_SECRET'),
|
||||
refreshExpire: envNumber('REFRESH_TOKEN_EXPIRE')
|
||||
}));
|
||||
|
||||
export type ISecurityConfig = ConfigType<typeof SecurityConfig>;
|
|
@ -0,0 +1,12 @@
|
|||
import { ConfigType, registerAs } from '@nestjs/config';
|
||||
|
||||
import { env, envBoolean } from '~/global/env';
|
||||
|
||||
export const swaggerRegToken = 'swagger';
|
||||
|
||||
export const SwaggerConfig = registerAs(swaggerRegToken, () => ({
|
||||
enable: envBoolean('SWAGGER_ENABLE'),
|
||||
path: env('SWAGGER_PATH')
|
||||
}));
|
||||
|
||||
export type ISwaggerConfig = ConfigType<typeof SwaggerConfig>;
|
|
@ -0,0 +1,8 @@
|
|||
export enum RedisKeys {
|
||||
AccessIp = 'access_ip',
|
||||
CAPTCHA_IMG_PREFIX = 'captcha:img:',
|
||||
AUTH_TOKEN_PREFIX = 'auth:token:',
|
||||
AUTH_PERM_PREFIX = 'auth:permission:',
|
||||
AUTH_PASSWORD_V_PREFIX = 'auth:passwordVersion:'
|
||||
}
|
||||
export const API_CACHE_PREFIX = 'api-cache:';
|
|
@ -0,0 +1,48 @@
|
|||
// 字典项status
|
||||
export enum DictTypeStatusEnum {
|
||||
/** 启用 */
|
||||
ENABLE = 1,
|
||||
/** 禁用 */
|
||||
DISABLE = 0
|
||||
}
|
||||
|
||||
// 业务模块枚举
|
||||
export enum BusinessModuleEnum {
|
||||
CONTRACT = 1,
|
||||
MATERIALS_INVENTORY = 2,
|
||||
COMPANY = 3
|
||||
}
|
||||
|
||||
// 原材料出库或者入库
|
||||
export enum MaterialsInOrOutEnum {
|
||||
In,
|
||||
Out
|
||||
}
|
||||
|
||||
// 系统参数key
|
||||
export enum ParamConfigEnum {
|
||||
InventoryNumberPrefix = 'inventory_number_prefix',
|
||||
InventoryInOutNumberPrefixIn = 'inventory_inout_number_prefix_in',
|
||||
InventoryInOutNumberPrefixOut = 'inventory_inout_number_prefix_out',
|
||||
ProductNumberPrefix = 'product_number_prefix'
|
||||
}
|
||||
|
||||
// 合同审核状态
|
||||
export enum ContractStatusEnum {
|
||||
Pending = 0, // 待审核
|
||||
Approved = 1, // 已通过
|
||||
Rejected = 2 // 已拒绝
|
||||
}
|
||||
|
||||
// 库存查询剩余咋黄台
|
||||
export enum HasInventoryStatusEnum {
|
||||
All = 0, // 全部
|
||||
Yes = 1, // 有库存
|
||||
No = 2 // 无库存
|
||||
}
|
||||
|
||||
// 权限资源设备类型
|
||||
export enum ResourceDeviceEnum {
|
||||
APP = 0,
|
||||
PC = 1
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
export enum ErrorEnum {
|
||||
DEFAULT = '0:未知错误',
|
||||
SERVER_ERROR = '500:服务繁忙,请稍后再试',
|
||||
|
||||
SYSTEM_USER_EXISTS = '1001:系统用户已存在',
|
||||
INVALID_VERIFICATION_CODE = '1002:验证码填写有误',
|
||||
INVALID_USERNAME_PASSWORD = '1003:用户名密码有误',
|
||||
NODE_ROUTE_EXISTS = '1004:节点路由已存在',
|
||||
PERMISSION_REQUIRES_PARENT = '1005:权限必须包含父节点',
|
||||
ILLEGAL_OPERATION_DIRECTORY_PARENT = '1006:非法操作:该节点仅支持目录类型父节点',
|
||||
ILLEGAL_OPERATION_CANNOT_CONVERT_NODE_TYPE = '1007:非法操作:节点类型无法直接转换',
|
||||
ROLE_HAS_ASSOCIATED_USERS = '1008:该角色存在关联用户,请先删除关联用户',
|
||||
DEPARTMENT_HAS_ASSOCIATED_USERS = '1009:该部门存在关联用户,请先删除关联用户',
|
||||
DEPARTMENT_HAS_ASSOCIATED_ROLES = '1010:该部门存在关联角色,请先删除关联角色',
|
||||
PASSWORD_MISMATCH = '1011:旧密码与原密码不一致',
|
||||
LOGOUT_OWN_SESSION = '1012:如想下线自身可右上角退出',
|
||||
NOT_ALLOWED_TO_LOGOUT_USER = '1013:不允许下线该用户',
|
||||
PARENT_MENU_NOT_FOUND = '1014:父级菜单不存在',
|
||||
DEPARTMENT_HAS_CHILD_DEPARTMENTS = '1015:该部门存在子部门,请先删除子部门',
|
||||
SYSTEM_BUILTIN_FUNCTION_NOT_ALLOWED = '1016:系统内置功能不允许操作',
|
||||
USER_NOT_FOUND = '1017:用户不存在',
|
||||
UNABLE_TO_FIND_DEPARTMENT_FOR_USER = '1018:无法查找当前用户所属部门',
|
||||
DEPARTMENT_NOT_FOUND = '1019:部门不存在',
|
||||
DICT_NAME_EXISTS = '1020: 已存在相同名称的字典',
|
||||
PARAMETER_CONFIG_KEY_EXISTS = '1021:参数配置键值对已存在',
|
||||
DEFAULT_ROLE_NOT_FOUND = '1022:所分配的默认角色不存在',
|
||||
|
||||
INVALID_LOGIN = '1101:登录无效,请重新登录',
|
||||
NO_PERMISSION = '1102:无权限访问',
|
||||
ONLY_ADMIN_CAN_LOGIN = '1103:不是管理员,无法登录',
|
||||
REQUEST_INVALIDATED = '1104:当前请求已失效',
|
||||
ACCOUNT_LOGGED_IN_ELSEWHERE = '1105:您的账号已在其他地方登录',
|
||||
GUEST_ACCOUNT_RESTRICTED_OPERATION = '1106:游客账号不允许操作',
|
||||
REQUESTED_RESOURCE_NOT_FOUND = '1107:所请求的资源不存在',
|
||||
|
||||
TOO_MANY_REQUESTS = '1201:请求频率过快,请一分钟后再试',
|
||||
MAXIMUM_FIVE_VERIFICATION_CODES_PER_DAY = '1202:一天最多发送5条验证码',
|
||||
VERIFICATION_CODE_SEND_FAILED = '1203:验证码发送失败',
|
||||
|
||||
INSECURE_MISSION = '1301:不安全的任务,确保执行的加入@Mission注解',
|
||||
EXECUTED_MISSION_NOT_FOUND = '1302:所执行的任务不存在',
|
||||
MISSION_EXECUTION_FAILED = '1303:任务执行失败',
|
||||
MISSION_NOT_FOUND = '1304:任务不存在',
|
||||
|
||||
// OSS相关
|
||||
OSS_FILE_OR_DIR_EXIST = '1401:当前创建的文件或目录已存在',
|
||||
OSS_NO_OPERATION_REQUIRED = '1402:无需操作',
|
||||
OSS_EXCEE_MAXIMUM_QUANTITY = '1403:已超出支持的最大处理数量',
|
||||
|
||||
// Storage相关
|
||||
STORAGE_NOT_FOUND = '1404:文件不存在,请重试',
|
||||
STORAGE_REFRENCE_EXISTS = '1405:文件存在关联,无法删除,请先找到该文件关联的业务解除关联。',
|
||||
|
||||
// Product
|
||||
PRODUCT_EXIST = '1406:产品已存在',
|
||||
|
||||
// Contract
|
||||
CONTRACT_NUMBER_EXIST = '1407:存在相同的合同编号',
|
||||
|
||||
// Inventory
|
||||
INVENTORY_INSUFFICIENT = '1408:库存数量不足。请检查库存或重新操作',
|
||||
MATERIALS_IN_OUT_NOT_FOUND = '1409:出入库信息不存在',
|
||||
MATERIALS_IN_OUT_UNIT_PRICE_CANNOT_BE_MODIFIED = '1410:该价格的产品已经出库,单价不允许修改。若有疑问,请联系管理员',
|
||||
MATERIALS_IN_OUT_UNIT_PRICE_MUST_ZERO_WHEN_MODIFIED = '1411:只能修改初始单价为0的入库记录。 若有疑问,请联系管理员',
|
||||
|
||||
// SaleQuotation
|
||||
SALE_QUOTATION_COMPONENT_DUPLICATED = '1412:存在名称,价格,规格都相同的配件,请检查是否重复录入',
|
||||
SALE_QUOTATION_TEMPLATE_NAME_DUPLICATE = '1413:模板名已存在',
|
||||
|
||||
//domain
|
||||
DOMAIN_TITLE_DUPLICATE = '1414:域标题已存在'
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export enum EventBusEvents {
|
||||
TokenExpired = 'token.expired',
|
||||
SystemException = 'system.exception'
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export const OSS_CONFIG = 'admin_module:qiniu_config';
|
||||
export const OSS_API = 'http://api.qiniu.com';
|
||||
|
||||
// 目录分隔符
|
||||
export const NETDISK_DELIMITER = '/';
|
||||
export const NETDISK_LIMIT = 100;
|
||||
export const NETDISK_HANDLE_MAX_ITEM = 1000;
|
||||
export const NETDISK_COPY_SUFFIX = '的副本';
|
|
@ -0,0 +1,15 @@
|
|||
export const RESPONSE_SUCCESS_CODE = 200;
|
||||
|
||||
export const RESPONSE_SUCCESS_MSG = 'success';
|
||||
|
||||
/**
|
||||
* @description: contentType
|
||||
*/
|
||||
export enum ContentTypeEnum {
|
||||
// json
|
||||
JSON = 'application/json;charset=UTF-8',
|
||||
// form-data qs
|
||||
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
// form-data upload
|
||||
FORM_DATA = 'multipart/form-data;charset=UTF-8'
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export const SYS_USER_INITPASSWORD = 'sys_user_initPassword';
|
||||
export const SYS_API_TOKEN = 'sys_api_token';
|
||||
/** 超级管理员用户 id */
|
||||
export const ROOT_USER_ID = 1;
|
||||
/** 超级管理员角色 id */
|
||||
export const ROOT_ROLE_ID = 1;
|
|
@ -0,0 +1,62 @@
|
|||
import cluster from 'node:cluster';
|
||||
|
||||
export const isMainCluster =
|
||||
process.env.NODE_APP_INSTANCE && Number.parseInt(process.env.NODE_APP_INSTANCE) === 0;
|
||||
export const isMainProcess = cluster.isPrimary || isMainCluster;
|
||||
|
||||
export const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
export const isTest = !!process.env.TEST;
|
||||
export const cwd = process.cwd();
|
||||
|
||||
/**
|
||||
* 基础类型接口
|
||||
*/
|
||||
export type BaseType = boolean | number | string | undefined | null;
|
||||
|
||||
/**
|
||||
* 格式化环境变量
|
||||
* @param key 环境变量的键值
|
||||
* @param defaultValue 默认值
|
||||
* @param callback 格式化函数
|
||||
*/
|
||||
function fromatValue<T extends BaseType = string>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
callback?: (value: string) => T
|
||||
): T {
|
||||
const value: string | undefined = process.env[key];
|
||||
if (typeof value === 'undefined') return defaultValue;
|
||||
|
||||
if (!callback) return value as unknown as T;
|
||||
|
||||
return callback(value);
|
||||
}
|
||||
|
||||
export function env(key: string, defaultValue: string = '') {
|
||||
return fromatValue(key, defaultValue);
|
||||
}
|
||||
|
||||
export function envString(key: string, defaultValue: string = '') {
|
||||
return fromatValue(key, defaultValue);
|
||||
}
|
||||
|
||||
export function envNumber(key: string, defaultValue: number = 0) {
|
||||
return fromatValue(key, defaultValue, value => {
|
||||
try {
|
||||
return Number(value);
|
||||
} catch {
|
||||
throw new Error(`${key} environment variable is not a number`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function envBoolean(key: string, defaultValue: boolean = false) {
|
||||
return fromatValue(key, defaultValue, value => {
|
||||
try {
|
||||
return Boolean(JSON.parse(value));
|
||||
} catch {
|
||||
throw new Error(`${key} environment variable is not a boolean`);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export function catchError() {
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Promise: ', p, 'Reason: ', reason);
|
||||
});
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ObjectLiteral, Repository } from 'typeorm';
|
||||
|
||||
import { PagerDto } from '~/common/dto/pager.dto';
|
||||
|
||||
import { paginate } from '../paginate';
|
||||
import { Pagination } from '../paginate/pagination';
|
||||
|
||||
export class BaseService<E extends ObjectLiteral, R extends Repository<E> = Repository<E>> {
|
||||
constructor(private repository: R) {}
|
||||
|
||||
async list({ page, pageSize }: PagerDto): Promise<Pagination<E>> {
|
||||
return paginate(this.repository, { page, pageSize });
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<E> {
|
||||
const item = await this.repository.createQueryBuilder().where({ id }).getOne();
|
||||
if (!item) throw new NotFoundException('未找到该记录');
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
async create(dto: any): Promise<E> {
|
||||
return await this.repository.save(dto);
|
||||
}
|
||||
|
||||
async update(id: number, dto: any): Promise<void> {
|
||||
await this.repository.update(id, dto);
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
const item = await this.findOne(id);
|
||||
await this.repository.remove(item);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import type { Type } from '@nestjs/common';
|
||||
|
||||
import { Body, Controller, Delete, Get, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiBody, IntersectionType, PartialType } from '@nestjs/swagger';
|
||||
import pluralize from 'pluralize';
|
||||
|
||||
import { ApiResult } from '~/common/decorators/api-result.decorator';
|
||||
import { IdParam } from '~/common/decorators/id-param.decorator';
|
||||
import { PagerDto } from '~/common/dto/pager.dto';
|
||||
|
||||
import { BaseService } from './base.service';
|
||||
|
||||
export function BaseCrudFactory<E extends new (...args: any[]) => any>({
|
||||
entity,
|
||||
dto,
|
||||
permissions
|
||||
}: {
|
||||
entity: E;
|
||||
dto?: Type<any>;
|
||||
permissions?: Record<string, string>;
|
||||
}): Type<any> {
|
||||
const prefix = entity.name.toLowerCase().replace(/entity$/, '');
|
||||
const pluralizeName = pluralize(prefix) as string;
|
||||
|
||||
dto = dto ?? class extends entity {};
|
||||
|
||||
class Dto extends dto {}
|
||||
class UpdateDto extends PartialType(Dto) {}
|
||||
class QueryDto extends IntersectionType(PagerDto, PartialType(Dto)) {}
|
||||
|
||||
permissions =
|
||||
permissions ??
|
||||
({
|
||||
LIST: `${prefix}:list`,
|
||||
CREATE: `${prefix}:create`,
|
||||
READ: `${prefix}:read`,
|
||||
UPDATE: `${prefix}:update`,
|
||||
DELETE: `${prefix}:delete`
|
||||
} as const);
|
||||
|
||||
@Controller(pluralizeName)
|
||||
class BaseController<S extends BaseService<E>> {
|
||||
constructor(private service: S) {}
|
||||
|
||||
@Get()
|
||||
@ApiResult({ type: [entity], isPage: true })
|
||||
async list(@Query() pager: QueryDto) {
|
||||
return await this.service.list(pager);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiResult({ type: entity })
|
||||
async get(@IdParam() id: number) {
|
||||
return await this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiBody({ type: dto })
|
||||
async create(@Body() dto: Dto) {
|
||||
return await this.service.create(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@IdParam() id: number, @Body() dto: UpdateDto) {
|
||||
return await this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async patch(@IdParam() id: number, @Body() dto: UpdateDto) {
|
||||
await this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@IdParam() id: number) {
|
||||
await this.service.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return BaseController;
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { RedisKeys } from '~/constants/cache.constant';
|
||||
|
||||
/** 生成验证码 redis key */
|
||||
export function genCaptchaImgKey(val: string | number) {
|
||||
return `${RedisKeys.CAPTCHA_IMG_PREFIX}${String(val)}` as const;
|
||||
}
|
||||
|
||||
/** 生成 auth token redis key */
|
||||
export function genAuthTokenKey(val: string | number) {
|
||||
return `${RedisKeys.AUTH_TOKEN_PREFIX}${String(val)}` as const;
|
||||
}
|
||||
/** 生成 auth permission redis key */
|
||||
export function genAuthPermKey(val: string | number) {
|
||||
return `${RedisKeys.AUTH_PERM_PREFIX}${String(val)}` as const;
|
||||
}
|
||||
/** 生成 auth passwordVersion redis key */
|
||||
export function genAuthPVKey(val: string | number) {
|
||||
return `${RedisKeys.AUTH_PASSWORD_V_PREFIX}${String(val)}` as const;
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { IPaginationMeta } from './interface';
|
||||
import { Pagination } from './pagination';
|
||||
|
||||
export function createPaginationObject<T>({
|
||||
items,
|
||||
totalItems,
|
||||
currentPage,
|
||||
limit
|
||||
}: {
|
||||
items: T[];
|
||||
totalItems?: number;
|
||||
currentPage: number;
|
||||
limit: number;
|
||||
}): Pagination<T> {
|
||||
const totalPages = totalItems !== undefined ? Math.ceil(totalItems / limit) : undefined;
|
||||
|
||||
const meta: IPaginationMeta = {
|
||||
totalItems,
|
||||
itemCount: items.length,
|
||||
itemsPerPage: limit,
|
||||
totalPages,
|
||||
currentPage
|
||||
};
|
||||
|
||||
return new Pagination<T>(items, meta);
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
import {
|
||||
FindManyOptions,
|
||||
FindOptionsWhere,
|
||||
ObjectLiteral,
|
||||
Repository,
|
||||
SelectQueryBuilder
|
||||
} from 'typeorm';
|
||||
|
||||
import { createPaginationObject } from './create-pagination';
|
||||
import { IPaginationOptions, PaginationTypeEnum } from './interface';
|
||||
import { Pagination } from './pagination';
|
||||
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const DEFAULT_PAGE = 1;
|
||||
|
||||
function resolveOptions(options: IPaginationOptions): [number, number, PaginationTypeEnum] {
|
||||
const { page, pageSize, paginationType } = options;
|
||||
|
||||
return [
|
||||
page || DEFAULT_PAGE,
|
||||
pageSize || DEFAULT_LIMIT,
|
||||
paginationType || PaginationTypeEnum.TAKE_AND_SKIP
|
||||
];
|
||||
}
|
||||
|
||||
async function paginateRepository<T>(
|
||||
repository: Repository<T>,
|
||||
options: IPaginationOptions,
|
||||
searchOptions?: FindOptionsWhere<T> | FindManyOptions<T>
|
||||
): Promise<Pagination<T>> {
|
||||
const [page, limit] = resolveOptions(options);
|
||||
|
||||
const promises: [Promise<T[]>, Promise<number> | undefined] = [
|
||||
repository.find({
|
||||
skip: limit * (page - 1),
|
||||
take: limit,
|
||||
...searchOptions
|
||||
}),
|
||||
undefined
|
||||
];
|
||||
|
||||
const [items, total] = await Promise.all(promises);
|
||||
|
||||
return createPaginationObject<T>({
|
||||
items,
|
||||
totalItems: total,
|
||||
currentPage: page,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
async function paginateQueryBuilder<T>(
|
||||
queryBuilder: SelectQueryBuilder<T>,
|
||||
options: IPaginationOptions
|
||||
): Promise<Pagination<T>> {
|
||||
const [page, limit, paginationType] = resolveOptions(options);
|
||||
|
||||
if (paginationType === PaginationTypeEnum.TAKE_AND_SKIP)
|
||||
queryBuilder.take(limit).skip((page - 1) * limit);
|
||||
else queryBuilder.limit(limit).offset((page - 1) * limit);
|
||||
|
||||
const [items, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return createPaginationObject<T>({
|
||||
items,
|
||||
totalItems: total,
|
||||
currentPage: page,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
export async function paginateRaw<T>(
|
||||
queryBuilder: SelectQueryBuilder<T>,
|
||||
options: IPaginationOptions
|
||||
): Promise<Pagination<T>> {
|
||||
const [page, limit, paginationType] = resolveOptions(options);
|
||||
|
||||
const promises: [Promise<T[]>, Promise<number> | undefined] = [
|
||||
(paginationType === PaginationTypeEnum.LIMIT_AND_OFFSET
|
||||
? queryBuilder.limit(limit).offset((page - 1) * limit)
|
||||
: queryBuilder.take(limit).skip((page - 1) * limit)
|
||||
).getRawMany<T>(),
|
||||
queryBuilder.getCount()
|
||||
];
|
||||
|
||||
const [items, total] = await Promise.all(promises);
|
||||
|
||||
return createPaginationObject<T>({
|
||||
items,
|
||||
totalItems: total,
|
||||
currentPage: page,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
export async function paginateRawAndEntities<T>(
|
||||
queryBuilder: SelectQueryBuilder<T>,
|
||||
options: IPaginationOptions
|
||||
): Promise<[Pagination<T>, Partial<T>[]]> {
|
||||
const [page, limit, paginationType] = resolveOptions(options);
|
||||
|
||||
const promises: [Promise<{ entities: T[]; raw: T[] }>, Promise<number> | undefined] = [
|
||||
(paginationType === PaginationTypeEnum.LIMIT_AND_OFFSET
|
||||
? queryBuilder.limit(limit).offset((page - 1) * limit)
|
||||
: queryBuilder.take(limit).skip((page - 1) * limit)
|
||||
).getRawAndEntities<T>(),
|
||||
queryBuilder.getCount()
|
||||
];
|
||||
|
||||
const [itemObject, total] = await Promise.all(promises);
|
||||
|
||||
return [
|
||||
createPaginationObject<T>({
|
||||
items: itemObject.entities,
|
||||
totalItems: total,
|
||||
currentPage: page,
|
||||
limit
|
||||
}),
|
||||
itemObject.raw
|
||||
];
|
||||
}
|
||||
|
||||
export async function paginate<T extends ObjectLiteral>(
|
||||
repository: Repository<T>,
|
||||
options: IPaginationOptions,
|
||||
searchOptions?: FindOptionsWhere<T> | FindManyOptions<T>
|
||||
): Promise<Pagination<T>>;
|
||||
export async function paginate<T>(
|
||||
queryBuilder: SelectQueryBuilder<T>,
|
||||
options: IPaginationOptions
|
||||
): Promise<Pagination<T>>;
|
||||
|
||||
export async function paginate<T extends ObjectLiteral>(
|
||||
repositoryOrQueryBuilder: Repository<T> | SelectQueryBuilder<T>,
|
||||
options: IPaginationOptions,
|
||||
searchOptions?: FindOptionsWhere<T> | FindManyOptions<T>
|
||||
) {
|
||||
return repositoryOrQueryBuilder instanceof Repository
|
||||
? paginateRepository<T>(repositoryOrQueryBuilder, options, searchOptions)
|
||||
: paginateQueryBuilder<T>(repositoryOrQueryBuilder, options);
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import { ObjectLiteral } from 'typeorm';
|
||||
|
||||
export enum PaginationTypeEnum {
|
||||
LIMIT_AND_OFFSET = 'limit',
|
||||
TAKE_AND_SKIP = 'take'
|
||||
}
|
||||
|
||||
export interface IPaginationOptions {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
paginationType?: PaginationTypeEnum;
|
||||
}
|
||||
|
||||
export interface IPaginationMeta extends ObjectLiteral {
|
||||
itemCount: number;
|
||||
totalItems?: number;
|
||||
itemsPerPage: number;
|
||||
totalPages?: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export interface IPaginationLinks {
|
||||
first?: string;
|
||||
previous?: string;
|
||||
next?: string;
|
||||
last?: string;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { IPaginationMeta } from './interface';
|
||||
|
||||
export class Pagination<PaginationObject, T extends ObjectLiteral = IPaginationMeta> {
|
||||
constructor(
|
||||
public items: PaginationObject[],
|
||||
|
||||
public readonly meta: T
|
||||
) {}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import cluster from 'node:cluster';
|
||||
import path from 'node:path';
|
||||
|
||||
import { HttpStatus, Logger, UnprocessableEntityException, ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
|
||||
import { useContainer } from 'class-validator';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
import { fastifyApp } from './common/adapters/fastify.adapter';
|
||||
import { RedisIoAdapter } from './common/adapters/socket.adapter';
|
||||
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
||||
import type { ConfigKeyPaths } from './config';
|
||||
import { isDev, isMainProcess } from './global/env';
|
||||
import { setupSwagger } from './setup-swagger';
|
||||
import { LoggerService } from './shared/logger/logger.service';
|
||||
|
||||
declare const module: any;
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(AppModule, fastifyApp, {
|
||||
bufferLogs: true,
|
||||
snapshot: true
|
||||
// forceCloseConnections: true,
|
||||
});
|
||||
|
||||
const configService = app.get(ConfigService<ConfigKeyPaths>);
|
||||
|
||||
const { port, globalPrefix } = configService.get('app', { infer: true });
|
||||
|
||||
// class-validator 的 DTO 类中注入 nest 容器的依赖 (用于自定义验证器)
|
||||
useContainer(app.select(AppModule), { fallbackOnErrors: true });
|
||||
app.enableCors({ origin: '*', credentials: true });
|
||||
app.setGlobalPrefix(globalPrefix);
|
||||
app.useStaticAssets({ root: path.join(__dirname, '..', 'public') });
|
||||
// Starts listening for shutdown hooks
|
||||
!isDev && app.enableShutdownHooks();
|
||||
|
||||
if (isDev) app.useGlobalInterceptors(new LoggingInterceptor());
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
// forbidNonWhitelisted: true, // 禁止 无装饰器验证的数据通过
|
||||
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
stopAtFirstError: true,
|
||||
exceptionFactory: errors =>
|
||||
new UnprocessableEntityException(
|
||||
errors.map(e => {
|
||||
const rule = Object.keys(e.constraints!)[0];
|
||||
const msg = e.constraints![rule];
|
||||
return msg;
|
||||
})[0]
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
app.useWebSocketAdapter(new RedisIoAdapter(app));
|
||||
|
||||
setupSwagger(app, configService);
|
||||
|
||||
await app.listen(port, '0.0.0.0', async () => {
|
||||
app.useLogger(app.get(LoggerService));
|
||||
const url = await app.getUrl();
|
||||
const { pid } = process;
|
||||
const env = cluster.isPrimary;
|
||||
const prefix = env ? 'P' : 'W';
|
||||
|
||||
if (!isMainProcess) return;
|
||||
|
||||
const logger = new Logger('NestApplication');
|
||||
logger.log(`[${prefix + pid}] Server running on ${url}`);
|
||||
|
||||
if (isDev) logger.log(`[${prefix + pid}] OpenAPI: ${url}/api-docs`);
|
||||
});
|
||||
|
||||
if (module.hot) {
|
||||
module.hot.accept();
|
||||
module.hot.dispose(() => app.close());
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
|
@ -0,0 +1,14 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
const sql = fs.readFileSync(path.join(__dirname, '../../init_data/sql/hxoa.sql'), 'utf8');
|
||||
|
||||
export class InitData1707996695540 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
export const PUBLIC_KEY = '__public_key__';
|
||||
|
||||
export const PERMISSION_KEY = '__permission_key__';
|
||||
|
||||
export const RESOURCE_KEY = '__resource_key__';
|
||||
|
||||
export const ALLOW_ANON_KEY = '__allow_anon_permission_key__';
|
||||
|
||||
export const AuthStrategy = {
|
||||
LOCAL: 'local',
|
||||
LOCAL_EMAIL: 'local_email',
|
||||
LOCAL_PHONE: 'local_phone',
|
||||
|
||||
JWT: 'jwt',
|
||||
|
||||
GITHUB: 'github',
|
||||
GOOGLE: 'google'
|
||||
} as const;
|
||||
|
||||
export const Roles = {
|
||||
ADMIN: 'admin',
|
||||
USER: 'user'
|
||||
// GUEST: 'guest',
|
||||
} as const;
|
||||
|
||||
export type Role = (typeof Roles)[keyof typeof Roles];
|
|
@ -0,0 +1,60 @@
|
|||
import { Body, Controller, Headers, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiResult } from '~/common/decorators/api-result.decorator';
|
||||
import { Ip, IsMobile } from '~/common/decorators/http.decorator';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { LoginDto, RegisterDto } from './dto/auth.dto';
|
||||
import { LocalGuard } from './guards/local.guard';
|
||||
import { LoginToken } from './models/auth.model';
|
||||
import { CaptchaService } from './services/captcha.service';
|
||||
import { AuthUser } from './decorators/auth-user.decorator';
|
||||
import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator';
|
||||
import { Domain, SkDomain } from '~/common/decorators/domain.decorator';
|
||||
|
||||
@ApiTags('Auth - 认证模块')
|
||||
@UseGuards(LocalGuard)
|
||||
@Public()
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private userService: UserService,
|
||||
private captchaService: CaptchaService
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: '登录' })
|
||||
@ApiResult({ type: LoginToken })
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Ip() ip: string,
|
||||
@IsMobile() isMobile: boolean,
|
||||
@Headers('user-agent') ua: string
|
||||
): Promise<LoginToken> {
|
||||
if (!isMobile) {
|
||||
await this.captchaService.checkImgCaptcha(dto.captchaId, dto.verifyCode);
|
||||
}
|
||||
const token = await this.authService.login(dto.username, dto.password, ip, ua);
|
||||
return { token };
|
||||
}
|
||||
|
||||
@Post('unlock')
|
||||
@ApiSecurityAuth()
|
||||
@ApiOperation({ summary: '屏幕解锁,使用密码和token' })
|
||||
@ApiResult({ type: LoginToken })
|
||||
async unlock(@Body() dto: LoginDto, @AuthUser() user: IAuthUser): Promise<Boolean> {
|
||||
await this.authService.unlock(user.uid, dto.password);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '注册' })
|
||||
async register(@Domain() domain: SkDomain, @Body() dto: RegisterDto): Promise<void> {
|
||||
await this.userService.register(dto, domain);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ConfigKeyPaths, ISecurityConfig } from '~/config';
|
||||
import { isDev } from '~/global/env';
|
||||
|
||||
import { LogModule } from '../system/log/log.module';
|
||||
import { MenuModule } from '../system/menu/menu.module';
|
||||
import { RoleModule } from '../system/role/role.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AccountController } from './controllers/account.controller';
|
||||
import { CaptchaController } from './controllers/captcha.controller';
|
||||
import { EmailController } from './controllers/email.controller';
|
||||
import { AccessTokenEntity } from './entities/access-token.entity';
|
||||
import { RefreshTokenEntity } from './entities/refresh-token.entity';
|
||||
import { CaptchaService } from './services/captcha.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
|
||||
const controllers = [AuthController, AccountController, CaptchaController, EmailController];
|
||||
const providers = [AuthService, TokenService, CaptchaService];
|
||||
const strategies = [LocalStrategy, JwtStrategy];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AccessTokenEntity, RefreshTokenEntity]),
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService<ConfigKeyPaths>) => {
|
||||
const { jwtSecret, jwtExprire } = configService.get<ISecurityConfig>('security');
|
||||
|
||||
return {
|
||||
secret: jwtSecret,
|
||||
expires: jwtExprire,
|
||||
ignoreExpiration: isDev
|
||||
};
|
||||
},
|
||||
inject: [ConfigService]
|
||||
}),
|
||||
UserModule,
|
||||
RoleModule,
|
||||
MenuModule,
|
||||
LogModule
|
||||
],
|
||||
controllers: [...controllers],
|
||||
providers: [...providers, ...strategies],
|
||||
exports: [TypeOrmModule, JwtModule, ...providers]
|
||||
})
|
||||
export class AuthModule {}
|
|
@ -0,0 +1,162 @@
|
|||
import { InjectRedis } from '@liaoliaots/nestjs-redis';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import Redis from 'ioredis';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { BusinessException } from '~/common/exceptions/biz.exception';
|
||||
|
||||
import { ErrorEnum } from '~/constants/error-code.constant';
|
||||
import { genAuthPVKey, genAuthPermKey, genAuthTokenKey } from '~/helper/genRedisKey';
|
||||
|
||||
import { UserService } from '~/modules/user/user.service';
|
||||
|
||||
import { md5 } from '~/utils';
|
||||
|
||||
import { LoginLogService } from '../system/log/services/login-log.service';
|
||||
import { MenuService } from '../system/menu/menu.service';
|
||||
import { RoleService } from '../system/role/role.service';
|
||||
|
||||
import { TokenService } from './services/token.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRedis() private readonly redis: Redis,
|
||||
private menuService: MenuService,
|
||||
private roleService: RoleService,
|
||||
private userService: UserService,
|
||||
private loginLogService: LoginLogService,
|
||||
private tokenService: TokenService
|
||||
) {}
|
||||
|
||||
async validateUser(credential: string, password: string): Promise<any> {
|
||||
const user = await this.userService.findUserByUserName(credential);
|
||||
|
||||
if (isEmpty(user)) throw new BusinessException(ErrorEnum.USER_NOT_FOUND);
|
||||
|
||||
const comparePassword = md5(`${password}${user.psalt}`);
|
||||
if (user.password !== comparePassword)
|
||||
throw new BusinessException(ErrorEnum.INVALID_USERNAME_PASSWORD);
|
||||
|
||||
if (user) {
|
||||
const { password, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录JWT
|
||||
* 返回null则账号密码有误,不存在该用户
|
||||
*/
|
||||
async login(username: string, password: string, ip: string, ua: string): Promise<string> {
|
||||
const user = await this.userService.findUserByUserName(username);
|
||||
if (isEmpty(user)) throw new BusinessException(ErrorEnum.INVALID_USERNAME_PASSWORD);
|
||||
|
||||
const comparePassword = md5(`${password}${user.psalt}`);
|
||||
if (user.password !== comparePassword)
|
||||
throw new BusinessException(ErrorEnum.INVALID_USERNAME_PASSWORD);
|
||||
|
||||
const roleIds = await this.roleService.getRoleIdsByUser(user.id);
|
||||
|
||||
const roles = await this.roleService.getRoleValues(roleIds);
|
||||
|
||||
// 包含access_token和refresh_token
|
||||
const token = await this.tokenService.generateAccessToken(user.id, roles);
|
||||
|
||||
await this.redis.set(genAuthTokenKey(user.id), token.accessToken);
|
||||
|
||||
// 设置密码版本号 当密码修改时,版本号+1
|
||||
await this.redis.set(genAuthPVKey(user.id), 1);
|
||||
|
||||
// 设置菜单权限
|
||||
const permissions = await this.menuService.getPermissions(user.id);
|
||||
await this.setPermissionsCache(user.id, permissions);
|
||||
|
||||
await this.loginLogService.create(user.id, ip, ua);
|
||||
|
||||
return token.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁屏幕
|
||||
* 返回null则账号密码有误,不存在该用户
|
||||
*/
|
||||
async unlock(uid: number, password: string): Promise<void> {
|
||||
const user = await this.userService.findUserById(uid);
|
||||
if (isEmpty(user)) throw new BusinessException(ErrorEnum.INVALID_USERNAME_PASSWORD);
|
||||
|
||||
const comparePassword = md5(`${password}${user.psalt}`);
|
||||
if (user.password !== comparePassword)
|
||||
throw new BusinessException(ErrorEnum.INVALID_USERNAME_PASSWORD);
|
||||
}
|
||||
|
||||
/**
|
||||
* 效验账号密码
|
||||
*/
|
||||
async checkPassword(username: string, password: string) {
|
||||
const user = await this.userService.findUserByUserName(username);
|
||||
|
||||
const comparePassword = md5(`${password}${user.psalt}`);
|
||||
if (user.password !== comparePassword)
|
||||
throw new BusinessException(ErrorEnum.INVALID_USERNAME_PASSWORD);
|
||||
}
|
||||
|
||||
async loginLog(uid: number, ip: string, ua: string) {
|
||||
await this.loginLogService.create(uid, ip, ua);
|
||||
}
|
||||
|
||||
async logout(uid: number) {
|
||||
// 删除token
|
||||
await this.userService.forbidden(uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
async resetPassword(username: string, password: string) {
|
||||
const user = await this.userService.findUserByUserName(username);
|
||||
|
||||
await this.userService.forceUpdatePassword(user.id, password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除登录状态信息
|
||||
*/
|
||||
async clearLoginStatus(uid: number): Promise<void> {
|
||||
await this.userService.forbidden(uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单列表
|
||||
*/
|
||||
async getMenus(uid: number, isApp: number): Promise<string[]> {
|
||||
return this.menuService.getMenus(uid,isApp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取权限列表
|
||||
*/
|
||||
async getPermissions(uid: number): Promise<string[]> {
|
||||
return this.menuService.getPermissions(uid);
|
||||
}
|
||||
|
||||
async getPermissionsCache(uid: number): Promise<string[]> {
|
||||
const permissionString = await this.redis.get(genAuthPermKey(uid));
|
||||
return permissionString ? JSON.parse(permissionString) : [];
|
||||
}
|
||||
|
||||
async setPermissionsCache(uid: number, permissions: string[]): Promise<void> {
|
||||
await this.redis.set(genAuthPermKey(uid), JSON.stringify(permissions));
|
||||
}
|
||||
|
||||
async getPasswordVersionByUid(uid: number): Promise<string> {
|
||||
return this.redis.get(genAuthPVKey(uid));
|
||||
}
|
||||
|
||||
async getTokenByUid(uid: number): Promise<string> {
|
||||
return this.redis.get(genAuthTokenKey(uid));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiResult } from '~/common/decorators/api-result.decorator';
|
||||
|
||||
import { ApiSecurityAuth } from '~/common/decorators/swagger.decorator';
|
||||
import { AllowAnon } from '~/modules/auth/decorators/allow-anon.decorator';
|
||||
import { AuthUser } from '~/modules/auth/decorators/auth-user.decorator';
|
||||
|
||||
import { PasswordUpdateDto } from '~/modules/user/dto/password.dto';
|
||||
|
||||
import { AccountInfo } from '../../user/user.model';
|
||||
import { UserService } from '../../user/user.service';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { AccountMenus, AccountUpdateDto } from '../dto/account.dto';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { IsMobile } from '~/common/decorators/http.decorator';
|
||||
import { ResourceDeviceEnum } from '~/constants/enum';
|
||||
import { Domain } from '~/common/decorators/domain.decorator';
|
||||
|
||||
@ApiTags('Account - 账户模块')
|
||||
@ApiSecurityAuth()
|
||||
@ApiExtraModels(AccountInfo)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('account')
|
||||
export class AccountController {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private authService: AuthService
|
||||
) {}
|
||||
|
||||
@Get('profile')
|
||||
@ApiOperation({ summary: '获取账户资料' })
|
||||
@ApiResult({ type: AccountInfo })
|
||||
@AllowAnon()
|
||||
async profile(@AuthUser() user: IAuthUser): Promise<AccountInfo> {
|
||||
return this.userService.getAccountInfo(user.uid);
|
||||
}
|
||||
|
||||
@Get('logout')
|
||||
@ApiOperation({ summary: '账户登出' })
|
||||
@AllowAnon()
|
||||
async logout(@AuthUser() user: IAuthUser): Promise<void> {
|
||||
await this.authService.clearLoginStatus(user.uid);
|
||||
}
|
||||
|
||||
@Get('menus')
|
||||
@ApiOperation({ summary: '获取菜单列表' })
|
||||
@ApiResult({ type: [AccountMenus] })
|
||||
@AllowAnon()
|
||||
async menu(@AuthUser() user: IAuthUser, @IsMobile() isApp: boolean): Promise<string[]> {
|
||||
return this.authService.getMenus(
|
||||
user.uid,
|
||||
isApp ? ResourceDeviceEnum.APP : ResourceDeviceEnum.PC
|
||||
);
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
@ApiOperation({ summary: '获取权限列表' })
|
||||
@ApiResult({ type: [String] })
|
||||
@AllowAnon()
|
||||
async permissions(@AuthUser() user: IAuthUser): Promise<string[]> {
|
||||
return this.authService.getPermissions(user.uid);
|
||||
}
|
||||
|
||||
@Put('update')
|
||||
@ApiOperation({ summary: '更改账户资料' })
|
||||
@AllowAnon()
|
||||
async update(@AuthUser() user: IAuthUser, @Body() dto: AccountUpdateDto): Promise<void> {
|
||||
await this.userService.updateAccountInfo(user.uid, dto);
|
||||
}
|
||||
|
||||
@Post('password')
|
||||
@ApiOperation({ summary: '更改账户密码' })
|
||||
@AllowAnon()
|
||||
async password(@AuthUser() user: IAuthUser, @Body() dto: PasswordUpdateDto): Promise<void> {
|
||||
await this.userService.updatePassword(user.uid, dto);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import { InjectRedis } from '@liaoliaots/nestjs-redis';
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import Redis from 'ioredis';
|
||||
import { isEmpty } from 'lodash';
|
||||
import * as svgCaptcha from 'svg-captcha';
|
||||
|
||||
import { ApiResult } from '~/common/decorators/api-result.decorator';
|
||||
import { genCaptchaImgKey } from '~/helper/genRedisKey';
|
||||
import { generateUUID } from '~/utils';
|
||||
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
|
||||
import { ImageCaptchaDto } from '../dto/captcha.dto';
|
||||
import { ImageCaptcha } from '../models/auth.model';
|
||||
|
||||
@ApiTags('Captcha - 验证码模块')
|
||||
// @UseGuards(ThrottlerGuard)
|
||||
@Controller('auth/captcha')
|
||||
export class CaptchaController {
|
||||
constructor(@InjectRedis() private redis: Redis) {}
|
||||
|
||||
@Get('img')
|
||||
@ApiOperation({ summary: '获取登录图片验证码' })
|
||||
@ApiResult({ type: ImageCaptcha })
|
||||
@Public()
|
||||
// @Throttle({ default: { limit: 2, ttl: 600000 } })
|
||||
async captchaByImg(@Query() dto: ImageCaptchaDto): Promise<ImageCaptcha> {
|
||||
const { width, height } = dto;
|
||||
|
||||
const svg = svgCaptcha.create({
|
||||
size: 4,
|
||||
color: true,
|
||||
noise: 4,
|
||||
width: isEmpty(width) ? 100 : width,
|
||||
height: isEmpty(height) ? 50 : height,
|
||||
charPreset: '1234567890'
|
||||
});
|
||||
const result = {
|
||||
img: `data:image/svg+xml;base64,${Buffer.from(svg.data).toString('base64')}`,
|
||||
id: generateUUID()
|
||||
};
|
||||
// 5分钟过期时间
|
||||
await this.redis.set(genCaptchaImgKey(result.id), svg.text, 'EX', 60 * 5);
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
import { Ip } from '~/common/decorators/http.decorator';
|
||||
|
||||
import { MailerService } from '~/shared/mailer/mailer.service';
|
||||
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
|
||||
import { SendEmailCodeDto } from '../dto/captcha.dto';
|
||||
|
||||
@ApiTags('Auth - 认证模块')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Controller('auth/email')
|
||||
export class EmailController {
|
||||
constructor(private mailerService: MailerService) {}
|
||||
|
||||
@Post('send')
|
||||
@ApiOperation({ summary: '发送邮箱验证码' })
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 2, ttl: 600000 } })
|
||||
async sendEmailCode(@Body() dto: SendEmailCodeDto, @Ip() ip: string): Promise<void> {
|
||||
// await this.authService.checkImgCaptcha(dto.captchaId, dto.verifyCode);
|
||||
const { email } = dto;
|
||||
|
||||
await this.mailerService.checkLimit(email, ip);
|
||||
const { code } = await this.mailerService.sendVerificationCode(email);
|
||||
|
||||
await this.mailerService.log(email, code, ip);
|
||||
}
|
||||
|
||||
// @Post()
|
||||
// async authWithEmail(@AuthUser() user: IAuthUser) {
|
||||
// // TODO:
|
||||
// }
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
import { ALLOW_ANON_KEY } from '../auth.constant';
|
||||
|
||||
/**
|
||||
* 当接口不需要检测用户是否具有操作权限时添加该装饰器
|
||||
*/
|
||||
export const AllowAnon = () => SetMetadata(ALLOW_ANON_KEY, true);
|
|
@ -0,0 +1,15 @@
|
|||
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
type Payload = keyof IAuthUser;
|
||||
|
||||
/**
|
||||
* @description 获取当前登录用户信息, 并挂载到request上
|
||||
*/
|
||||
export const AuthUser = createParamDecorator((data: Payload, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<FastifyRequest>();
|
||||
// auth guard will mount this
|
||||
const user = request.user as IAuthUser;
|
||||
|
||||
return data ? user?.[data] : user;
|
||||
});
|
|
@ -0,0 +1,63 @@
|
|||
import { SetMetadata, applyDecorators } from '@nestjs/common';
|
||||
|
||||
import { isPlainObject } from 'lodash';
|
||||
|
||||
import { PERMISSION_KEY } from '../auth.constant';
|
||||
|
||||
type TupleToObject<T extends string, P extends ReadonlyArray<string>> = {
|
||||
[K in Uppercase<P[number]>]: `${T}:${Lowercase<K>}`;
|
||||
};
|
||||
type AddPrefixToObjectValue<T extends string, P extends Record<string, string>> = {
|
||||
[K in keyof P]: K extends string ? `${T}:${P[K]}` : never;
|
||||
};
|
||||
|
||||
/** 资源操作需要特定的权限 */
|
||||
export function Perm(permission: string | string[]) {
|
||||
return applyDecorators(SetMetadata(PERMISSION_KEY, permission));
|
||||
}
|
||||
|
||||
/** (此举非必需)保存通过 definePermission 定义的所有权限,可用于前端开发人员开发阶段的 ts 类型提示,避免前端权限定义与后端定义不匹配 */
|
||||
let permissions: string[] = [];
|
||||
/**
|
||||
* 定义权限,同时收集所有被定义的权限
|
||||
*
|
||||
* - 通过对象形式定义, eg:
|
||||
* ```ts
|
||||
* definePermission('app:health', {
|
||||
* NETWORK: 'network'
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* - 通过字符串数组形式定义, eg:
|
||||
* ```ts
|
||||
* definePermission('app:health', ['network']);
|
||||
* ```
|
||||
*/
|
||||
export function definePermission<T extends string, U extends Record<string, string>>(
|
||||
modulePrefix: T,
|
||||
actionMap: U
|
||||
): AddPrefixToObjectValue<T, U>;
|
||||
export function definePermission<T extends string, U extends ReadonlyArray<string>>(
|
||||
modulePrefix: T,
|
||||
actions: U
|
||||
): TupleToObject<T, U>;
|
||||
export function definePermission(modulePrefix: string, actions) {
|
||||
if (isPlainObject(actions)) {
|
||||
Object.entries(actions).forEach(([key, action]) => {
|
||||
actions[key] = `${modulePrefix}:${action}`;
|
||||
});
|
||||
permissions = [...new Set([...permissions, ...Object.values<string>(actions)])];
|
||||
return actions;
|
||||
} else if (Array.isArray(actions)) {
|
||||
const permissionFormats = actions.map(action => `${modulePrefix}:${action}`);
|
||||
permissions = [...new Set([...permissions, ...permissionFormats])];
|
||||
|
||||
return actions.reduce((prev, action) => {
|
||||
prev[action.toUpperCase()] = `${modulePrefix}:${action}`;
|
||||
return prev;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取所有通过 definePermission 定义的权限 */
|
||||
export const getDefinePermissions = () => permissions;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue