Compare commits

...

2 Commits

Author SHA1 Message Date
Kuizuo
4f44206d7d chore: update README.md 2024-05-05 20:40:43 +08:00
Kuizuo
326d16068f chore: fix some code 2024-05-05 20:37:52 +08:00
12 changed files with 177 additions and 69 deletions

View File

@ -1,27 +1,27 @@
{
"folders": [
{
"name": "ROOT",
"path": "../"
},
{
"name": "server",
"path": "../apps/server"
},
{
"name": "admin",
"path": "../apps/admin"
},
{
"name": "web",
"path": "../apps/web"
},
{
"name": "database",
"path": "../packages/database"
},
],
"settings": {
"testing.automaticallyOpenPeekView": "never"
"folders": [
{
"name": "ROOT",
"path": "../"
},
}
{
"name": "server",
"path": "../apps/server"
},
{
"name": "admin",
"path": "../apps/admin"
},
{
"name": "web",
"path": "../apps/web"
},
{
"name": "database",
"path": "../packages/database"
}
],
"settings": {
"testing.automaticallyOpenPeekView": "never"
}
}

135
README.md
View File

@ -1,7 +1,14 @@
# Nest + Prisma + tRPC + Zod
# Nest + tRPC + Prisma + Zod
这是一个使用 Nest.js、Prisma、tRPC 和 Zod 构建的现代化全栈应用程序的示例项目。
## ✨ 特性
- NestJS + tRPC 一套完整的类型安全方案,客户端 api 接入体验拉满!
- Prisma 现代化的 ORM 框架。
- 使用 Zod 替代 [class-validator](https://github.com/typestack/class-validator),让你无需编写繁琐的装饰器。
- [CASL.js](https://casl.js.org/) 完成复杂角色权限的验证。
## 🔧 技术栈
- Server
@ -16,13 +23,135 @@
## 📄 使用说明
撰写中...
你可以使用传统 Controller 的方式来编写接口,也可以定义 tRPC router 的形式,取决于你喜好。我个人针对用户端部分(Next.js、React Native) 会偏向于 tRPC而对于管理面板(Ant Design Pro)还是选用传统 Controller 方式。
### 运行项目
1. 克隆该项目
```
git clone https://github.com/kuizuo/nest-trpc-prisma-starter
```
2. 配置环境变量,将 .env.example 更改为 .env并配置好 postgresql 数据库变量。
3. 执行如下代码
```
pnpm i
pnpm dev
```
将会启动以下服务
Server: http://127.0.0.1:5001
Trpc placground: http://127.0.0.1:5001/api/trpc-playground
Admin: http://localhost:8000
Web: http://localhost:3000
### 创建一个 trpc 服务模块
实现代码参考
1. 假定你的 module 为 xxx在 modules/xxx 文件夹下创建 xxx.trpc.ts 文件,其代码可以参考 [todo.trpc.ts](./apps/server/src/modules/todo/todo.trpc.ts) 文件。这里贴上示例代码
```typescript
@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)
}),
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)
}),
}
}
```
2. 将 xxx.trpc.ts 在 xxx.module.ts 声明导入,同时在 [trpc.routers.ts](./apps/server/src/shared/trpc/trpc.routes.ts) 导入用于类型提示生成。
3. 此时便可在 client 端接入 trpc server这部分请参阅 [tRPC 文档](https://trpc.io/docs/client)。
### 如何进行权限控制
1. 首先要定义所访问的资源,也就是 Prisma 的 model可在 [ability.class.ts](./apps/server/src/modules/casl/ability.class.ts) 中查看详情,这里假定你的模块为 `XXX`
2. 在指定 module 中,创建 xxx.ability.ts可以仿造 [todo.ability.ts](./apps/server/src/modules/todo/todo.ability.ts) 进行编写。
3. 记得将 xxxAbility 作为 Provider 导入到 xxxModule 中CaslModule 会自动扫描所有 ability 注入服务。
4. 在 Controller 中使用装饰器 `@UseGuards(PolicyGuard)`,同样可参见 [todo.controller.ts](./apps/server/src/modules/todo/todo.controller.ts) 。并在指定控制器方法中 使用 `@Policy` 来声明该路由请求者所需的权限。
```typescript
export class TodoController {
@Get(':id')
@Policy({ model: 'Todo', action: Action.Read })
async findOne(@Param() { id }: IdDto) {
return this.todoService.findOne(id)
}
}
```
5. trpc 则使用 `.meta`,如下所示。
```typescript
defineTrpcRouter('todo', {
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)
}),
}
```
### VS Code 多根工作区
由于项目使用 Monorepo 进行管理,因此你可以打开 .vscode/project.code-workspace点击右下角 Open Workplace 打开多根工作区,如下图所示。
![image-20240505171520650](https://img.kuizuo.cn/2024/0505171846-image-20240505171520650.png)
## TODO
- [ ] 示例从 Todo List 更改成 Post
- [ ] 升级到 Trpc 11
- [ ] 升级 Tanstack Query 5
- [ ] Nest.js 集成 Auth.js
- [ ] 集成 [Auth.js](https://authjs.dev/)
## 相关项目
[Youni](https://github.com/kuizuo/youni) 一个基于 React Native 开发的校园社交应用。
## 参考

View File

@ -55,7 +55,6 @@
"@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",
@ -65,6 +64,7 @@
"cron": "^3.1.6",
"cron-parser": "^4.9.0",
"crypto-js": "^4.2.0",
"database": "workspace:*",
"dayjs": "^1.11.10",
"dotenv": "16.3.1",
"dotenv-expand": "^10.0.0",

View File

@ -3,5 +3,5 @@ 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": {} } }]] } };
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": {}, "page": {}, "findOne": {}, "create": {}, "update": {}, "delete": {}, "batchDelete": {} } }]] } };
};

View File

@ -1,14 +1,14 @@
import { Injectable, OnModuleInit } from '@nestjs/common'
import { BizException } from '@server/common/exceptions/biz.exception'
import { ErrorCodeEnum } from '@server/constants/error-code.constant'
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'
import { AuthService } from './auth.service'
@TRPCRouter()
@Injectable()
@ -41,7 +41,7 @@ export class AuthTrpcRouter implements OnModuleInit {
if (user.role === 'Admin')
throw new BizException(ErrorCodeEnum.PasswordMismatch)
const jwt = await this.authService.sign(user.id, user.role, { ip, ua })
const jwt = await this.authService.sign(user.id, user.role)
return {
data: { authToken: jwt },

View File

@ -5,9 +5,9 @@ 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 { UpdateProfileDto } from '../../user/dto/account.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 - 账户模块')
@ -27,9 +27,10 @@ export class AccountController {
@Put('profile')
async updateProfile(
@AuthUser() user: IAuthUser, @Body() dto: UpdateProfileDto,
@AuthUser() user: IAuthUser, @Body()
dto: UpdateProfileDto,
): Promise<void> {
await this.userService.updateProfile(user.id, dto)
await this.userService.updateProfile(dto, user.id)
}
@Get('logout')

View File

@ -14,7 +14,6 @@ export enum Action {
export type PrismaSubjects = {
User: User
Todo: Todo
}
export type AppAbility = PureAbility<[Action, Subjects<PrismaSubjects>]>

View File

@ -20,10 +20,16 @@ import { TodoService } from './todo.service'
export class TodoController {
constructor(private readonly todoService: TodoService) { }
@Get('page')
@Get()
@Policy({ model: 'Todo', action: Action.Manage })
async list(@Query() dto: TodoPagerDto, @AuthUser() user: IAuthUser) {
return this.todoService.paginate(dto, user.id)
return this.todoService.list(dto, user.id)
}
@Get('page')
@Policy({ model: 'Todo', action: Action.Manage })
async page(@Query() dto: TodoPagerDto, @AuthUser() user: IAuthUser) {
return this.todoService.paginate(dto)
}
@Get(':id')

View File

@ -29,15 +29,6 @@ export class UserPublicService {
},
select: {
...UserSelect,
gender: true,
yoId: true,
campus: {
select: {
id: true,
logo: true,
name: true,
},
},
},
}).catch(resourceNotFoundWrapper(
new BizException(ErrorCodeEnum.UserNotFound),

View File

@ -11,6 +11,7 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"reinstall": "rimraf pnpm-lock.yaml && rimraf package.lock.json && rimraf node_modules && npm run bootstrap",
"postinstall": "cp .env apps/server/.env && cp .env packages/database/.env",
"build": "turbo run build",
"dev": "turbo run dev",
"bundle": "pnpm -F server run bundle",

View File

@ -11,6 +11,7 @@
},
"main": "./client/index.js",
"scripts": {
"postinstall": "npm run db:generate",
"db:generate": "rimraf dist client && prisma generate && tsc && cp -r client dist/client",
"db:push": "prisma db push --skip-generate",
"migrate:dev": "prisma migrate dev",

View File

@ -484,12 +484,6 @@ importers:
'@trpc/server':
specifier: 10.45.1
version: 10.45.1
cookies-next:
specifier: ^4.1.1
version: 4.1.1
js-cookie:
specifier: ^3.0.5
version: 3.0.5
next:
specifier: 14.2.2
version: 14.2.2(@opentelemetry/api@1.8.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -3640,9 +3634,6 @@ packages:
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
'@types/node@16.18.96':
resolution: {integrity: sha512-84iSqGXoO+Ha16j8pRZ/L90vDMKX04QTYMTfYeE1WrjWaZXuchBehGUZEpNgx7JnmlrIHdnABmpjrQjhCnNldQ==}
'@types/node@20.12.7':
resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==}
@ -5319,9 +5310,6 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
cookies-next@4.1.1:
resolution: {integrity: sha512-20QaN0iQSz87Os0BhNg9M71eM++gylT3N5szTlhq2rK6QvXn1FYGPB4eAgU4qFTunbQKhD35zfQ95ZWgzUy3Cg==}
copy-anything@2.0.6:
resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==}
@ -17108,8 +17096,6 @@ snapshots:
'@types/node@12.20.55': {}
'@types/node@16.18.96': {}
'@types/node@20.12.7':
dependencies:
undici-types: 5.26.5
@ -19826,12 +19812,6 @@ snapshots:
cookie@0.6.0: {}
cookies-next@4.1.1:
dependencies:
'@types/cookie': 0.6.0
'@types/node': 16.18.96
cookie: 0.6.0
copy-anything@2.0.6:
dependencies:
is-what: 3.14.1