本地oa-backup

This commit is contained in:
yixr 2024-10-16 11:30:51 +08:00
commit f3b09bbed4
328 changed files with 33240 additions and 0 deletions

94
.cz-config.js Normal file
View File

@ -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
};

46
.dockerignore Normal file
View File

@ -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/

40
.env Normal file
View File

@ -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

35
.env.development Normal file
View File

@ -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

38
.env.production Normal file
View File

@ -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

8
.eslintignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
dist/
config
build/
.eslintrc.js
package.json
tsconfig**.json
.vscode/

22
.eslintrc.cjs Normal file
View File

@ -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,
},
};

11
.gitattributes vendored Normal file
View File

@ -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

78
.gitignore vendored Normal file
View File

@ -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

4
.husky/commit-msg Normal file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run commitlint

7
.husky/pre-commit Normal file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
#推送之前运行eslint检查
npx lint-staged
##推送之前运行单元测试检查
#npm run test:unit

6
.npmrc Normal file
View File

@ -0,0 +1,6 @@
shamefully-hoist=true
strict-peer-dependencies=false
# 使用淘宝镜像源
registry = https://registry.npmmirror.com
# registry = https://registry.npmjs.org

11
.prettierignore Normal file
View File

@ -0,0 +1,11 @@
/dist/*
.local
.output.js
/node_modules/**
**/*.svg
**/*.sh
/public/*
test/**/*
/.vscode/*

11
.prettierrc.cjs Normal file
View File

@ -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
};

19
.versionrc.js Normal file
View File

@ -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 配置' },
],
};

17
.vscode/launch.json vendored Normal file
View File

@ -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
CHANGELOG.md Normal file
View File

55
Dockerfile Normal file
View File

@ -0,0 +1,55 @@
# 遇到网络问题可以配置镜像加速https://gist.github.com/y0ngb1n/7e8f16af3242c7815e7ca2f0833d3ea6
# FROM 表示设置要制作的镜像基于哪个镜像FROM指令必须是整个Dockerfile的第一个指令如果指定的镜像不存在默认会自动从Docker Hub上下载。
# 指定我们的基础镜像是nodelatest表示版本是最新, 如果要求空间极致可以选择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 /dataWORKDIR logsRUN 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

21
LICENSE Normal file
View File

@ -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.

110
README.md Normal file
View File

@ -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

24
commitlint.config.cjs Normal file
View File

@ -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大小写不做校验
},
};

9
deploy.sh Normal file
View File

@ -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

68
docker-compose.yml Normal file
View File

@ -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

22
ecosystem.config.js Normal file
View File

@ -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
}
}
]
};

36
eslint.config.js Normal file
View File

@ -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,
},
],
},
})

1090
init_data/sql/hxoa.sql Normal file

File diff suppressed because it is too large Load Diff

14
minio.js Normal file
View File

@ -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);
});

17
nest-cli.json Normal file
View File

@ -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
}
}]
}
}

176
package.json Normal file
View File

@ -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"
}
}

12671
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

0
public/upload/.gitkeep Normal file
View File

49
scripts/genEnvTypes.ts Normal file
View File

@ -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
}

26
scripts/resetScheduler.ts Normal file
View File

@ -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())
},
})

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

@ -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 {}

View File

@ -0,0 +1,4 @@
<p>你的验证码是:</p>
<h2>{{code}}</h2>
<p>该验证码 10 分钟内有效,请勿将验证码告知给他人!</p>
<font color='grey'>本邮件由系统自动发出,请勿回复。</font>

View File

@ -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>

View File

@ -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();
});

View File

@ -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;
}
}

View File

@ -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
}
}
]
}
})
);
}

View File

@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const BYPASS_KEY = '__bypass_key__';
/**
*
*/
export function Bypass() {
return SetMetadata(BYPASS_KEY, true);
}

View File

@ -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;
});

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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 pathurl params
*/
export const Uri = createParamDecorator((_, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<FastifyRequest>();
return request.routerPath;
});

View File

@ -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 格式不正确');
}
})
);
}

View File

@ -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);
};
}

View File

@ -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));
}

View File

@ -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 }
);
}

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
import { IsDefined, IsNotEmpty, IsNumber } from 'class-validator';
export class BatchDeleteDto {
@IsDefined()
@IsNotEmpty()
@IsNumber({}, { each: true })
ids: number[];
}

6
src/common/dto/id.dto.ts Normal file
View File

@ -0,0 +1,6 @@
import { IsNumber } from 'class-validator';
export class IdDto {
@IsNumber()
id: number;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 };

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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);
});
}
}

View File

@ -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));
}
}

View File

@ -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`}`);
})
);
}
}

View File

@ -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);
})
);
}
}

View File

@ -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);
})
);
}
}

View File

@ -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>[];
}

View File

@ -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;
}
}

20
src/config/app.config.ts Normal file
View File

@ -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>;

View File

@ -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;

37
src/config/index.ts Normal file
View File

@ -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
};

View File

@ -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>;

34
src/config/oss.config.ts Normal file
View File

@ -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>;

View File

@ -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>;

View File

@ -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>;

View File

@ -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>;

View File

@ -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:';

View File

@ -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
}

View File

@ -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:域标题已存在'
}

View File

@ -0,0 +1,4 @@
export enum EventBusEvents {
TokenExpired = 'token.expired',
SystemException = 'system.exception'
}

View File

@ -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 = '的副本';

View File

@ -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'
}

View File

@ -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;

62
src/global/env.ts Normal file
View File

@ -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`);
}
});
}

5
src/helper/catchError.ts Normal file
View File

@ -0,0 +1,5 @@
export function catchError() {
process.on('unhandledRejection', (reason, p) => {
console.log('Promise: ', p, 'Reason: ', reason);
});
}

View File

@ -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);
}
}

View File

@ -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;
}

19
src/helper/genRedisKey.ts Normal file
View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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
) {}
}

88
src/main.ts Normal file
View File

@ -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();

View File

@ -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> {}
}

View File

@ -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];

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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:
// }
}

View File

@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
import { ALLOW_ANON_KEY } from '../auth.constant';
/**
*
*/
export const AllowAnon = () => SetMetadata(ALLOW_ANON_KEY, true);

View File

@ -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;
});

View File

@ -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