chore: init
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# compiled output
|
||||
dist
|
||||
node_modules
|
||||
out
|
||||
documentation
|
||||
|
||||
# testing
|
||||
coverage
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# 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
|
||||
|
||||
# local env files
|
||||
.env
|
||||
|
||||
# log files
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
tmp
|
||||
public/static/
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# prisma
|
||||
prisma/client
|
||||
prisma/schemas
|
||||
prisma/zod
|
||||
prisma/dist
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${workspaceFolder}/tsconfig.json",
|
||||
"preLaunchTask": "tsc: build - tsconfig.json",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
// Enable the ESlint flat config support
|
||||
"eslint.experimental.useFlatConfig": true,
|
||||
|
||||
// Disable the default formatter, use eslint instead
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
|
||||
// Auto fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "always",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
|
||||
// Silent the stylistic rules in you IDE, but still auto fix them
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "style/*", "severity": "off" },
|
||||
{ "rule": "*-indent", "severity": "off" },
|
||||
{ "rule": "*-spacing", "severity": "off" },
|
||||
{ "rule": "*-spaces", "severity": "off" },
|
||||
{ "rule": "*-order", "severity": "off" },
|
||||
{ "rule": "*-dangle", "severity": "off" },
|
||||
{ "rule": "*-newline", "severity": "off" },
|
||||
{ "rule": "*quotes", "severity": "off" },
|
||||
{ "rule": "*semi", "severity": "off" }
|
||||
],
|
||||
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const { cpus } = require('node:os')
|
||||
|
||||
const cpuLen = cpus().length
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'm-shop',
|
||||
script: './dist/main.js',
|
||||
autorestart: true,
|
||||
exec_mode: 'cluster',
|
||||
watch: false,
|
||||
instances: cpuLen,
|
||||
max_memory_restart: '520M',
|
||||
args: '',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: process.env.APP_PORT,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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',
|
||||
'ts/prefer-ts-expect-error': '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,
|
||||
},
|
||||
],
|
||||
|
||||
'eslint-comments/no-unlimited-disable': 'off',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"builder": "swc",
|
||||
"typeCheck": true,
|
||||
"plugins": ["@nestjs/swagger"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.5",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"./*": {
|
||||
"types": "./src/*"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build --webpack",
|
||||
"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": "dotenv -e ../../.env.test vitest",
|
||||
"doc": "compodoc -p tsconfig.json -s"
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.5.0",
|
||||
"@casl/prisma": "^1.4.1",
|
||||
"@fastify/cookie": "^9.1.0",
|
||||
"@fastify/multipart": "^8.0.0",
|
||||
"@fastify/static": "^6.12.0",
|
||||
"@liaoliaots/nestjs-redis": "^9.0.5",
|
||||
"@nestjs-modules/mailer": "^1.9.1",
|
||||
"@nestjs/axios": "^3.0.1",
|
||||
"@nestjs/bull": "^10.0.1",
|
||||
"@nestjs/cache-manager": "^2.1.1",
|
||||
"@nestjs/common": "^10.3.1",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.3.1",
|
||||
"@nestjs/event-emitter": "^2.0.3",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.2",
|
||||
"@nestjs/platform-fastify": "^10.3.1",
|
||||
"@nestjs/platform-socket.io": "^10.3.1",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.1.15",
|
||||
"@nestjs/terminus": "^10.1.1",
|
||||
"@nestjs/throttler": "^5.0.1",
|
||||
"@nestjs/websockets": "^10.3.1",
|
||||
"@socket.io/redis-adapter": "^8.2.1",
|
||||
"@socket.io/redis-emitter": "^5.1.0",
|
||||
"@trpc/server": "10.45.1",
|
||||
"@types/lodash": "^4.14.201",
|
||||
"database": "workspace:*",
|
||||
"axios": "^1.6.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bull": "^4.11.4",
|
||||
"cache-manager": "^5.2.4",
|
||||
"cache-manager-ioredis-yet": "^1.2.2",
|
||||
"chalk": "^5.3.0",
|
||||
"cron": "^3.1.6",
|
||||
"cron-parser": "^4.9.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dotenv": "16.3.1",
|
||||
"dotenv-expand": "^10.0.0",
|
||||
"fastify": "^4.24.3",
|
||||
"fastify-multer": "^2.0.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"mysql": "^2.18.1",
|
||||
"nanoid": "^3.3.6",
|
||||
"nestjs-prisma": "^0.22.0",
|
||||
"nestjs-zod": "3.0.0",
|
||||
"nodemailer": "^6.9.7",
|
||||
"passport": "^0.6.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"prisma-extension-pagination": "^0.6.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^5.0.5",
|
||||
"rxjs": "^7.8.1",
|
||||
"snakecase-keys": "^5.5.0",
|
||||
"socket.io": "^4.7.2",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"systeminformation": "^5.21.16",
|
||||
"trpc-playground": "^1.0.4",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"unplugin-swc": "^1.4.4",
|
||||
"winston": "^3.11.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"zod": "3.22.4",
|
||||
"zx-cjs": "7.0.7-0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^2.1.0",
|
||||
"@compodoc/compodoc": "^1.1.22",
|
||||
"@nestjs/cli": "^10.2.1",
|
||||
"@nestjs/schematics": "^10.0.3",
|
||||
"@nestjs/testing": "^10.3.1",
|
||||
"@swc/cli": "^0.1.63",
|
||||
"@swc/core": "^1.3.102",
|
||||
"@types/cache-manager": "^4.0.5",
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/multer": "^1.4.10",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "^8.53.0",
|
||||
"lint-staged": "^15.1.0",
|
||||
"simple-git-hooks": "^2.9.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.5.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.11",
|
||||
"vite-tsconfig-paths": "^4.2.3",
|
||||
"vitest": "^1.1.3"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "pnpm lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "eslint --fix"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { BullModule } from '@nestjs/bull'
|
||||
import { Module } from '@nestjs/common'
|
||||
|
||||
import { ConfigModule } from '@nestjs/config'
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, HttpAdapterHost } from '@nestjs/core'
|
||||
|
||||
import * as config from '@server/config'
|
||||
|
||||
import { AllExceptionsFilter } from './common/filters/any-exception.filter'
|
||||
import { PrismaClientExceptionFilter } from './common/filters/prisma-client-exception.filter'
|
||||
import { IdempotenceInterceptor } from './common/interceptors/idempotence.interceptor'
|
||||
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor'
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor'
|
||||
import { ZodValidationPipe } from './common/pipes/zod-validation.pipe'
|
||||
import { AuthModule } from './modules/auth/auth.module'
|
||||
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'
|
||||
import { CaslModule } from './modules/casl/casl.module'
|
||||
import { FileModule } from './modules/file/file.module'
|
||||
import { UserModule } from './modules/user/user.module'
|
||||
import { CacheModule } from './shared/cache/cache.module'
|
||||
import { DatabaseModule } from './shared/database/database.module'
|
||||
import { HelperModule } from './shared/helper/helper.module'
|
||||
import { LoggerModule } from './shared/logger/logger.module'
|
||||
import { RedisModule } from './shared/redis/redis.module'
|
||||
import { TRPCModule } from './shared/trpc/trpc.module'
|
||||
import { SocketModule } from './socket/socket.module'
|
||||
import { TodoModule } from './modules/todo/todo.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['../../.env', '.env'],
|
||||
load: [...Object.values(config)],
|
||||
}),
|
||||
LoggerModule,
|
||||
CacheModule,
|
||||
DatabaseModule,
|
||||
RedisModule,
|
||||
BullModule,
|
||||
HelperModule,
|
||||
|
||||
AuthModule,
|
||||
UserModule,
|
||||
SocketModule,
|
||||
FileModule,
|
||||
|
||||
// biz
|
||||
|
||||
// end biz
|
||||
|
||||
TodoModule,
|
||||
|
||||
// wait module load
|
||||
CaslModule,
|
||||
TRPCModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useFactory: () => new TimeoutInterceptor(15 * 1000) },
|
||||
{ provide: APP_INTERCEPTOR, useClass: IdempotenceInterceptor },
|
||||
|
||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useFactory: ({ httpAdapter }: HttpAdapterHost) => {
|
||||
return new PrismaClientExceptionFilter(httpAdapter)
|
||||
},
|
||||
inject: [HttpAdapterHost],
|
||||
},
|
||||
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule { }
|
||||
@@ -0,0 +1,4 @@
|
||||
<p>你的验证码是:</p>
|
||||
<h2>{{code}}</h2>
|
||||
<p>该验证码 10 分钟内有效,请勿将验证码告知给他人!</p>
|
||||
<font color='grey'>本邮件由系统自动发出,请勿回复。</font>
|
||||
@@ -0,0 +1,5 @@
|
||||
<p>Your verification code is:</p>
|
||||
<h1>{{verificationCode}}</h1>
|
||||
<p>This code will expire in 10 minutes.</p>
|
||||
<font color='grey'>This email is sent automatically by the system, please do not
|
||||
reply.</font>
|
||||
@@ -0,0 +1,57 @@
|
||||
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,
|
||||
})
|
||||
export { app as fastifyApp }
|
||||
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
app.register(FastifyMultipart, {
|
||||
limits: {
|
||||
fields: 10, // Max number of non-file fields
|
||||
fileSize: 1024 * 1024 * 20, // limit size 20M
|
||||
files: 10, // Max number of file fields
|
||||
},
|
||||
})
|
||||
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
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
|
||||
|
||||
;(reply as any).setHeader = function (key, value) {
|
||||
return this.raw.setHeader(key, value)
|
||||
}
|
||||
;(reply as any).end = function () {
|
||||
this.raw.end()
|
||||
}
|
||||
;(request as any).res = reply
|
||||
|
||||
// forbidden php
|
||||
|
||||
const { url } = request
|
||||
|
||||
if (url.endsWith('.php')) {
|
||||
reply.raw.statusMessage
|
||||
= 'Eh. PHP is not support on this machine. Yep, I also think PHP is bestest programming language. But for me it is beyond my reach.'
|
||||
|
||||
return reply.code(418).send()
|
||||
}
|
||||
|
||||
// skip favicon request
|
||||
if (url.match(/favicon.ico$/) || url.match(/manifest.json$/))
|
||||
return reply.code(204).send()
|
||||
|
||||
done()
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { INestApplication } from '@nestjs/common'
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io'
|
||||
import { REDIS_PUBSUB } from '@server/shared/redis/redis.constant'
|
||||
import { createAdapter } from '@socket.io/redis-adapter'
|
||||
|
||||
export const RedisIoAdapterKey = 'm-shop-socket'
|
||||
|
||||
export class RedisIoAdapter extends IoAdapter {
|
||||
constructor(private readonly app: INestApplication) {
|
||||
super(app)
|
||||
}
|
||||
|
||||
createIOServer(port: number, options?: any) {
|
||||
const server = super.createIOServer(port, options)
|
||||
|
||||
const { pubClient, subClient } = this.app.get(REDIS_PUBSUB)
|
||||
|
||||
const redisAdapter = createAdapter(pubClient, subClient, {
|
||||
key: RedisIoAdapterKey,
|
||||
requestsTimeout: 10000,
|
||||
})
|
||||
server.adapter(redisAdapter)
|
||||
return server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SetMetadata } from '@nestjs/common'
|
||||
|
||||
export const BYPASS_KEY = '__bypass_key__'
|
||||
|
||||
/**
|
||||
* 当不需要转换成基础返回格式时添加该装饰器
|
||||
*/
|
||||
export function Bypass() {
|
||||
return SetMetadata(BYPASS_KEY, true)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import cluster from 'node:cluster'
|
||||
|
||||
import { Cron } from '@nestjs/schedule'
|
||||
|
||||
import { isMainProcess } from '@server/global/env'
|
||||
|
||||
export const CronOnce: typeof Cron = (...rest): MethodDecorator => {
|
||||
// If not in cluster mode, and PM2 main worker
|
||||
if (isMainProcess)
|
||||
// eslint-disable-next-line no-useless-call
|
||||
return Cron.call(null, ...rest)
|
||||
|
||||
if (cluster.isWorker && cluster.worker?.id === 1)
|
||||
// eslint-disable-next-line no-useless-call
|
||||
return Cron.call(null, ...rest)
|
||||
|
||||
const returnNothing: MethodDecorator = () => {}
|
||||
return returnNothing
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ExecutionContext } from '@nestjs/common'
|
||||
|
||||
import { createParamDecorator } from '@nestjs/common'
|
||||
import { getIp } from '@server/utils/ip.util'
|
||||
import type { FastifyRequest } from 'fastify'
|
||||
|
||||
/**
|
||||
* 快速获取 IP
|
||||
*/
|
||||
export const Ip = createParamDecorator((_, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>()
|
||||
return getIp(request)
|
||||
})
|
||||
|
||||
/**
|
||||
* 快速获取request path,并不包括url params
|
||||
*/
|
||||
export const Uri = createParamDecorator((_, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>()
|
||||
return request.routerPath
|
||||
})
|
||||
|
||||
/**
|
||||
* 快速获取 cookies
|
||||
*/
|
||||
export const Cookies = createParamDecorator((data: string, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<FastifyRequest>()
|
||||
return data ? request.cookies?.[data] : request.cookies
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SetMetadata } from '@nestjs/common'
|
||||
|
||||
import { IdempotenceOption } from '../interceptors/idempotence.interceptor'
|
||||
|
||||
export const HTTP_IDEMPOTENCE_KEY = '__idempotence_key__'
|
||||
export const HTTP_IDEMPOTENCE_OPTIONS = '__idempotence_options__'
|
||||
|
||||
/**
|
||||
* 幂等
|
||||
*/
|
||||
export function Idempotence(options?: IdempotenceOption): MethodDecorator {
|
||||
return function (target, key, descriptor: PropertyDescriptor) {
|
||||
SetMetadata(HTTP_IDEMPOTENCE_OPTIONS, options || {})(descriptor.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common'
|
||||
|
||||
export const OMIT_RESPONSE_PROTECT_KEY = '__omit_response_protect_keys__'
|
||||
/**
|
||||
* @description 过滤响应体中的字段
|
||||
*/
|
||||
export function ProtectKeys(keys: string[]): MethodDecorator {
|
||||
return function (target, key, descriptor: PropertyDescriptor) {
|
||||
SetMetadata(OMIT_RESPONSE_PROTECT_KEY, keys)(descriptor.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { applyDecorators } from '@nestjs/common'
|
||||
import { ApiSecurity } from '@nestjs/swagger'
|
||||
|
||||
export const API_SECURITY_AUTH = 'auth'
|
||||
|
||||
/**
|
||||
* like to @ApiSecurity('auth')
|
||||
*/
|
||||
export function ApiSecurityAuth(): ClassDecorator & MethodDecorator {
|
||||
return applyDecorators(ApiSecurity(API_SECURITY_AUTH))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
const BatchDeleteSchema = z.object({
|
||||
ids: z.array(z.string()),
|
||||
})
|
||||
|
||||
export class BatchDeleteDto extends createZodDto(BatchDeleteSchema) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const SnowflakeIdSchema = z.string().regex(/^\d{16,19}$/)
|
||||
|
||||
export class IdDto extends createZodDto(
|
||||
z.object({
|
||||
id: SnowflakeIdSchema,
|
||||
}),
|
||||
) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const ImageSchema = z.object({
|
||||
src: z.string(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
})
|
||||
|
||||
export const ImagesSchema = z.array(ImageSchema)
|
||||
|
||||
export class ImagesDto extends createZodDto(ImagesSchema) {}
|
||||
|
||||
export type Image = z.infer<typeof ImageSchema>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { SnowflakeIdSchema } from './id.dto'
|
||||
|
||||
export const DEFAULT_LIMIT = 10
|
||||
|
||||
export const basePagerSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(50).optional().default(DEFAULT_LIMIT),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
sortBy: z.string().default('createdAt'),
|
||||
sortOrder: z.string()
|
||||
.or(z.enum(['asc', 'desc']))
|
||||
.optional(),
|
||||
|
||||
cursor: SnowflakeIdSchema
|
||||
.or(z.null())
|
||||
.or(z.boolean())
|
||||
.transform(val => val === null || val === false || val === true ? '' : val)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export class PagerDto extends createZodDto(basePagerSchema) { }
|
||||
@@ -0,0 +1,43 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common'
|
||||
|
||||
import { ErrorCode, ErrorCodeEnum } from '@server/constants/error-code.constant'
|
||||
|
||||
/**
|
||||
* 业务异常抛出
|
||||
*/
|
||||
export class BizException extends HttpException {
|
||||
public bizCode: ErrorCodeEnum
|
||||
|
||||
constructor(message: string)
|
||||
constructor(code: ErrorCodeEnum)
|
||||
constructor(arg: any) {
|
||||
if (typeof arg == 'string') {
|
||||
const message = arg
|
||||
super(
|
||||
HttpException.createBody({
|
||||
code: ErrorCodeEnum.Default,
|
||||
message,
|
||||
}),
|
||||
HttpStatus.OK,
|
||||
)
|
||||
this.bizCode = ErrorCodeEnum.Default
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const code = arg as ErrorCodeEnum
|
||||
const message = ErrorCode[code]
|
||||
|
||||
super(
|
||||
HttpException.createBody({
|
||||
code,
|
||||
message,
|
||||
}),
|
||||
HttpStatus.OK,
|
||||
)
|
||||
|
||||
this.bizCode = code
|
||||
}
|
||||
}
|
||||
|
||||
// export { BizException as BusinessException }
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NotFoundException } from '@nestjs/common'
|
||||
import { sample } from 'lodash'
|
||||
|
||||
export const NotFoundMessage = ['404, Not Found']
|
||||
|
||||
export class CannotFindException extends NotFoundException {
|
||||
constructor() {
|
||||
super(sample(NotFoundMessage))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common'
|
||||
|
||||
import { BizException } from '@server/common/exceptions/biz.exception'
|
||||
import { ErrorCode, ErrorCodeEnum } from '@server/constants/error-code.constant'
|
||||
|
||||
import { isDev } from '@server/global/env'
|
||||
import { FastifyReply, FastifyRequest } from 'fastify'
|
||||
|
||||
import { ResOp } from '../model/response.model'
|
||||
|
||||
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>()
|
||||
|
||||
if (request.method === 'OPTIONS')
|
||||
return response.status(HttpStatus.OK).send()
|
||||
|
||||
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 BizException)
|
||||
) {
|
||||
Logger.error(exception, undefined, 'Catch')
|
||||
|
||||
// 生产环境下隐藏错误信息
|
||||
if (!isDev)
|
||||
message = ErrorCode[ErrorCodeEnum.ServerError]
|
||||
}
|
||||
else {
|
||||
this.logger.warn(
|
||||
`错误信息:(${status}) ${message} Path: ${decodeURI(url)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const errorCode: number
|
||||
= exception instanceof BizException ? exception.bizCode : status
|
||||
|
||||
// 返回基础响应结果
|
||||
const resBody = new ResOp({
|
||||
code: errorCode,
|
||||
message,
|
||||
ok: false,
|
||||
})
|
||||
|
||||
response.status(status).type('application/json').send(resBody)
|
||||
}
|
||||
|
||||
registerCatchAllExceptionsHook() {
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('unhandledRejection: ', reason)
|
||||
})
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('uncaughtException: ', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// @copy https://github.com/notiz-dev/nestjs-prisma/blob/main/lib/prisma-client-exception.filter.ts
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ContextType,
|
||||
HttpException,
|
||||
HttpServer,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common'
|
||||
import { APP_FILTER, BaseExceptionFilter, HttpAdapterHost } from '@nestjs/core'
|
||||
import { Prisma } from 'database'
|
||||
|
||||
export declare type GqlContextType = 'graphql' | ContextType
|
||||
|
||||
export interface ErrorCodesStatusMapping {
|
||||
[key: string]: number
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link PrismaClientExceptionFilter} catches {@link Prisma.PrismaClientKnownRequestError} exceptions.
|
||||
*/
|
||||
@Catch(Prisma?.PrismaClientKnownRequestError)
|
||||
export class PrismaClientExceptionFilter extends BaseExceptionFilter {
|
||||
/**
|
||||
* default error codes mapping
|
||||
*
|
||||
* Error codes definition for Prisma Client (Query Engine)
|
||||
* @see https://www.prisma.io/docs/reference/api-reference/error-reference#prisma-client-query-engine
|
||||
*/
|
||||
private errorCodesStatusMapping: ErrorCodesStatusMapping = {
|
||||
P2000: HttpStatus.BAD_REQUEST,
|
||||
P2002: HttpStatus.CONFLICT,
|
||||
P2025: HttpStatus.NOT_FOUND,
|
||||
}
|
||||
|
||||
/**
|
||||
* @param applicationRef
|
||||
* @param errorCodesStatusMapping
|
||||
*/
|
||||
constructor(
|
||||
applicationRef?: HttpServer,
|
||||
errorCodesStatusMapping: ErrorCodesStatusMapping | null = null,
|
||||
) {
|
||||
super(applicationRef)
|
||||
|
||||
// use custom error codes mapping (overwrite)
|
||||
//
|
||||
// @example:
|
||||
//
|
||||
// const { httpAdapter } = app.get(HttpAdapterHost);
|
||||
// app.useGlobalFilters(new PrismaClientExceptionFilter(httpAdapter, {
|
||||
// P2022: HttpStatus.BAD_REQUEST,
|
||||
// }));
|
||||
//
|
||||
if (errorCodesStatusMapping) {
|
||||
this.errorCodesStatusMapping = Object.assign(
|
||||
this.errorCodesStatusMapping,
|
||||
errorCodesStatusMapping,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param exception
|
||||
* @param host
|
||||
* @returns
|
||||
*/
|
||||
catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
|
||||
return this.catchClientKnownRequestError(exception, host)
|
||||
}
|
||||
|
||||
private catchClientKnownRequestError(
|
||||
exception: Prisma.PrismaClientKnownRequestError,
|
||||
host: ArgumentsHost,
|
||||
) {
|
||||
const statusCode = this.errorCodesStatusMapping[exception.code]
|
||||
const message = `[${exception.code}]: ${this.exceptionShortMessage(
|
||||
exception.message,
|
||||
)}`
|
||||
|
||||
if (host.getType() === 'http') {
|
||||
if (!Object.keys(this.errorCodesStatusMapping).includes(exception.code))
|
||||
return super.catch(exception, host)
|
||||
|
||||
return super.catch(
|
||||
new HttpException({ statusCode, message }, statusCode),
|
||||
host,
|
||||
)
|
||||
}
|
||||
else if (host.getType<GqlContextType>() === 'graphql') {
|
||||
// for graphql requests
|
||||
if (!Object.keys(this.errorCodesStatusMapping).includes(exception.code))
|
||||
return exception
|
||||
|
||||
return new HttpException({ statusCode, message }, statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
private exceptionShortMessage(message: string): string {
|
||||
const shortMessage = message.substring(message.indexOf('→'))
|
||||
return shortMessage
|
||||
.substring(shortMessage.indexOf('\n'))
|
||||
.replace(/\n/g, '')
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function providePrismaClientExceptionFilter(
|
||||
errorCodesStatusMapping?: ErrorCodesStatusMapping,
|
||||
) {
|
||||
return {
|
||||
provide: APP_FILTER,
|
||||
useFactory: ({ httpAdapter }: HttpAdapterHost) => {
|
||||
return new PrismaClientExceptionFilter(
|
||||
httpAdapter,
|
||||
errorCodesStatusMapping,
|
||||
)
|
||||
},
|
||||
inject: [HttpAdapterHost],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
} from '@nestjs/common'
|
||||
import { BaseWsExceptionFilter } from '@nestjs/websockets'
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter extends BaseWsExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
super.catch(exception, host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common'
|
||||
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
|
||||
import { CacheService } from '@server/shared/cache/cache.service'
|
||||
import { getIp } from '@server/utils/ip.util'
|
||||
import { getRedisKey } from '@server/utils/redis.util'
|
||||
|
||||
import { hashString } from '@server/utils/tool.util'
|
||||
import type { FastifyRequest } from 'fastify'
|
||||
import { catchError, tap } from 'rxjs'
|
||||
|
||||
import { HTTP_IDEMPOTENCE_KEY, HTTP_IDEMPOTENCE_OPTIONS } from '../decorators/idempotence.decorator'
|
||||
|
||||
const IdempotenceHeaderKey = 'x-idempotence'
|
||||
|
||||
export interface IdempotenceOption {
|
||||
errorMessage?: string
|
||||
pendingMessage?: string
|
||||
|
||||
/**
|
||||
* 如果重复请求的话,手动处理异常
|
||||
*/
|
||||
handler?: (req: FastifyRequest) => any
|
||||
|
||||
/**
|
||||
* 记录重复请求的时间
|
||||
* @default 60
|
||||
*/
|
||||
expired?: number
|
||||
|
||||
/**
|
||||
* 如果 header 没有幂等 key,根据 request 生成 key,如何生成这个 key 的方法
|
||||
*/
|
||||
generateKey?: (req: FastifyRequest) => string
|
||||
|
||||
/**
|
||||
* 仅读取 header 的 key,不自动生成
|
||||
* @default false
|
||||
*/
|
||||
disableGenerateKey?: boolean
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IdempotenceInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>()
|
||||
|
||||
// skip Get 请求
|
||||
if (request.method.toUpperCase() === 'GET')
|
||||
return next.handle()
|
||||
|
||||
const handler = context.getHandler()
|
||||
const options: IdempotenceOption | undefined = this.reflector.get(
|
||||
HTTP_IDEMPOTENCE_OPTIONS,
|
||||
handler,
|
||||
)
|
||||
|
||||
if (!options)
|
||||
return next.handle()
|
||||
|
||||
const {
|
||||
errorMessage = '相同请求成功后在 60 秒内只能发送一次',
|
||||
pendingMessage = '相同请求正在处理中...',
|
||||
handler: errorHandler,
|
||||
expired = 60,
|
||||
disableGenerateKey = false,
|
||||
} = options
|
||||
const redis = this.cacheService.getClient()
|
||||
|
||||
const idempotence = request.headers[IdempotenceHeaderKey] as string
|
||||
const key = disableGenerateKey
|
||||
? undefined
|
||||
: options.generateKey
|
||||
? options.generateKey(request)
|
||||
: this.generateKey(request)
|
||||
|
||||
const idempotenceKey
|
||||
= !!(idempotence || key) && getRedisKey(`idempotence:${idempotence || key}`)
|
||||
|
||||
SetMetadata(HTTP_IDEMPOTENCE_KEY, idempotenceKey)(handler)
|
||||
|
||||
if (idempotenceKey) {
|
||||
const resultValue: '0' | '1' | null = (await redis.get(
|
||||
idempotenceKey,
|
||||
)) as any
|
||||
if (resultValue !== null) {
|
||||
if (errorHandler)
|
||||
return await errorHandler(request)
|
||||
|
||||
const message = {
|
||||
1: errorMessage,
|
||||
0: pendingMessage,
|
||||
}[resultValue]
|
||||
throw new ConflictException(message)
|
||||
}
|
||||
else {
|
||||
await redis.set(idempotenceKey, '0', 'EX', expired)
|
||||
}
|
||||
}
|
||||
return next.handle().pipe(
|
||||
tap(async () => {
|
||||
idempotenceKey && (await redis.set(idempotenceKey, '1', 'KEEPTTL'))
|
||||
}),
|
||||
catchError(async (err) => {
|
||||
if (idempotenceKey)
|
||||
await redis.del(idempotenceKey)
|
||||
|
||||
throw err
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private generateKey(req: FastifyRequest) {
|
||||
const { body, params, query = {}, headers, url } = req
|
||||
|
||||
const obj = { body, url, params, query } as any
|
||||
|
||||
const uuid = headers['x-uuid']
|
||||
if (uuid) {
|
||||
obj.uuid = uuid
|
||||
}
|
||||
else {
|
||||
const ua = headers['user-agent']
|
||||
const ip = getIp(req)
|
||||
|
||||
if (!ua && !ip)
|
||||
return undefined
|
||||
|
||||
Object.assign(obj, { ua, ip })
|
||||
}
|
||||
|
||||
return hashString(JSON.stringify(obj))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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}`
|
||||
this.logger.debug(`+++ 请求:${content}`)
|
||||
const now = Date.now()
|
||||
|
||||
return call$.pipe(
|
||||
tap(() =>
|
||||
this.logger.debug(`--- 响应:${content}${` +${Date.now() - now}ms`}`),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
RequestTimeoutException,
|
||||
} from '@nestjs/common'
|
||||
import { Observable, TimeoutError, throwError } from 'rxjs'
|
||||
import { catchError, timeout } from 'rxjs/operators'
|
||||
|
||||
@Injectable()
|
||||
export class TimeoutInterceptor implements NestInterceptor {
|
||||
constructor(private readonly time: number = 10000) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
timeout(this.time),
|
||||
catchError((err) => {
|
||||
if (err instanceof TimeoutError)
|
||||
return throwError(new RequestTimeoutException('请求超时'))
|
||||
|
||||
return throwError(err)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
import { ResOp } from '@server/common/model/response.model'
|
||||
import { isObjectLike, omit } from 'lodash'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import snakecaseKeys from 'snakecase-keys'
|
||||
|
||||
import { BYPASS_KEY } from '../decorators/bypass.decorator'
|
||||
import { OMIT_RESPONSE_PROTECT_KEY } from '../decorators/protect-keys.decorator'
|
||||
|
||||
/**
|
||||
* 统一处理返回接口结果
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor implements NestInterceptor {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler<any>,
|
||||
): Observable<any> {
|
||||
if (!context.switchToHttp().getRequest())
|
||||
return next.handle()
|
||||
|
||||
const handler = context.getHandler()
|
||||
|
||||
const bypass = this.reflector.get<boolean>(
|
||||
BYPASS_KEY,
|
||||
handler,
|
||||
)
|
||||
|
||||
if (bypass)
|
||||
return next.handle()
|
||||
|
||||
const omitKeys = this.reflector.getAllAndOverride(
|
||||
OMIT_RESPONSE_PROTECT_KEY,
|
||||
[handler, context.getClass()],
|
||||
)
|
||||
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
// if (typeof data === 'undefined') {
|
||||
// context.switchToHttp().getResponse().status(HttpStatus.NO_CONTENT);
|
||||
// return data;
|
||||
// }
|
||||
|
||||
if (Array.isArray(omitKeys))
|
||||
data = omit(data, omitKeys)
|
||||
|
||||
// data = this.serialize(data)
|
||||
|
||||
return new ResOp({
|
||||
data,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private serialize(obj: any) {
|
||||
if (!isObjectLike(obj))
|
||||
return obj
|
||||
|
||||
return snakecaseKeys(obj, { deep: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ApiProperty } from '@nestjs/swagger'
|
||||
|
||||
import {
|
||||
RESPONSE_SUCCESS_CODE,
|
||||
RESPONSE_SUCCESS_MSG,
|
||||
} from '@server/constants/response.constant'
|
||||
|
||||
export interface IBaseResponse<T = any> {
|
||||
ok?: boolean
|
||||
code?: number
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export class ResOp<T = any> {
|
||||
@ApiProperty({ type: 'boolean', default: true })
|
||||
ok: boolean
|
||||
|
||||
@ApiProperty({ type: 'number', default: RESPONSE_SUCCESS_CODE })
|
||||
code: number
|
||||
|
||||
@ApiProperty({ type: 'string', default: RESPONSE_SUCCESS_MSG })
|
||||
message: string
|
||||
|
||||
@ApiProperty({ type: 'object' })
|
||||
data?: T
|
||||
|
||||
constructor({
|
||||
code,
|
||||
message,
|
||||
ok,
|
||||
data,
|
||||
}: IBaseResponse<T>) {
|
||||
this.code = code ?? RESPONSE_SUCCESS_CODE
|
||||
this.message = message ?? RESPONSE_SUCCESS_MSG
|
||||
this.ok = ok ?? true
|
||||
|
||||
if (data)
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
ArgumentMetadata,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
PipeTransform,
|
||||
} from '@nestjs/common'
|
||||
|
||||
@Injectable()
|
||||
export class ParseIntPipe implements PipeTransform<string, number> {
|
||||
transform(value: string, metadata: ArgumentMetadata): number {
|
||||
const val = Number.parseInt(value, 10)
|
||||
|
||||
if (Number.isNaN(val))
|
||||
throw new BadRequestException('id validation failed')
|
||||
|
||||
return val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { UnprocessableEntityException } from '@nestjs/common'
|
||||
import { createZodValidationPipe } from 'nestjs-zod'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
export const ZodValidationPipe = createZodValidationPipe({
|
||||
createValidationException: (error: ZodError) => {
|
||||
const firstError = error.errors[0]
|
||||
|
||||
if ('expected' in firstError) {
|
||||
let formattedErrorMessage: string = firstError.code
|
||||
if (firstError.path.length !== 0)
|
||||
formattedErrorMessage = `Path \`${firstError.path}\` should be \`${firstError.expected}\`, but got \`${firstError.received}\``
|
||||
|
||||
return new UnprocessableEntityException(formattedErrorMessage)
|
||||
}
|
||||
|
||||
return new UnprocessableEntityException(
|
||||
`\`${firstError.path}\`: ${firstError.message}`,
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type ConfigType, registerAs } from '@nestjs/config'
|
||||
|
||||
import { env, envNumber } from '@server/global/env'
|
||||
|
||||
export const AppConfig = registerAs('app', () => ({
|
||||
name: env('APP_NAME'),
|
||||
port: envNumber('APP_PORT', 3000),
|
||||
baseUrl: env('APP_BASE_URL'),
|
||||
globalPrefix: env('GLOBAL_PREFIX', 'api'),
|
||||
locale: env('APP_LOCALE', 'zh-CN'),
|
||||
|
||||
logger: {
|
||||
level: env('LOGGER_LEVEL'),
|
||||
maxFiles: envNumber('LOGGER_MAX_FILES'),
|
||||
},
|
||||
}))
|
||||
|
||||
export type IAppConfig = ConfigType<typeof AppConfig>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type ConfigType, registerAs } from '@nestjs/config'
|
||||
|
||||
import { env } from '@server/global/env'
|
||||
|
||||
const DATABASE = {
|
||||
url: env('DATABASE_URL'),
|
||||
}
|
||||
|
||||
export const DatabaseConfig = registerAs('database', () => DATABASE)
|
||||
|
||||
export type IDatabaseConfig = ConfigType<typeof DatabaseConfig>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ConfigType, registerAs } from '@nestjs/config'
|
||||
|
||||
import { env } from '@server/global/env'
|
||||
|
||||
export const GoogleConfig = registerAs('google', () => ({
|
||||
clientId: env('GOOGLE_CLIENT_ID'),
|
||||
secret: env('GOOGLE_SECRET'),
|
||||
}))
|
||||
|
||||
export type IGoogleConfig = ConfigType<typeof GoogleConfig>
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './app.config'
|
||||
export * from './redis.config'
|
||||
export * from './database.config'
|
||||
export * from './security.config'
|
||||
export * from './mailer.config'
|
||||
export * from './sms.config'
|
||||
export * from './google.config'
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type ConfigType, registerAs } from '@nestjs/config'
|
||||
|
||||
import { env, envNumber } from '@server/global/env'
|
||||
|
||||
export const MailerConfig = registerAs('mailer', () => ({
|
||||
host: env('SMTP_HOST'),
|
||||
port: envNumber('SMTP_PORT'),
|
||||
ignoreTLS: true,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: env('SMTP_USER'),
|
||||
pass: env('SMTP_PASS'),
|
||||
},
|
||||
}))
|
||||
|
||||
export type IMailerConfig = ConfigType<typeof MailerConfig>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { type ConfigType, registerAs } from '@nestjs/config'
|
||||
|
||||
import { env, envNumber } from '@server/global/env'
|
||||
|
||||
export const RedisConfig = registerAs('redis', () => ({
|
||||
host: env('REDIS_HOST', '127.0.0.1'),
|
||||
port: envNumber('REDIS_PORT', 6379),
|
||||
password: env('REDIS_PASSWORD'),
|
||||
db: envNumber('REDIS_DB'),
|
||||
ttl: null,
|
||||
httpCacheTTL: 15,
|
||||
max: 120,
|
||||
}))
|
||||
|
||||
export type IRedisConfig = ConfigType<typeof RedisConfig>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type ConfigType, registerAs } from '@nestjs/config'
|
||||
|
||||
import { env, envNumber } from '@server/global/env'
|
||||
|
||||
export const SecurityConfig = registerAs('security', () => ({
|
||||
jwtSecret: env('JWT_SECRET'),
|
||||
jwtExprire: env('JWT_EXPIRE'),
|
||||
refreshSecret: env('REFRESH_TOKEN_SECRET'),
|
||||
refreshExpire: envNumber('REFRESH_TOKEN_EXPIRE'),
|
||||
}))
|
||||
|
||||
export type ISecurityConfig = ConfigType<typeof SecurityConfig>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type ConfigType, registerAs } from '@nestjs/config'
|
||||
|
||||
import { env } from '@server/global/env'
|
||||
|
||||
export const SmsConfig = registerAs('sms', () => ({
|
||||
sign: env('SMS_SING', 'xxx'),
|
||||
region: env('SMS_REGION', 'ap-guangzhou'),
|
||||
appid: env('SMS_APPID', 'xxx'),
|
||||
secretId: env('SMS_SECRET_ID', 'your-secret-id'),
|
||||
secretKey: env('SMS_SECRET_KEY', 'your-secret-key'),
|
||||
}))
|
||||
|
||||
export type ISmsConfig = ConfigType<typeof SmsConfig>
|
||||
@@ -0,0 +1,12 @@
|
||||
export enum RedisKeys {
|
||||
JWTStore = 'jwt_store',
|
||||
CaptchaStore = 'captcha_store',
|
||||
CacheGet = 'cache_get',
|
||||
|
||||
AccessIp = 'access_ip',
|
||||
Like = 'like',
|
||||
View = 'view',
|
||||
|
||||
Order = 'order',
|
||||
}
|
||||
export const API_CACHE_PREFIX = 'api-cache:'
|
||||
@@ -0,0 +1,47 @@
|
||||
export enum ErrorCodeEnum {
|
||||
Default = 1,
|
||||
ServerError = 500,
|
||||
|
||||
JWTInvalid = 1103,
|
||||
AuthFail = 1000,
|
||||
NoPermission = 1001,
|
||||
ResourceNotFound = 1102,
|
||||
|
||||
RequestTooFast = 1200,
|
||||
|
||||
UserNotFound = 2001,
|
||||
UserExist = 2002,
|
||||
PasswordMismatch = 2003,
|
||||
|
||||
VerificationCodeError = 3000,
|
||||
VerificationCodeSendFail = 3001,
|
||||
MaximumFiveVerificationCodesPerDay = 3002,
|
||||
|
||||
NoteNotFound = 4001,
|
||||
CommentNotFound = 4002,
|
||||
|
||||
NoUserFollowing = 5001,
|
||||
}
|
||||
|
||||
export const ErrorCode: Record<ErrorCodeEnum, string> = {
|
||||
[ErrorCodeEnum.Default]: '未知错误',
|
||||
[ErrorCodeEnum.ServerError]: '服务器错误, 请稍后再试',
|
||||
|
||||
[ErrorCodeEnum.JWTInvalid]: 'JWT无效',
|
||||
[ErrorCodeEnum.AuthFail]: '认证失败',
|
||||
[ErrorCodeEnum.NoPermission]: '没有权限',
|
||||
[ErrorCodeEnum.ResourceNotFound]: '资源不存在',
|
||||
[ErrorCodeEnum.RequestTooFast]: '请求过于频繁',
|
||||
|
||||
[ErrorCodeEnum.UserNotFound]: '用户不存在',
|
||||
[ErrorCodeEnum.UserExist]: '用户已存在',
|
||||
[ErrorCodeEnum.PasswordMismatch]: '密码错误',
|
||||
|
||||
[ErrorCodeEnum.VerificationCodeError]: '验证码无效',
|
||||
[ErrorCodeEnum.VerificationCodeSendFail]: '验证码发送失败',
|
||||
[ErrorCodeEnum.MaximumFiveVerificationCodesPerDay]: '一天最多发送5个验证码',
|
||||
|
||||
[ErrorCodeEnum.NoteNotFound]: '笔记不存在',
|
||||
[ErrorCodeEnum.CommentNotFound]: '评论不存在',
|
||||
[ErrorCodeEnum.NoUserFollowing]: '您还未关注任何用户',
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum EventBusEvents {
|
||||
EmailInit = 'email.init',
|
||||
TokenExpired = 'token.expired',
|
||||
SystemException = 'system.exception',
|
||||
ConfigChanged = 'config.changed',
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const alphabet = `1234567890abcdefghijklmnopqrstuvwxyz`
|
||||
@@ -0,0 +1,26 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { cwd, isDev } from '@server/global/env'
|
||||
|
||||
export const HOME = homedir()
|
||||
|
||||
export const TEMP_DIR = isDev ? join(cwd, './public') : '/public/demo'
|
||||
|
||||
export const DATA_DIR = isDev ? join(cwd, './public') : join(HOME, '.demo')
|
||||
|
||||
export const USER_ASSET_DIR = join(DATA_DIR, 'assets')
|
||||
export const LOG_DIR = join(DATA_DIR, 'log')
|
||||
|
||||
export const STATIC_FILE_DIR = join(DATA_DIR, 'static')
|
||||
|
||||
export const BACKUP_DIR = !isDev
|
||||
? join(DATA_DIR, 'backup')
|
||||
: join(TEMP_DIR, 'backup')
|
||||
|
||||
// 生产环境直接打包到 目录的 admin 下
|
||||
export const LOCAL_ADMIN_ASSET_PATH = isDev
|
||||
? join(DATA_DIR, 'admin')
|
||||
: join(cwd, './admin')
|
||||
|
||||
export const NODE_REQUIRE_PATH = join(DATA_DIR, 'node_modules')
|
||||
@@ -0,0 +1,15 @@
|
||||
export const RESPONSE_SUCCESS_CODE = 0
|
||||
|
||||
export const RESPONSE_SUCCESS_MSG = 'success'
|
||||
|
||||
/**
|
||||
* @description: contentType
|
||||
*/
|
||||
export enum ContentTypeEnum {
|
||||
// json
|
||||
JSON = 'application/json;charset=UTF-8',
|
||||
// form-data qs
|
||||
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
// form-data upload
|
||||
FORM_DATA = 'multipart/form-data;charset=UTF-8',
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SYS_USER_INITPASSWORD = 'sys_user_initPassword'
|
||||
export const SYS_API_TOKEN = 'sys_api_token'
|
||||
@@ -0,0 +1,67 @@
|
||||
import cluster from 'node:cluster'
|
||||
|
||||
import { config } from 'dotenv'
|
||||
import dotenvExpand from 'dotenv-expand'
|
||||
|
||||
dotenvExpand.expand(config())
|
||||
|
||||
export const isMainCluster
|
||||
= process.env.NODE_APP_INSTANCE && Number.parseInt(process.env.NODE_APP_INSTANCE) === 0
|
||||
export const isMainProcess = cluster.isPrimary || isMainCluster
|
||||
|
||||
export const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
export const isTest = !!process.env.TEST
|
||||
export const cwd = process.cwd()
|
||||
|
||||
/**
|
||||
* 基础类型接口
|
||||
*/
|
||||
export type BaseType = boolean | number | string | undefined | null
|
||||
|
||||
/**
|
||||
* 格式化环境变量
|
||||
* @param key 环境变量的键值
|
||||
* @param defaultValue 默认值
|
||||
* @param callback 格式化函数
|
||||
*/
|
||||
function fromatValue<T extends BaseType = string>(key: string, defaultValue: T, callback?: (value: string) => T): T {
|
||||
const value: string | undefined = process.env[key]
|
||||
if (typeof value === 'undefined')
|
||||
return defaultValue
|
||||
|
||||
if (!callback)
|
||||
return value as unknown as T
|
||||
|
||||
return callback(value)
|
||||
}
|
||||
|
||||
export function env(key: string, defaultValue: string = '') {
|
||||
return fromatValue(key, defaultValue)
|
||||
}
|
||||
|
||||
export function envString(key: string, defaultValue: string = '') {
|
||||
return fromatValue(key, defaultValue)
|
||||
}
|
||||
|
||||
export function envNumber(key: string, defaultValue: number = 0) {
|
||||
return fromatValue(key, defaultValue, (value) => {
|
||||
try {
|
||||
return Number(value)
|
||||
}
|
||||
catch {
|
||||
throw new Error(`${key} environment variable is not a number`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function envBoolean(key: string, defaultValue: boolean = false) {
|
||||
return fromatValue(key, defaultValue, (value) => {
|
||||
try {
|
||||
return Boolean(JSON.parse(value))
|
||||
}
|
||||
catch {
|
||||
throw new Error(`${key} environment variable is not a boolean`)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function catchError() {
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Promise: ', p, 'Reason: ', reason)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Type } from '@nestjs/common'
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common'
|
||||
|
||||
import { IdDto } from '@server/common/dto/id.dto'
|
||||
import { PagerDto } from '@server/common/dto/pager.dto'
|
||||
import { BizException } from '@server/common/exceptions/biz.exception'
|
||||
import { ErrorCodeEnum } from '@server/constants/error-code.constant'
|
||||
|
||||
import { AllModelNames, ExtendedPrismaClient, InjectPrismaClient } from '@server/shared/database/prisma.extension'
|
||||
import { resourceNotFoundWrapper } from '@server/utils/prisma.util'
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import pluralize from 'pluralize'
|
||||
import { z } from 'zod'
|
||||
|
||||
export function BaseCrudFactory<
|
||||
M extends AllModelNames,
|
||||
CDto extends z.AnyZodObject = z.AnyZodObject,
|
||||
UDto extends z.AnyZodObject = z.AnyZodObject,
|
||||
>({ modelName, createSchema, updateSchema, apiPrefix }: {
|
||||
modelName: M
|
||||
createSchema: CDto
|
||||
updateSchema?: UDto
|
||||
apiPrefix?: string
|
||||
}): Type<any> {
|
||||
const prefix = modelName.toLowerCase()
|
||||
const pluralizeName = pluralize(prefix) as string
|
||||
|
||||
class UpdateDto extends createZodDto(updateSchema || createSchema.partial()) {}
|
||||
|
||||
class CreateDto extends createZodDto(createSchema) {}
|
||||
|
||||
@Controller(apiPrefix || pluralizeName)
|
||||
class BaseController {
|
||||
@InjectPrismaClient()
|
||||
private readonly prisma: ExtendedPrismaClient
|
||||
|
||||
private get db() {
|
||||
return this.prisma[modelName]
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(@Query() pager: PagerDto) {
|
||||
const { page, limit } = pager
|
||||
return await this.db.paginate().withPages({
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
@Get('all')
|
||||
async getAll() {
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return await this.db.findMany()
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async get(@Param() { id }: IdDto) {
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return await this.db.findUniqueOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
.catch(
|
||||
resourceNotFoundWrapper(
|
||||
new BizException(ErrorCodeEnum.ResourceNotFound),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() body: CreateDto) {
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return await this.db.create({
|
||||
data: body,
|
||||
})
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@Param() { id }: IdDto, @Body() body: UpdateDto) {
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return await this.db.update({
|
||||
where: { id },
|
||||
data: body,
|
||||
})
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async patch(@Param() { id }: IdDto, @Body() body: UpdateDto) {
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-ignore
|
||||
await this.db.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...body,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param() { id }: IdDto) {
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-ignore
|
||||
await this.db.delete({ where: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
return BaseController
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FastifyRequest } from 'fastify'
|
||||
|
||||
export function getRequestItemId(request?: FastifyRequest): string {
|
||||
const { params = {}, body = {}, query = {} } = (request ?? {}) as any
|
||||
const id = params.id ?? body.id ?? query.id
|
||||
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import cluster from 'node:cluster'
|
||||
|
||||
import {
|
||||
Logger,
|
||||
} from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { NestFactory } from '@nestjs/core'
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify'
|
||||
|
||||
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 { IAppConfig } from './config'
|
||||
import { DATA_DIR } from './constants/path.constant'
|
||||
import { isDev, isMainProcess } from './global/env'
|
||||
import { setupSwagger } from './setup-swagger'
|
||||
import { MyLogger } from './shared/logger/logger.service'
|
||||
import { TRPCService } from './shared/trpc/trpc.service'
|
||||
|
||||
declare const module: any
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
fastifyApp,
|
||||
{
|
||||
bufferLogs: true,
|
||||
snapshot: true,
|
||||
},
|
||||
)
|
||||
|
||||
const configService = app.get(ConfigService)
|
||||
|
||||
const { port, globalPrefix } = configService.get<IAppConfig>('app')!
|
||||
|
||||
app.enableCors({ origin: '*', credentials: true })
|
||||
app.setGlobalPrefix(globalPrefix)
|
||||
app.useStaticAssets({ root: DATA_DIR })
|
||||
|
||||
isDev && app.useGlobalInterceptors(new LoggingInterceptor())
|
||||
|
||||
app.useWebSocketAdapter(new RedisIoAdapter(app))
|
||||
|
||||
const trpc = app.get(TRPCService)
|
||||
trpc.applyMiddleware(app)
|
||||
|
||||
setupSwagger(app, configService)
|
||||
|
||||
await app.listen(port, '0.0.0.0', async () => {
|
||||
app.useLogger(app.get(MyLogger))
|
||||
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}`)
|
||||
logger.log(`[${prefix + pid}] Trpc: ${url}/api/trpc-playground`)
|
||||
|
||||
if (isDev)
|
||||
logger.log(`[${prefix + pid}] OpenAPI: ${url}/api-docs`)
|
||||
})
|
||||
|
||||
if (module.hot) {
|
||||
module.hot.accept()
|
||||
module.hot.dispose(() => app.close())
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
@@ -0,0 +1,7 @@
|
||||
/* eslint-disable */
|
||||
export default async () => {
|
||||
const t = {
|
||||
["./modules/auth/auth.model"]: await import("./modules/auth/auth.model")
|
||||
};
|
||||
return { "@nestjs/swagger": { "models": [[import("./modules/auth/auth.dto"), { "LoginDto": {}, "RegisterDto": {} }], [import("./common/dto/id.dto"), { "IdDto": {} }], [import("./common/dto/pager.dto"), { "PagerDto": {} }], [import("./modules/user/dto/password.dto"), { "PasswordUpdateDto": {}, "UserPasswordDto": {} }], [import("./modules/user/dto/user.dto"), { "UserDto": {}, "UserUpdateDto": {}, "UserQueryDto": {} }], [import("./modules/user/dto/account.dto"), { "UpdateProfileDto": {}, "ResetPasswordDto": {} }], [import("./modules/user/dto/search.dto"), { "UserSearchDto": {} }], [import("./common/dto/delete.dto"), { "BatchDeleteDto": {} }], [import("./modules/todo/todo.dto"), { "TodoDto": {}, "TodoUpdateDto": {}, "TodoPagerDto": {} }], [import("./modules/auth/captcha/captcha.dto"), { "ImageCaptchaDto": {}, "SendEmailCodeDto": {}, "SendSmsCodeDto": {}, "CheckCodeDto": {} }], [import("./modules/file/file.dto"), { "FileQueryDto": {}, "FileUploadDto": {} }], [import("./common/dto/image.dto"), { "ImagesDto": {} }]], "controllers": [[import("./modules/user/user.controller"), { "UserController": { "list": {}, "getUserById": {}, "create": {}, "update": {}, "delete": {}, "password": {} } }], [import("./modules/auth/auth.admin.controller"), { "AuthAdminController": { "login": {} } }], [import("./modules/auth/auth.controller"), { "AuthController": { "login": {}, "register": {} } }], [import("./modules/auth/captcha/captcha.controller"), { "CaptchaController": { "captchaByImg": { type: t["./modules/auth/auth.model"].ImageCaptcha } } }], [import("./modules/auth/controllers/account.controller"), { "AccountController": { "profile": {}, "updateProfile": {}, "logout": {}, "password": {} } }], [import("./modules/auth/controllers/email.controller"), { "EmailController": { "sendEmailCode": {} } }], [import("./modules/file/file.controller"), { "FileController": { "getTypes": { type: Object }, "get": {}, "upload": {}, "uploadMultiple": { type: [Object] }, "delete": {} } }], [import("./modules/todo/todo.controller"), { "TodoController": { "list": {}, "findOne": {}, "create": {}, "update": {}, "delete": {}, "batchDelete": {} } }]] } };
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Body, Controller, Headers, Post, Res, UseGuards } from '@nestjs/common'
|
||||
import { ApiTags } from '@nestjs/swagger'
|
||||
|
||||
import { Ip } from '@server/common/decorators/http.decorator'
|
||||
|
||||
import { BizException } from '@server/common/exceptions/biz.exception'
|
||||
import { ResOp } from '@server/common/model/response.model'
|
||||
import { ErrorCodeEnum } from '@server/constants/error-code.constant'
|
||||
import { FastifyReply } from 'fastify'
|
||||
|
||||
import { UserService } from '../user/user.service'
|
||||
|
||||
import { LoginDto } from './auth.dto'
|
||||
import { AuthService } from './auth.service'
|
||||
import { Public } from './decorators/public.decorator'
|
||||
import { LocalGuard } from './guards/local.guard'
|
||||
|
||||
@ApiTags('Auth - 认证模块')
|
||||
@UseGuards(LocalGuard)
|
||||
@Public()
|
||||
@Controller('auth/admin')
|
||||
export class AuthAdminController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private userService: UserService,
|
||||
) { }
|
||||
|
||||
@Post('login')
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Res() res: FastifyReply,
|
||||
@Ip() ip: string,
|
||||
@Headers('user-agent') ua: string,
|
||||
) {
|
||||
const { username, password } = dto
|
||||
|
||||
// await this.captchaService.checkImgCaptcha(captchaId, verifyCode);
|
||||
|
||||
const user = await this.authService.validateUser(username, password, 'account')
|
||||
|
||||
if (user.role !== 'Admin')
|
||||
throw new BizException(ErrorCodeEnum.PasswordMismatch)
|
||||
|
||||
const jwt = await this.authService.sign(user.id, user.role, { ip, ua })
|
||||
|
||||
res.setCookie('auth-token', jwt)
|
||||
res.send(
|
||||
new ResOp({
|
||||
data: { authToken: jwt },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// @Post('register')
|
||||
// @ProtectKeys(['password'])
|
||||
// async register(@Body() dto: RegisterDto) {
|
||||
// return await this.userService.register(dto)
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { LoginTypeEnum } from './auth.dto'
|
||||
|
||||
export { Role } from 'database'
|
||||
|
||||
export const PUBLIC_KEY = '__public_key__'
|
||||
|
||||
export const ROLES_KEY = '__roles_key__'
|
||||
|
||||
export const PERMISSION_KEY = '__permission_key__'
|
||||
|
||||
export const RESOURCE_KEY = '__resource_key__'
|
||||
|
||||
export type LoginType = z.infer<typeof LoginTypeEnum>
|
||||
|
||||
export const AuthStrategy = {
|
||||
LOCAL: 'local',
|
||||
LOCAL_EMAIL: 'local_email',
|
||||
LOCAL_PHONE: 'local_phone',
|
||||
|
||||
JWT: 'jwt',
|
||||
|
||||
GITHUB: 'github',
|
||||
GOOGLE: 'google',
|
||||
} as const
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Body, Controller, Headers, Post, Res, UseGuards } from '@nestjs/common'
|
||||
import { ApiTags } from '@nestjs/swagger'
|
||||
|
||||
import { Ip } from '@server/common/decorators/http.decorator'
|
||||
import { ProtectKeys } from '@server/common/decorators/protect-keys.decorator'
|
||||
|
||||
import { ResOp } from '@server/common/model/response.model'
|
||||
import { FastifyReply } from 'fastify'
|
||||
|
||||
import { BizException } from 'src/common/exceptions/biz.exception'
|
||||
import { ErrorCodeEnum } from 'src/constants/error-code.constant'
|
||||
|
||||
import { UserService } from '../user/user.service'
|
||||
|
||||
import { LoginDto, RegisterDto } from './auth.dto'
|
||||
import { AuthService } from './auth.service'
|
||||
import { Public } from './decorators/public.decorator'
|
||||
import { LocalGuard } from './guards/local.guard'
|
||||
|
||||
@ApiTags('Auth - 认证模块')
|
||||
@UseGuards(LocalGuard)
|
||||
@Public()
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private userService: UserService,
|
||||
) { }
|
||||
|
||||
@Post('login')
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Res() res: FastifyReply,
|
||||
@Ip() ip: string,
|
||||
@Headers('user-agent') ua: string,
|
||||
) {
|
||||
const { username, password } = dto
|
||||
|
||||
// await this.captchaService.checkImgCaptcha(captchaId, verifyCode);
|
||||
|
||||
const user = await this.authService.validateUser(username, password, 'account')
|
||||
|
||||
if (user.role === 'Admin')
|
||||
throw new BizException(ErrorCodeEnum.PasswordMismatch)
|
||||
|
||||
const jwt = await this.authService.sign(user.id, user.role, { ip, ua })
|
||||
|
||||
res.setCookie('auth-token', jwt)
|
||||
res.send(
|
||||
new ResOp({
|
||||
data: { authToken: jwt },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@ProtectKeys(['password'])
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
return await this.userService.register(dto)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'nestjs-zod/z'
|
||||
|
||||
export const LoginTypeEnum = z.enum(['account', 'email', 'mobile'])
|
||||
|
||||
export const CredentialsSchema = z.object({
|
||||
username: z.string().min(4).max(255),
|
||||
password: z.password().min(6, { message: '密码长度不能小于6位' }),
|
||||
// type: LoginTypeEnum,
|
||||
})
|
||||
|
||||
export class LoginDto extends createZodDto(CredentialsSchema) { }
|
||||
|
||||
export class RegisterDto extends createZodDto(CredentialsSchema.extend({
|
||||
// ...
|
||||
})) { }
|
||||
@@ -0,0 +1,8 @@
|
||||
export class ImageCaptcha {
|
||||
id: string
|
||||
image: string
|
||||
}
|
||||
|
||||
export class LoginResult {
|
||||
authToken: string
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Global, Module, Provider } from '@nestjs/common'
|
||||
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { PassportModule } from '@nestjs/passport'
|
||||
|
||||
import { ISecurityConfig } from '@server/config'
|
||||
import { isDev } from '@server/global/env'
|
||||
|
||||
import { UserModule } from '../user/user.module'
|
||||
|
||||
import { AuthAdminController } from './auth.admin.controller'
|
||||
import { AuthController } from './auth.controller'
|
||||
import { AuthService } from './auth.service'
|
||||
import { AuthTrpcRouter } from './auth.trpc'
|
||||
import { CaptchaModule } from './captcha/captcha.module'
|
||||
import { AccountController } from './controllers/account.controller'
|
||||
import { EmailController } from './controllers/email.controller'
|
||||
import { TokenService } from './services/token.service'
|
||||
import { JwtStrategy } from './strategies/jwt.strategy'
|
||||
import { LocalStrategy } from './strategies/local.strategy'
|
||||
|
||||
const controllers = [
|
||||
AuthController,
|
||||
AuthAdminController,
|
||||
AccountController,
|
||||
EmailController,
|
||||
]
|
||||
const providers: Provider[] = [
|
||||
AuthService,
|
||||
TokenService,
|
||||
AuthTrpcRouter,
|
||||
]
|
||||
const strategies = [LocalStrategy, JwtStrategy]
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const { jwtSecret, jwtExprire }
|
||||
= configService.get<ISecurityConfig>('security')!
|
||||
|
||||
return {
|
||||
secret: jwtSecret,
|
||||
expires: jwtExprire,
|
||||
ignoreExpiration: isDev,
|
||||
}
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
CaptchaModule,
|
||||
UserModule,
|
||||
],
|
||||
controllers: [...controllers],
|
||||
providers: [...providers, ...strategies],
|
||||
exports: [JwtModule, ...providers],
|
||||
})
|
||||
@Global()
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { InjectRedis } from '@liaoliaots/nestjs-redis'
|
||||
import { Injectable } from '@nestjs/common'
|
||||
|
||||
import { BizException } from '@server/common/exceptions/biz.exception'
|
||||
import { ErrorCodeEnum } from '@server/constants/error-code.constant'
|
||||
import { UserService } from '@server/modules/user/user.service'
|
||||
|
||||
import { sleep } from '@server/utils/tool.util'
|
||||
import { compareSync } from 'bcrypt'
|
||||
import Redis from 'ioredis'
|
||||
|
||||
import { LoginType, Role } from './auth.constant'
|
||||
import { TokenService } from './services/token.service'
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRedis()
|
||||
private readonly redis: Redis,
|
||||
private readonly userService: UserService,
|
||||
private readonly tokenService: TokenService,
|
||||
) { }
|
||||
|
||||
async validateUser(credential: string, password: string, type: LoginType) {
|
||||
const user = type === 'account'
|
||||
? await this.userService.getUserByUsername(credential)
|
||||
: type === 'email'
|
||||
? await this.userService.getUserByEmail(credential)
|
||||
: null
|
||||
|
||||
if (!user)
|
||||
throw new BizException(ErrorCodeEnum.PasswordMismatch)
|
||||
|
||||
const isSamePassword = compareSync(password, user.password)
|
||||
|
||||
if (!isSamePassword) {
|
||||
await sleep(1500)
|
||||
throw new BizException(ErrorCodeEnum.PasswordMismatch)
|
||||
}
|
||||
|
||||
const { password: _p, ...result } = user
|
||||
return result
|
||||
}
|
||||
|
||||
async validateToken(token: string) {
|
||||
return await this.tokenService.verifyToken(token)
|
||||
}
|
||||
|
||||
async sign(
|
||||
userId: string,
|
||||
role: Role,
|
||||
otherInfo?: {
|
||||
ip: string
|
||||
ua: string
|
||||
},
|
||||
): Promise<string> {
|
||||
const token = await this.tokenService.generateToken({ id: userId, role }, otherInfo)
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
async clearLoginStatus(userId: string): Promise<void> {
|
||||
await this.tokenService.removeToken(userId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common'
|
||||
|
||||
import { TRPCRouter } from '@server/shared/trpc/trpc.decorator'
|
||||
import { defineTrpcRouter } from '@server/shared/trpc/trpc.helper'
|
||||
import { TRPCService } from '@server/shared/trpc/trpc.service'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { AuthService } from './auth.service'
|
||||
import { CredentialsSchema } from './auth.dto'
|
||||
import { BizException } from '@server/common/exceptions/biz.exception'
|
||||
import { ErrorCodeEnum } from '@server/constants/error-code.constant'
|
||||
|
||||
@TRPCRouter()
|
||||
@Injectable()
|
||||
export class AuthTrpcRouter implements OnModuleInit {
|
||||
private router: ReturnType<typeof this.createRouter>
|
||||
|
||||
constructor(
|
||||
private readonly trpcService: TRPCService,
|
||||
private readonly authService: AuthService,
|
||||
) { }
|
||||
|
||||
onModuleInit() {
|
||||
this.router = this.createRouter()
|
||||
}
|
||||
|
||||
private createRouter() {
|
||||
const procedureAuth = this.trpcService.procedureAuth
|
||||
const procedure = this.trpcService.procedure
|
||||
return defineTrpcRouter('auth', {
|
||||
login: procedure
|
||||
.input(CredentialsSchema)
|
||||
.mutation(async (opt) => {
|
||||
const { input } = opt
|
||||
const { username, password } = input
|
||||
|
||||
// await this.captchaService.checkImgCaptcha(captchaId, verifyCode);
|
||||
|
||||
const user = await this.authService.validateUser(username, password, 'account')
|
||||
|
||||
if (user.role === 'Admin')
|
||||
throw new BizException(ErrorCodeEnum.PasswordMismatch)
|
||||
|
||||
const jwt = await this.authService.sign(user.id, user.role, { ip, ua })
|
||||
|
||||
return {
|
||||
data: { authToken: jwt },
|
||||
}
|
||||
}),
|
||||
logout: procedureAuth
|
||||
.input(z.undefined())
|
||||
.mutation(async (opt) => {
|
||||
const { input, ctx: { user } } = opt
|
||||
|
||||
return await this.authService.clearLoginStatus(user.id)
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { InjectRedis } from '@liaoliaots/nestjs-redis'
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common'
|
||||
import { ApiTags } from '@nestjs/swagger'
|
||||
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'
|
||||
|
||||
import { RedisKeys } from '@server/constants/cache.constant'
|
||||
import { getRedisKey } from '@server/utils/redis.util'
|
||||
import { generateUUID } from '@server/utils/tool.util'
|
||||
import Redis from 'ioredis'
|
||||
import { isEmpty } from 'lodash'
|
||||
import * as svgCaptcha from 'svg-captcha'
|
||||
|
||||
import { ImageCaptcha } from '../auth.model'
|
||||
import { Public } from '../decorators/public.decorator'
|
||||
|
||||
import { ImageCaptchaDto } from './captcha.dto'
|
||||
|
||||
@ApiTags('Captcha - 验证码模块')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Controller('auth/captcha')
|
||||
export class CaptchaController {
|
||||
constructor(@InjectRedis() private redis: Redis) {}
|
||||
|
||||
@Get('image')
|
||||
@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 data = {
|
||||
image: `data:image/svg+xml;base64,${Buffer.from(svg.data).toString(
|
||||
'base64',
|
||||
)}`,
|
||||
id: generateUUID(),
|
||||
}
|
||||
// 5分钟过期时间
|
||||
await this.redis.set(getRedisKey(RedisKeys.CaptchaStore, data.id), svg.text, 'EX', 5 * 60)
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
export class ImageCaptchaDto extends createZodDto(z.object({
|
||||
width: z.number().optional().default(100),
|
||||
height: z.number().optional().default(50),
|
||||
})) {}
|
||||
|
||||
export class SendEmailCodeDto extends createZodDto(z.object({
|
||||
email: z.string().email({ message: '邮箱格式不正确' }),
|
||||
})) {}
|
||||
|
||||
export class SendSmsCodeDto extends createZodDto(z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' }),
|
||||
})) {}
|
||||
|
||||
export class CheckCodeDto extends createZodDto(z.object({
|
||||
identity: z.string(),
|
||||
code: z.string(),
|
||||
})) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class LoginResult {
|
||||
authToken: string
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module, Provider } from '@nestjs/common'
|
||||
|
||||
import { CaptchaController } from './captcha.controller'
|
||||
import { CaptchaService } from './captcha.service'
|
||||
|
||||
const providers: Provider[] = [CaptchaService]
|
||||
|
||||
@Module({
|
||||
controllers: [CaptchaController],
|
||||
providers: [...providers],
|
||||
exports: [...providers],
|
||||
})
|
||||
export class CaptchaModule {}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { InjectRedis } from '@liaoliaots/nestjs-redis'
|
||||
import { Injectable } from '@nestjs/common'
|
||||
|
||||
import { BizException } from '@server/common/exceptions/biz.exception'
|
||||
import { RedisKeys } from '@server/constants/cache.constant'
|
||||
import { ErrorCodeEnum } from '@server/constants/error-code.constant'
|
||||
import { getRedisKey } from '@server/utils/redis.util'
|
||||
import Redis from 'ioredis'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaService {
|
||||
constructor(
|
||||
@InjectRedis() private readonly redis: Redis,
|
||||
|
||||
// private captchaLogService: CaptchaLogService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 校验图片验证码
|
||||
*/
|
||||
async checkImgCaptcha(id: string, code: string): Promise<void> {
|
||||
const key = getRedisKey(RedisKeys.CaptchaStore, id)
|
||||
const result = await this.redis.get(key)
|
||||
if (isEmpty(result) || code.toLowerCase() !== result?.toLowerCase())
|
||||
throw new BizException(ErrorCodeEnum.VerificationCodeError)
|
||||
|
||||
// 校验成功后移除验证码
|
||||
await this.redis.del(key)
|
||||
}
|
||||
|
||||
async log(
|
||||
account: string,
|
||||
code: string,
|
||||
provider: 'sms' | 'email',
|
||||
userId?: number,
|
||||
): Promise<void> {
|
||||
// await this.captchaLogService.create(account, code, provider, userId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common'
|
||||
import { ApiTags } from '@nestjs/swagger'
|
||||
|
||||
import { ApiSecurityAuth } from '@server/common/decorators/swagger.decorator'
|
||||
import { AuthUser } from '@server/modules/auth/decorators/auth-user.decorator'
|
||||
import { PasswordUpdateDto } from '@server/modules/user/dto/password.dto'
|
||||
|
||||
import { UserService } from '../../user/user.service'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { UpdateProfileDto } from '../../user/dto/account.dto'
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard'
|
||||
|
||||
@ApiTags('Account - 账户模块')
|
||||
@ApiSecurityAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('account')
|
||||
export class AccountController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly authService: AuthService,
|
||||
) { }
|
||||
|
||||
@Get('profile')
|
||||
async profile(@AuthUser() user: IAuthUser) {
|
||||
return this.userService.getProfile(user.id)
|
||||
}
|
||||
|
||||
@Put('profile')
|
||||
async updateProfile(
|
||||
@AuthUser() user: IAuthUser, @Body() dto: UpdateProfileDto,
|
||||
): Promise<void> {
|
||||
await this.userService.updateProfile(user.id, dto)
|
||||
}
|
||||
|
||||
@Get('logout')
|
||||
async logout(@AuthUser() user: IAuthUser): Promise<void> {
|
||||
await this.authService.clearLoginStatus(user.id)
|
||||
}
|
||||
|
||||
@Put('password')
|
||||
async password(
|
||||
@AuthUser() user: IAuthUser, @Body()
|
||||
dto: PasswordUpdateDto,
|
||||
): Promise<void> {
|
||||
await this.userService.updatePassword(user.id, dto)
|
||||
}
|
||||
|
||||
// @Get('menus')
|
||||
// async menu(@AuthUser() user: IAuthUser) {
|
||||
// return this.authService.getMenus(user.id)
|
||||
// }
|
||||
|
||||
// @Get('permissions')
|
||||
// async permissions(@AuthUser() user: IAuthUser) {
|
||||
// return this.authService.getPermissions(user.id)
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common'
|
||||
import { ApiTags } from '@nestjs/swagger'
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'
|
||||
|
||||
import { Ip } from '@server/common/decorators/http.decorator'
|
||||
import { MailerService } from '@server/shared/helper/mailer/mailer.service'
|
||||
|
||||
import { SendEmailCodeDto } from '../captcha/captcha.dto'
|
||||
import { Public } from '../decorators/public.decorator'
|
||||
|
||||
@ApiTags('Auth - 认证模块')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Controller('auth/email')
|
||||
export class EmailController {
|
||||
constructor(
|
||||
private mailerService: MailerService,
|
||||
) {}
|
||||
|
||||
@Post('send')
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 2, ttl: 60_000 } })
|
||||
async sendEmailCode(
|
||||
@Body() dto: SendEmailCodeDto,
|
||||
@Ip() ip: string,
|
||||
): Promise<void> {
|
||||
const { email } = dto
|
||||
|
||||
await this.mailerService.checkLimit(email, ip)
|
||||
const { code } = await this.mailerService.sendVerificationCode(email)
|
||||
|
||||
await this.mailerService.log(email, code, ip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ExecutionContext, createParamDecorator } from '@nestjs/common'
|
||||
import { FastifyRequest } from 'fastify'
|
||||
|
||||
type Payload = keyof IAuthUser
|
||||
|
||||
/**
|
||||
* @description 获取当前登录用户信息, 并挂载到request上
|
||||
*/
|
||||
export const AuthUser = createParamDecorator(
|
||||
(data: Payload, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<FastifyRequest>()
|
||||
// auth guard will mount this
|
||||
const user = request.user as IAuthUser
|
||||
|
||||
return data ? user?.[data] : user
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SetMetadata } from '@nestjs/common'
|
||||
|
||||
import { PUBLIC_KEY } from '../auth.constant'
|
||||
|
||||
/**
|
||||
* 当接口不需要检测用户登录时添加该装饰器
|
||||
*/
|
||||
export const Public = () => SetMetadata(PUBLIC_KEY, true)
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
import { AuthGuard } from '@nestjs/passport'
|
||||
import { ErrorCode, ErrorCodeEnum } from '@server/constants/error-code.constant'
|
||||
import { AuthService } from '@server/modules/auth/auth.service'
|
||||
import { FastifyRequest } from 'fastify'
|
||||
|
||||
import { AuthStrategy, PUBLIC_KEY } from '../auth.constant'
|
||||
import { TokenService } from '../services/token.service'
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard(AuthStrategy.JWT) {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private authService: AuthService,
|
||||
private tokenService: TokenService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<any> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
])
|
||||
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>()
|
||||
// const response = context.switchToHttp().getResponse<FastifyReply>()
|
||||
|
||||
const Authorization = request.headers.authorization
|
||||
|
||||
let result: any = false
|
||||
try {
|
||||
result = await super.canActivate(context)
|
||||
}
|
||||
catch (e) {
|
||||
// 需要后置判断 这样携带了 token 的用户就能够解析到 request.user
|
||||
if (isPublic)
|
||||
return true
|
||||
|
||||
if (!Authorization)
|
||||
throw new UnauthorizedException('未登录')
|
||||
|
||||
const ok = await this.tokenService.verifyToken(Authorization)
|
||||
if (!ok)
|
||||
throw new UnauthorizedException(ErrorCode[ErrorCodeEnum.JWTInvalid])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
handleRequest(err, user, info) {
|
||||
// You can throw an exception based on either "info" or "err" arguments
|
||||
if (err || !user)
|
||||
throw err || new UnauthorizedException()
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common'
|
||||
import { AuthGuard } from '@nestjs/passport'
|
||||
|
||||
import { AuthStrategy } from '../auth.constant'
|
||||
|
||||
@Injectable()
|
||||
export class LocalGuard extends AuthGuard(AuthStrategy.LOCAL) {
|
||||
async canActivate(_context: ExecutionContext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { InjectRedis } from '@liaoliaots/nestjs-redis'
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
|
||||
import { RedisKeys } from '@server/constants/cache.constant'
|
||||
|
||||
import { ErrorCode, ErrorCodeEnum } from '@server/constants/error-code.constant'
|
||||
|
||||
import { getRedisKey } from '@server/utils/redis.util'
|
||||
import { Redis } from 'ioredis'
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
@InjectRedis()
|
||||
private readonly redis: Redis,
|
||||
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async generateToken(payload: IAuthUser, otherInfo?: any) {
|
||||
const token = this.jwtService.sign(payload)
|
||||
|
||||
// store in redis
|
||||
await this.redis.hset(
|
||||
getRedisKey(RedisKeys.JWTStore),
|
||||
payload.id,
|
||||
JSON.stringify({
|
||||
token,
|
||||
date: new Date().toISOString(),
|
||||
...otherInfo,
|
||||
}),
|
||||
)
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
async isTokenInRedis(userId: string) {
|
||||
if (!userId)
|
||||
return false
|
||||
const key = getRedisKey(RedisKeys.JWTStore)
|
||||
const has = await this.redis.hexists(key, userId)
|
||||
return !!has
|
||||
}
|
||||
|
||||
async removeToken(userId: string) {
|
||||
const key = getRedisKey(RedisKeys.JWTStore)
|
||||
|
||||
await this.redis.hdel(
|
||||
key,
|
||||
userId,
|
||||
)
|
||||
}
|
||||
|
||||
async revokeAll() {
|
||||
const key = getRedisKey(RedisKeys.JWTStore)
|
||||
await this.redis.del(key)
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
const jwt = token.replace(/[Bb]earer /, '')
|
||||
|
||||
if (!isJWT(jwt))
|
||||
throw new UnauthorizedException(ErrorCode[ErrorCodeEnum.JWTInvalid])
|
||||
|
||||
try {
|
||||
const result = this.jwtService.verify(jwt) as IAuthUser
|
||||
if (!result)
|
||||
return false
|
||||
|
||||
const has = await this.isTokenInRedis(result.id)
|
||||
if (!has)
|
||||
return false
|
||||
|
||||
return result
|
||||
}
|
||||
catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isJWT(token: string): boolean {
|
||||
const parts = token.split('.')
|
||||
return (
|
||||
parts.length === 3
|
||||
&& /^[a-zA-Z0-9_-]+$/.test(parts[0])
|
||||
&& /^[a-zA-Z0-9_-]+$/.test(parts[1])
|
||||
&& /^[a-zA-Z0-9_-]+$/.test(parts[2])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Inject, Injectable } from '@nestjs/common'
|
||||
import { PassportStrategy } from '@nestjs/passport'
|
||||
import { ISecurityConfig, SecurityConfig } from '@server/config'
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt'
|
||||
|
||||
import { AuthStrategy } from '../auth.constant'
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, AuthStrategy.JWT) {
|
||||
constructor(
|
||||
@Inject(SecurityConfig.KEY) private securityConfig: ISecurityConfig,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: securityConfig.jwtSecret,
|
||||
})
|
||||
}
|
||||
|
||||
async validate(payload: IAuthUser) {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { PassportStrategy } from '@nestjs/passport'
|
||||
import { Strategy } from 'passport-local'
|
||||
|
||||
import { AuthStrategy } from '../auth.constant'
|
||||
import { AuthService } from '../auth.service'
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
AuthStrategy.LOCAL,
|
||||
) {
|
||||
constructor(private authService: AuthService) {
|
||||
super({
|
||||
usernameField: 'credential',
|
||||
passwordField: 'password',
|
||||
})
|
||||
}
|
||||
|
||||
async validate(username: string, password: string) {
|
||||
const user = await this.authService.validateUser(username, password, 'account')
|
||||
return user
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PureAbility } from '@casl/ability'
|
||||
import { Subjects } from '@casl/prisma'
|
||||
import { Todo, User } from 'database'
|
||||
|
||||
export enum Action {
|
||||
Manage = 'manage',
|
||||
Create = 'create',
|
||||
Read = 'read',
|
||||
Update = 'update',
|
||||
Delete = 'delete',
|
||||
}
|
||||
|
||||
// eslint-disable-next-line ts/consistent-type-definitions
|
||||
export type PrismaSubjects = {
|
||||
User: User
|
||||
Todo: Todo
|
||||
|
||||
}
|
||||
|
||||
export type AppAbility = PureAbility<[Action, Subjects<PrismaSubjects>]>
|
||||
|
||||
export abstract class BaseAbility {
|
||||
abstract createForUser(user: IAuthUser): AppAbility
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ModelName } from '@server/shared/database/prisma.extension'
|
||||
|
||||
export const ABILITY_FACTORY_KEY = '__ABILITY_FACTORY_KEY__'
|
||||
|
||||
/**
|
||||
* 标识服务为 Ability
|
||||
* @param model
|
||||
* @returns
|
||||
*/
|
||||
export function DefineAbility(model: ModelName): ClassDecorator {
|
||||
return (target) => {
|
||||
Reflect.defineMetadata(ABILITY_FACTORY_KEY, model, target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common'
|
||||
|
||||
import { DiscoveryModule } from '@nestjs/core'
|
||||
|
||||
import { AbilityService } from './casl.service'
|
||||
|
||||
@Module({
|
||||
imports: [DiscoveryModule],
|
||||
providers: [AbilityService],
|
||||
exports: [AbilityService],
|
||||
})
|
||||
@Global()
|
||||
export class CaslModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'
|
||||
import { DiscoveryService, Reflector } from '@nestjs/core'
|
||||
|
||||
import { ModelName } from '@server/shared/database/prisma.extension'
|
||||
|
||||
import { BaseAbility } from './ability.class'
|
||||
import { ABILITY_FACTORY_KEY } from './ability.decorator'
|
||||
|
||||
@Injectable()
|
||||
export class AbilityService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly discovery: DiscoveryService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
private logger = new Logger('AbilityService')
|
||||
|
||||
abilityMap: Record<ModelName, BaseAbility>
|
||||
|
||||
onModuleInit() {
|
||||
this.createAbility()
|
||||
}
|
||||
|
||||
private createAbility() {
|
||||
const providers = this.discovery.getProviders()
|
||||
|
||||
const abilityMap = {}
|
||||
|
||||
providers
|
||||
.forEach((provider) => {
|
||||
try {
|
||||
const model = this.reflector.get(ABILITY_FACTORY_KEY, provider.metatype)
|
||||
|
||||
if (model)
|
||||
abilityMap[model] = provider.instance
|
||||
|
||||
return model
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
this.abilityMap = abilityMap as Record<ModelName, BaseAbility>
|
||||
return abilityMap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SetMetadata } from '@nestjs/common'
|
||||
|
||||
import { ModelName } from '@server/shared/database/prisma.extension'
|
||||
|
||||
import { Action } from './ability.class'
|
||||
|
||||
export const CHECK_POLICY_KEY = '__check_policy_key__'
|
||||
|
||||
// eslint-disable-next-line ts/consistent-type-definitions
|
||||
export type PolicyObject = { action: Action, model: ModelName }
|
||||
|
||||
export function Policy(policy: PolicyObject) {
|
||||
return SetMetadata(CHECK_POLICY_KEY, policy)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { subject } from '@casl/ability'
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
import { getRequestItemId } from '@server/helper/get-request-item-id.helper'
|
||||
import { ExtendedPrismaClient, InjectPrismaClient } from '@server/shared/database/prisma.extension'
|
||||
|
||||
import { FastifyRequest } from 'fastify'
|
||||
|
||||
import { BizException } from 'src/common/exceptions/biz.exception'
|
||||
|
||||
import { ErrorCodeEnum } from 'src/constants/error-code.constant'
|
||||
|
||||
import { AbilityService } from './casl.service'
|
||||
import { CHECK_POLICY_KEY, PolicyObject } from './policy.decortor'
|
||||
|
||||
@Injectable()
|
||||
export class PolicyGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private abilityService: AbilityService,
|
||||
@InjectPrismaClient()
|
||||
private readonly prisma: ExtendedPrismaClient,
|
||||
) {
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>()
|
||||
|
||||
const { user } = context.switchToHttp().getRequest()
|
||||
|
||||
if (!user)
|
||||
throw new UnauthorizedException()
|
||||
|
||||
const policy = this.reflector.getAllAndOverride<PolicyObject>(CHECK_POLICY_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
])
|
||||
|
||||
// 使用了 PolicyGuard 但没未其定义 policy 则不允许通过
|
||||
if (!policy)
|
||||
throw new BizException(ErrorCodeEnum.NoPermission)
|
||||
|
||||
const { action, model } = policy
|
||||
|
||||
const ability = this.abilityService.abilityMap[model].createForUser(user)
|
||||
|
||||
// 获取请求资源的的 id
|
||||
const id = getRequestItemId(request)
|
||||
|
||||
// 如果 id 存在,则检查具体资源
|
||||
if (id) {
|
||||
const item = await this.prisma[model].findUniqueOrThrow({
|
||||
where: { id },
|
||||
})
|
||||
|
||||
return ability.can(action, subject(model, item))
|
||||
}
|
||||
|
||||
return ability.can(action, model)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export enum FileTypeEnum {
|
||||
icon = 'icon',
|
||||
photo = 'photo',
|
||||
file = 'file',
|
||||
avatar = 'avatar',
|
||||
video = 'video',
|
||||
}
|
||||
export type FileType = keyof typeof FileTypeEnum
|
||||
@@ -0,0 +1,121 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { BadRequestException, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common'
|
||||
import { ApiTags } from '@nestjs/swagger'
|
||||
import { Throttle } from '@nestjs/throttler'
|
||||
import { Bypass } from '@server/common/decorators/bypass.decorator'
|
||||
import { PagerDto } from '@server/common/dto/pager.dto'
|
||||
import { BizException } from '@server/common/exceptions/biz.exception'
|
||||
import { ErrorCodeEnum } from '@server/constants/error-code.constant'
|
||||
import { alphabet } from '@server/constants/other.constant'
|
||||
import { FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { lookup } from 'mime-types'
|
||||
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
import { Public } from '../auth/decorators/public.decorator'
|
||||
|
||||
import { FileQueryDto, FileUploadDto } from './file.dto'
|
||||
import { FileService } from './file.service'
|
||||
|
||||
@ApiTags('System - 文件模块')
|
||||
@Controller(['objects', 'files'])
|
||||
export class FileController {
|
||||
constructor(
|
||||
private readonly fileService: FileService,
|
||||
) { }
|
||||
|
||||
@Get('/:type')
|
||||
async getTypes(@Query() query: PagerDto, @Param() params: FileUploadDto) {
|
||||
const { type = 'file' } = params
|
||||
// const { page, size } = query
|
||||
const dir = await this.fileService.getDir(type)
|
||||
return Promise.all(
|
||||
dir.map(async (name) => {
|
||||
return { name, url: await this.fileService.resolveFileUrl(type, name) }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@Get('/:type/:name')
|
||||
@Throttle({ default: { limit: 60, ttl: 60_000 } })
|
||||
@Bypass()
|
||||
@Public()
|
||||
async get(@Param() params: FileQueryDto, @Res() res: FastifyReply) {
|
||||
const { type, name } = params
|
||||
const ext = path.extname(name)
|
||||
const mimetype = lookup(ext)
|
||||
|
||||
try {
|
||||
const stream = await this.fileService.getFileStream(type, name)
|
||||
if (mimetype) {
|
||||
res.type(mimetype)
|
||||
res.header('cache-control', 'public, max-age=31536000')
|
||||
res.header(
|
||||
'expires',
|
||||
new Date(Date.now() + 31536000 * 1000).toUTCString(),
|
||||
)
|
||||
}
|
||||
|
||||
return res.send(stream)
|
||||
}
|
||||
catch {
|
||||
throw new BizException(ErrorCodeEnum.ResourceNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/upload')
|
||||
async upload(@Query() query: FileUploadDto, @Req() req: FastifyRequest) {
|
||||
const file = await req.file()
|
||||
|
||||
if (!file)
|
||||
throw new BadRequestException('仅供上传文件!')
|
||||
|
||||
if (file.fieldname !== 'file')
|
||||
throw new BadRequestException('字段必须为 file')
|
||||
|
||||
const { type = 'file' } = query
|
||||
|
||||
const ext = path.extname(file.filename)
|
||||
|
||||
const filename = customAlphabet(alphabet)(18) + ext.toLowerCase()
|
||||
|
||||
if (!(await this.fileService.exists(type, filename)))
|
||||
await this.fileService.writeFile(type, filename, file.file)
|
||||
|
||||
// TODO: save record in dabase
|
||||
return {
|
||||
url: await this.fileService.resolveFileUrl(type, filename),
|
||||
name: filename,
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/upload/multiple')
|
||||
async uploadMultiple(@Query() query: FileUploadDto, @Req() req: FastifyRequest) {
|
||||
const { type = 'file' } = query
|
||||
const uploadedFiles: any[] = []
|
||||
|
||||
const files = req.files()
|
||||
|
||||
for await (const file of files) {
|
||||
const ext = path.extname(file.filename)
|
||||
const filename = customAlphabet(alphabet)(18) + ext.toLowerCase()
|
||||
|
||||
if (!(await this.fileService.exists(type, filename)))
|
||||
await this.fileService.writeFile(type, filename, file.file)
|
||||
|
||||
uploadedFiles.push({
|
||||
url: await this.fileService.resolveFileUrl(type, filename),
|
||||
name: filename,
|
||||
})
|
||||
}
|
||||
|
||||
return uploadedFiles
|
||||
}
|
||||
|
||||
@Delete('/:type/:name')
|
||||
async delete(@Param() params: FileQueryDto) {
|
||||
const { type, name } = params
|
||||
await this.fileService.deleteFile(type, name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { FileTypeEnum } from './file.constant'
|
||||
|
||||
export const FileQuerySchema = z.object({
|
||||
type: z.nativeEnum(FileTypeEnum),
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
export class FileQueryDto extends createZodDto(FileQuerySchema) {}
|
||||
|
||||
export const FileUploadSchema = z.object({
|
||||
type: z.nativeEnum(FileTypeEnum).optional(),
|
||||
})
|
||||
|
||||
export class FileUploadDto extends createZodDto(FileUploadSchema) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Global, Module, Provider } from '@nestjs/common'
|
||||
|
||||
import { FileController } from './file.controller'
|
||||
import { FileService } from './file.service'
|
||||
|
||||
const providers: Provider[] = [FileService]
|
||||
|
||||
@Module({
|
||||
controllers: [FileController],
|
||||
providers,
|
||||
exports: providers,
|
||||
})
|
||||
@Global()
|
||||
export class FileModule { }
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Readable, pipeline } from 'node:stream'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { IAppConfig } from '@server/config/app.config'
|
||||
import { STATIC_FILE_DIR } from '@server/constants/path.constant'
|
||||
import fs from 'fs-extra'
|
||||
|
||||
import { FileType } from './file.constant'
|
||||
|
||||
const pump = promisify(pipeline)
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(private readonly configService: ConfigService) { }
|
||||
|
||||
private resolveFilePath(type: FileType, name: string) {
|
||||
return path.resolve(STATIC_FILE_DIR, type, name)
|
||||
}
|
||||
|
||||
private async checkIsExist(path: string) {
|
||||
try {
|
||||
await fs.access(path)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getFileStream(type: FileType, name: string) {
|
||||
const exists = await this.checkIsExist(this.resolveFilePath(type, name))
|
||||
if (!exists)
|
||||
throw new NotFoundException('文件不存在')
|
||||
|
||||
return fs.createReadStream(this.resolveFilePath(type, name))
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
type: FileType,
|
||||
name: string,
|
||||
data: Readable,
|
||||
encoding?: BufferEncoding,
|
||||
) {
|
||||
const filePath = this.resolveFilePath(type, name)
|
||||
if (await this.checkIsExist(filePath))
|
||||
throw new BadRequestException('文件已存在')
|
||||
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
|
||||
const writable = createWriteStream(filePath, { encoding })
|
||||
|
||||
await pump(data, writable)
|
||||
}
|
||||
|
||||
async deleteFile(type: FileType, name: string) {
|
||||
try {
|
||||
return await fs.unlink(this.resolveFilePath(type, name))
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async getDir(type: FileType) {
|
||||
await fs.mkdir(this.resolveFilePath(type, ''), { recursive: true })
|
||||
const path_1 = path.resolve(STATIC_FILE_DIR, type)
|
||||
return await fs.readdir(path_1)
|
||||
}
|
||||
|
||||
async resolveFileUrl(type: FileType, name: string) {
|
||||
const { baseUrl, globalPrefix } = await this.configService.get<IAppConfig>('app')!
|
||||
return `${baseUrl.replace(/\/+$/, '')}/${globalPrefix}/files/${type}/${name}`
|
||||
}
|
||||
|
||||
exists(type: FileType, name: string) {
|
||||
return this.checkIsExist(this.resolveFilePath(type, name))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AbilityBuilder } from '@casl/ability'
|
||||
import { createPrismaAbility } from '@casl/prisma'
|
||||
import { Injectable } from '@nestjs/common'
|
||||
|
||||
import { Role } from '@server/modules/auth/auth.constant'
|
||||
|
||||
import { Action, AppAbility, BaseAbility } from '../casl/ability.class'
|
||||
import { DefineAbility } from '../casl/ability.decorator'
|
||||
|
||||
@DefineAbility('Todo')
|
||||
@Injectable()
|
||||
export class TodoAbility implements BaseAbility {
|
||||
createForUser(user: IAuthUser) {
|
||||
const { can, build } = new AbilityBuilder<AppAbility>(createPrismaAbility)
|
||||
|
||||
if (user.role === Role.Admin)
|
||||
can(Action.Manage, 'Todo')
|
||||
|
||||
if (user.role === Role.User) {
|
||||
can(Action.Create, 'Todo')
|
||||
can(Action.Read, 'Todo', { userId: user.id })
|
||||
can(Action.Update, 'Todo', { userId: user.id })
|
||||
can(Action.Delete, 'Todo', { userId: user.id })
|
||||
}
|
||||
|
||||
return build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'
|
||||
import { ApiTags } from '@nestjs/swagger'
|
||||
|
||||
import { BatchDeleteDto } from '@server/common/dto/delete.dto'
|
||||
|
||||
import { IdDto } from '@server/common/dto/id.dto'
|
||||
|
||||
import { AuthUser } from '../auth/decorators/auth-user.decorator'
|
||||
|
||||
import { Action } from '../casl/ability.class'
|
||||
import { Policy } from '../casl/policy.decortor'
|
||||
import { PolicyGuard } from '../casl/policy.guard'
|
||||
|
||||
import { TodoDto, TodoPagerDto, TodoUpdateDto } from './todo.dto'
|
||||
import { TodoService } from './todo.service'
|
||||
|
||||
@ApiTags('Business - Todo模块')
|
||||
@UseGuards(PolicyGuard)
|
||||
@Controller('todos')
|
||||
export class TodoController {
|
||||
constructor(private readonly todoService: TodoService) { }
|
||||
|
||||
@Get('page')
|
||||
@Policy({ model: 'Todo', action: Action.Manage })
|
||||
async list(@Query() dto: TodoPagerDto, @AuthUser() user: IAuthUser) {
|
||||
return this.todoService.paginate(dto, user.id)
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Policy({ model: 'Todo', action: Action.Read })
|
||||
async findOne(@Param() { id }: IdDto) {
|
||||
return this.todoService.findOne(id)
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Policy({ model: 'Todo', action: Action.Create })
|
||||
async create(@Body() dto: TodoDto, @AuthUser() user: IAuthUser) {
|
||||
await this.todoService.create(dto, user.id)
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Policy({ model: 'Todo', action: Action.Update })
|
||||
async update(@Param() { id }: IdDto, @Body() dto: TodoUpdateDto) {
|
||||
await this.todoService.update(id, dto)
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Policy({ model: 'Todo', action: Action.Delete })
|
||||
async delete(@Param() { id }: IdDto) {
|
||||
await this.todoService.delete(id)
|
||||
}
|
||||
|
||||
@Delete()
|
||||
async batchDelete(@Body() dto: BatchDeleteDto, @AuthUser() user: IAuthUser) {
|
||||
const { ids } = dto
|
||||
await this.todoService.batchDelete(ids, user.id)
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type TodoItem = Awaited<
|
||||
ReturnType<import('./todo.service').TodoService['findOne']>
|
||||
>
|
||||
|
||||
export type TodoList = Awaited<
|
||||
ReturnType<import('./todo.service').TodoService['paginate']>
|
||||
>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { basePagerSchema } from '@server/common/dto/pager.dto'
|
||||
import { TodoOptionalDefaultsSchema } from 'database/zod'
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const TodoInputSchema = TodoOptionalDefaultsSchema.pick({
|
||||
value: true,
|
||||
status: true,
|
||||
})
|
||||
|
||||
export class TodoDto extends createZodDto(TodoInputSchema) {}
|
||||
|
||||
export class TodoUpdateDto extends createZodDto(TodoInputSchema.partial()) {}
|
||||
|
||||
export class TodoPagerDto extends createZodDto(basePagerSchema.extend({
|
||||
sortBy: z.enum(['createdAt', 'updateAt']).optional(),
|
||||
// select: z.array(TodoSchema.keyof()).optional(),
|
||||
})) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module, Provider } from '@nestjs/common'
|
||||
|
||||
import { TodoAbility } from './todo.ability'
|
||||
import { TodoController } from './todo.controller'
|
||||
import { TodoService } from './todo.service'
|
||||
import { TodoTrpcRouter } from './todo.trpc'
|
||||
|
||||
const providers: Provider[] = [TodoService, TodoTrpcRouter, TodoAbility]
|
||||
|
||||
@Module({
|
||||
controllers: [TodoController],
|
||||
providers,
|
||||
exports: [...providers],
|
||||
})
|
||||
export class TodoModule {}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
|
||||
import { ExtendedPrismaClient, InjectPrismaClient } from '../../shared/database/prisma.extension'
|
||||
|
||||
import { TodoDto, TodoPagerDto, TodoUpdateDto } from './todo.dto'
|
||||
|
||||
@Injectable()
|
||||
export class TodoService {
|
||||
@InjectPrismaClient()
|
||||
private prisma: ExtendedPrismaClient
|
||||
|
||||
async paginate({
|
||||
page,
|
||||
limit,
|
||||
}: TodoPagerDto) {
|
||||
const [items, meta] = await this.prisma.todo.paginate({
|
||||
where: {
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}).withPages({
|
||||
page,
|
||||
limit,
|
||||
includePageCount: true,
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
async list(dto: TodoPagerDto, userId: string) {
|
||||
const { page, limit } = dto
|
||||
const [items, meta] = await this.prisma.todo.paginate({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}).withPages({
|
||||
page,
|
||||
limit,
|
||||
includePageCount: true,
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
return this.prisma.todo.findUniqueOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async create(dto: TodoDto, userId: string) {
|
||||
const { ...data } = dto
|
||||
return this.prisma.todo.create({
|
||||
data: {
|
||||
...data,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async update(id: string, dto: TodoUpdateDto) {
|
||||
return this.prisma.todo.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
return this.prisma.todo.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async batchDelete(ids: string[], userId: string) {
|
||||
const items = await this.prisma.todo.deleteMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
userId,
|
||||
},
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common'
|
||||
|
||||
import { BatchDeleteDto } from '@server/common/dto/delete.dto'
|
||||
import { IdDto } from '@server/common/dto/id.dto'
|
||||
import { TRPCRouter } from '@server/shared/trpc/trpc.decorator'
|
||||
import { defineTrpcRouter } from '@server/shared/trpc/trpc.helper'
|
||||
import { TRPCService } from '@server/shared/trpc/trpc.service'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Action } from '../casl/ability.class'
|
||||
|
||||
import { TodoInputSchema, TodoPagerDto } from './todo.dto'
|
||||
import { TodoService } from './todo.service'
|
||||
|
||||
@TRPCRouter()
|
||||
@Injectable()
|
||||
export class TodoTrpcRouter implements OnModuleInit {
|
||||
private router: ReturnType<typeof this.createRouter>
|
||||
|
||||
constructor(
|
||||
private readonly trpcService: TRPCService,
|
||||
private readonly todoService: TodoService,
|
||||
) { }
|
||||
|
||||
onModuleInit() {
|
||||
this.router = this.createRouter()
|
||||
}
|
||||
|
||||
private createRouter() {
|
||||
const procedureAuth = this.trpcService.procedureAuth
|
||||
return defineTrpcRouter('todo', {
|
||||
list: this.trpcService.procedureAuth
|
||||
.input(TodoPagerDto.schema)
|
||||
.meta({ model: 'Todo', action: Action.Read })
|
||||
.query(async (opt) => {
|
||||
const { input, ctx: { user } } = opt
|
||||
|
||||
return this.todoService.list(input, user.id)
|
||||
}),
|
||||
byId: procedureAuth
|
||||
.input(IdDto.schema)
|
||||
.meta({ model: 'Todo', action: Action.Read })
|
||||
.query(async (opt) => {
|
||||
const { input } = opt
|
||||
const { id } = input
|
||||
|
||||
return this.todoService.findOne(id)
|
||||
}),
|
||||
create: procedureAuth
|
||||
.input(TodoInputSchema)
|
||||
.meta({ model: 'Todo', action: Action.Create })
|
||||
.mutation(async (opt) => {
|
||||
const { input, ctx: { user } } = opt
|
||||
|
||||
return this.todoService.create(input, user.id)
|
||||
}),
|
||||
update: procedureAuth
|
||||
.input(TodoInputSchema.extend({ id: z.string(), value: z.string().optional() }))
|
||||
.meta({ model: 'Todo', action: Action.Update })
|
||||
.mutation(async (opt) => {
|
||||
const { input } = opt
|
||||
const { id, ...data } = input
|
||||
|
||||
return this.todoService.update(id, data)
|
||||
}),
|
||||
delete: procedureAuth
|
||||
.input(IdDto.schema)
|
||||
.meta({ model: 'Todo', action: Action.Delete })
|
||||
.mutation(async (opt) => {
|
||||
const { input } = opt
|
||||
const { id } = input
|
||||
|
||||
return this.todoService.delete(id)
|
||||
}),
|
||||
batchDelete: procedureAuth
|
||||
.input(BatchDeleteDto.schema)
|
||||
.meta({ model: 'Todo', action: Action.Delete })
|
||||
.mutation(async (opt) => {
|
||||
const { input, ctx: { user } } = opt
|
||||
const { ids } = input
|
||||
|
||||
return this.todoService.batchDelete(ids, user.id)
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { strongPasswordSchema } from '@server/modules/user/dto/password.dto'
|
||||
import { UserSchema } from 'database/zod'
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
export class UpdateProfileDto extends createZodDto(
|
||||
UserSchema.pick({
|
||||
// username: true,
|
||||
avatar: true,
|
||||
}).partial(),
|
||||
) { }
|
||||
|
||||
export class ResetPasswordDto extends createZodDto(z.object({
|
||||
password: strongPasswordSchema,
|
||||
})) { }
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const strongPasswordSchema = z.string().refine((value) => {
|
||||
const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\W_]{6,}$/
|
||||
return strongPasswordRegex.test(value)
|
||||
}, { message: '密码必须包含大小写字母和数字,且长度不能小于6位' })
|
||||
|
||||
export class PasswordUpdateDto extends createZodDto(z.object({
|
||||
oldPassword: strongPasswordSchema,
|
||||
newPassword: strongPasswordSchema,
|
||||
}).refine(data => data.oldPassword !== data.newPassword, {
|
||||
message: '新密码不能与旧密码相同',
|
||||
})) {}
|
||||
|
||||
export class UserPasswordDto extends createZodDto(z.object({
|
||||
id: z.string(),
|
||||
password: strongPasswordSchema,
|
||||
})) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { basePagerSchema } from '@server/common/dto/pager.dto'
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
export class UserSearchDto extends createZodDto(basePagerSchema.extend({
|
||||
keyword: z.string().optional(), // .min(1, { message: '关键字不能为空' }),
|
||||
sortBy: z.string().default('createdAt'),
|
||||
sortOrder: z.string()
|
||||
.or(z.enum(['asc', 'desc']))
|
||||
.optional(),
|
||||
})) { }
|
||||
@@ -0,0 +1,24 @@
|
||||
import { basePagerSchema } from '@server/common/dto/pager.dto'
|
||||
import { UserOptionalDefaultsSchema } from 'database/zod'
|
||||
|
||||
import { createZodDto } from 'nestjs-zod'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
const UserInputSchema = UserOptionalDefaultsSchema
|
||||
.extend({
|
||||
username: z.string().min(4, '用户名长度过短'),
|
||||
avatar: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
})
|
||||
|
||||
export class UserDto extends createZodDto(UserInputSchema) { }
|
||||
|
||||
export class UserUpdateDto extends createZodDto(UserInputSchema.partial()) { }
|
||||
|
||||
export class UserQueryDto extends createZodDto(
|
||||
basePagerSchema.extend({
|
||||
keyword: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
}),
|
||||
) { }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user