chore: init

This commit is contained in:
Kuizuo
2024-04-26 02:12:44 +08:00
commit 5231e5f565
298 changed files with 44435 additions and 0 deletions
+50
View File
@@ -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
View File
+21
View File
@@ -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"
]
}
]
}
+41
View File
@@ -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"
]
}
+22
View File
@@ -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,
},
},
],
}
+38
View File
@@ -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',
},
})
+9
View File
@@ -0,0 +1,9 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"builder": "swc",
"typeCheck": true,
"plugins": ["@nestjs/swagger"]
}
}
+159
View File
@@ -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"
}
}
+79
View File
@@ -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))
}
+8
View File
@@ -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) {}
+10
View File
@@ -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,
}),
) {}
+14
View File
@@ -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>
+23
View File
@@ -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}`,
)
},
})
+18
View File
@@ -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>
+11
View File
@@ -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>
+10
View File
@@ -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>
+7
View File
@@ -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'
+16
View File
@@ -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>
+15
View File
@@ -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>
+12
View File
@@ -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>
+13
View File
@@ -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'
+67
View File
@@ -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`)
}
})
}
+5
View File
@@ -0,0 +1,5 @@
export function catchError() {
process.on('unhandledRejection', (reason, p) => {
console.log('Promise: ', p, 'Reason: ', reason)
})
}
+124
View File
@@ -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
}
+75
View File
@@ -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()
+7
View File
@@ -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)
}
}
+16
View File
@@ -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)
}
}
+59
View File
@@ -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)
}
}
+17
View File
@@ -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
View File
@@ -0,0 +1,7 @@
export type TodoItem = Awaited<
ReturnType<import('./todo.service').TodoService['findOne']>
>
export type TodoList = Awaited<
ReturnType<import('./todo.service').TodoService['paginate']>
>
+18
View File
@@ -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
}
}
+86
View File
@@ -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