refactor: update backend stack and rename controllers to handlers in AGENTS.md

This commit is contained in:
mlogclub
2026-05-28 08:27:16 +08:00
parent 574d316b11
commit dd1b0d1b81
2 changed files with 110 additions and 112 deletions
+109 -111
View File
@@ -10,7 +10,7 @@
## 2. 固定技术栈
- 后端:`Golang` + `Iris` + `GORM` + `github.com/mlogclub/simple`
- 后端:`Golang` + `Gin` + `GORM` + `github.com/mlogclub/simple`
- 数据库:同时兼容 `SQLite``MySQL`
- 前端:`Next.js(App Router)` + `React` + `shadcn/ui` + `Tailwind CSS`
- 前端包管理器:`pnpm`
@@ -25,38 +25,46 @@
│ └── generator/
├── internal/
│ ├── bootstrap/
│ ├── config/
│ ├── builders/
│ ├── handlers/
│ │ ├── api/
│ │ ├── dashboard/
│ │ └── third/
│ ├── middleware/
│ ├── migration/
│ ├── models/
│ ├── repositories/
│ ├── services/
── controllers/
├── migration/
│ ├── dto/
├── convert/
│ ├── errorsx/
└── logx/
── pkg/
├── config/
├── dto/
├── enums/
├── errorsx/
├── httpx/
│ ├── logx/
│ └── utils/
├── web/
└── docs/
```
## 4. 后端分层
必须遵循单向依赖:`models -> repositories -> services -> controllers`
必须遵循单向依赖:`models -> repositories -> services -> handlers`
- `models`:只定义实体和表映射
- `repositories`:只封装数据访问
- `services`:负责业务规则、事务编排、聚合逻辑
- `controllers`:只做参数解析、权限校验、service 调用、响应封装
- `handlers`:只做参数解析、权限校验、service 调用、响应封装
禁止:
- controller 直接调用 repository
- handler 直接调用 repository
- 直接将 GORM model 返回前端
- 在 models/repositories 中写业务编排
## 4.1 分层全链路(models -> repositories -> services -> controllers -> builders
## 4.1 分层全链路(models -> repositories -> services -> handlers -> builders
本章是对“分层”规则的**可执行细化**:每一层只做该层应该做的事情,数据以 DTO 为中心流转,GORM 细节集中在 repository,事务边界集中在 service,返回组装集中在 builders/controller。
本章是对“分层”规则的**可执行细化**:每一层只做该层应该做的事情,数据以 DTO 为中心流转,GORM 细节集中在 repository,事务边界集中在 service,返回组装集中在 builders/handler。
### 4.1.1 依赖方向(必须)
@@ -66,28 +74,28 @@
- `repositories` → 依赖 `models`、基础库(`gorm`/`simple/sqls`
- `services` → 依赖 `repositories``models``enums/errorsx/utils`,负责事务与业务编排
- `builders` → 依赖 `models``dto/response`(必要时可依赖少量 `services` 用于补充展示字段,但优先在 service 里聚合好)
- `controllers` → 依赖 `services``builders``dto/request``web/params``web` 响应封装
- `handlers` → 依赖 `services``builders``pkg/dto/request``pkg/httpx/params``pkg/httpx` 响应封装
禁止反向依赖:
- `repositories` 不能依赖 `services/controllers/builders`
- `models` 不能依赖 `repositories/services/controllers/builders`
- `controllers` 不能依赖 `repositories`(必须通过 service
- `repositories` 不能依赖 `services/handlers/builders`
- `models` 不能依赖 `repositories/services/handlers/builders`
- `handlers` 不能依赖 `repositories`(必须通过 service
### 4.1.2 数据形态与流转(推荐统一)
一条典型 CRUD/业务动作的数据流:
1. **controller** 读取参数(query/body/form/path),做权限校验,调用 **service**
1. **handler** 读取参数(query/body/form/path),做权限校验,调用 **service**
2. **service** 执行业务规则(校验、幂等、状态机、聚合),必要时开启事务,调用 **repository**
3. **repository** 只做数据读写(CRUD + 查询),返回 `models` 或必要的聚合结构
4. **builders**`models`/聚合结果映射为 `response DTO`
5. **controller** 返回 `web.JsonData(...)``web.JsonPageData(...)`
5. **handler** 返回 `httpx.WriteJSON(...)`
强约束:
- **controller 入参使用 request DTO**
- **controller 出参使用 response DTO**
- **handler 入参使用 request DTO**
- **handler 出参使用 response DTO**
- **禁止直接返回 models 到前端**
### 4.1.3 各层“允许/禁止”清单
@@ -110,7 +118,7 @@
- **禁止**
- 业务编排(跨表流程、状态流转、事件发布等)
- 权限判断、登录态判断
- 直接拼接返回 DTODTO 映射属于 builders/controller
- 直接拼接返回 DTODTO 映射属于 builders/handler
Repository 最佳实践:
@@ -126,7 +134,7 @@ Repository 最佳实践:
- 事务编排:`sqls.WithTransaction(func(ctx *sqls.TxContext) error { ... })`
- 调用 builders 前的领域对象整理(如果 builders 只做映射更干净)
- **禁止**
- controller 才该做的事情:参数解析/HTTP 细节/响应封装
- handler 才该做的事情:参数解析/HTTP 细节/响应封装
- repository 才该做的事情:散落 GORM 查询(除非一次性复杂 SQL 且不值得抽)
Service 最佳实践:
@@ -136,7 +144,7 @@ Service 最佳实践:
#### builders(输出构建层)
定位:将 `models`(或 service 聚合结果)转换为 `response DTO`,避免 controller 写一堆映射样板代码。
定位:将 `models`(或 service 聚合结果)转换为 `response DTO`,避免 handler 写一堆映射样板代码。
- **允许**
- `Model -> ResponseDTO` 的纯映射
@@ -151,16 +159,16 @@ builders 推荐形式:
- 位置:`internal/builders/*_builder.go`
- 方法:`BuildXxx(item *models.Xxx) *response.Xxx` / `BuildXxxList(list []models.Xxx) []response.Xxx`
#### controllers(接口层)
#### handlers(接口层)
- **允许**
- 参数解析:`params.ReadJSON/ReadForm/NewPagedSqlCnd/GetInt64...`
- 权限:`AuthService.GetAuthPrincipal/RequirePermission/HasPermission`
- 调 service,调 builders,包装 `web.JsonData/JsonPageData/JsonError`
- 调 service,调 builders,包装 `httpx.WriteJSON`
- **禁止**
- 直接调用 repository
- 直接返回 models
-controller 内写业务编排(例如“先写 A 再写 B”)
-handler 内写业务编排(例如“先写 A 再写 B”)
### 4.1.4 “事务”最佳实践(替代模糊口号)
@@ -182,20 +190,23 @@ builders 推荐形式:
### 4.1.5 一个“标准接口”的代码骨架(示例)
```go
// Controller: 参数/权限/响应
func (c *XxxController) PostUpdate() web.JsonResult {
operator := services.AuthService.GetAuthPrincipal(c.Ctx)
if operator == nil {
return web.JsonErrorMsg("未登录或登录已过期")
// Handler: 参数/权限/响应
func XxxPostUpdate(ctx *gin.Context) {
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionXxxUpdate)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
var req request.UpdateXxxRequest
if err := params.ReadJSON(c.Ctx, &req); err != nil {
return web.JsonError(err)
req := request.UpdateXxxRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if err := services.XxxService.UpdateXxx(req, operator); err != nil {
return web.JsonError(err)
httpx.WriteJSON(ctx, err)
return
}
return web.JsonSuccess()
httpx.WriteJSON(ctx, nil)
}
```
@@ -233,8 +244,8 @@ func BuildXxx(item *models.Xxx) *response.Xxx {
- DB 初始化后必须执行:`sqls.SetDB(db)`
- 查询条件优先使用:`sqls.Cnd`
- 参数绑定优先使用:`web/params`
- HTTP 响应统一使用:`web.JsonData``web.JsonPageData``web.JsonError`
- 参数绑定优先使用:`internal/pkg/httpx/params`
- HTTP 响应统一使用:`internal/pkg/httpx.WriteJSON`
- 写操作事务边界按 **4.1.4 事务最佳实践** 执行(禁止“单条写 SQL 也默认开事务”的口号式规则)
## 6. 数据库兼容规则
@@ -253,7 +264,7 @@ func BuildXxx(item *models.Xxx) *response.Xxx {
- 生成库:`github.com/mlogclub/codegen`
- 注册方式:`codegen.GetGenerateStruct(&models.XXX{})`
- 生成文件建议放在 `generated` 目录,命名为 `*_gen.go`
- 生成代码只负责基础 CRUD,业务逻辑必须写在手写 service/controller 中
- 生成代码只负责基础 CRUD,业务逻辑必须写在手写 service/handler 中
标准流程:
@@ -303,98 +314,85 @@ func BuildXxx(item *models.Xxx) *response.Xxx {
### 8.4 路由注册
- 业务后台统一在 `internal/bootstrap/server.go`通过 `mvc.Configure(app.Party("/api/dashboard"), ...)` 注册
- 开放接口按领域归档,如 `mvc.Configure(app.Party("/api"), ...)`
- 在分组内部通过 `m.Party("/xxx").Handle(...)` 挂载资源
- 不要为每个资源单独再写一层顶级 `mvc.Configure(app.Party("/api/dashboard/xxx"), ...)`
- Gin 引擎统一在 `internal/bootstrap/server.go`创建,中间件也在这里按顺序注册
- 路由按分组函数拆到 `internal/bootstrap/routes.go``internal/bootstrap/*_routes.go`
- 业务后台统一通过 `dashboardGroup := app.Group("/api/dashboard", middleware.AuthMiddleware)` 注册
- 开放接口按领域归档到 `/api/*` 分组,第三方回调归档到 `/api/third/*` 分组
- 在分组内部通过 `group.GET/POST/PUT/DELETE/Any(...)` 显式挂载 handler
- 不要为每个资源单独再创建一层顶级 `app.Group("/api/dashboard/xxx")`
- 认证与鉴权中间件优先挂在 `/api/dashboard``/api/admin` 这一层
### 8.5 Iris MVC 自动路由规则
### 8.5 Gin 显式路由规则
本项目使用 Iris MVC 自动路由,controller 方法名必须按 Iris 规则命名,不能按个人习惯随意写
本项目使用 Gin 显式路由,不使用框架自动路由。handler 方法名只用于代码组织,最终 URL 以 `internal/bootstrap/*_routes.go` 中注册的路径为准
- controller 挂载方式示例:
- 路由挂载方式示例:
```go
m.Party("/quick-reply").Handle(new(dashboard.QuickReplyController))
func registerDashboardQuickReplyRoutes(group *gin.RouterGroup) {
group.Any("/list", dashboard.QuickReplyAnyList)
group.GET("/:id", dashboard.QuickReplyGetBy)
group.POST("/create", dashboard.QuickReplyPostCreate)
group.POST("/update", dashboard.QuickReplyPostUpdate)
group.POST("/delete", dashboard.QuickReplyPostDelete)
}
```
在上面的注册下,controller 的基础路径就是 `/quick-reply`,最终完整路径再拼上外层分组,如 `/api/dashboard/quick-reply`
- controller 名称 `QuickReplyController` 不会自动变成路径,路径以 `m.Party("/quick-reply")` 为准
- 方法名前缀决定 HTTP Method
- `AnyXxx`:匹配任意方法
- `GetXxx`:匹配 `GET`
- `PostXxx`:匹配 `POST`
- `PutXxx`:匹配 `PUT`
- `DeleteXxx`:匹配 `DELETE`
- 方法名后缀决定子路径:
- `Any()` -> `/`
- `AnyList()` -> `/list`
- `PostCreate()` -> `/create`
- `PostUpdate()` -> `/update`
- `PostDelete()` -> `/delete`
- `GetBy(id int64)` -> `/{id}`
- `GetMessageList()` -> `/message/list`
- `PostSendMessage()` -> `/send/message`
- `PostSend_message()` -> `/send_message`
- `By` 表示路径参数,不是普通单词:
- `GetBy(id int64)` -> `GET /{id}`
- `GetUserBy(id int64)` -> `GET /user/{id}`
- 不要把本来想要 `/list``/detail``/create` 的接口误写成带 `By` 的方法
- 多个参数会继续追加路径参数:
- `GetBy(projectId int64, episodeId int64)` -> `GET /{projectId}/{episodeId}`
- 本项目默认不鼓励这样设计,除详情场景外优先用 query/body
- `AnyList()` 虽然可匹配任意方法,但本项目约定它用于列表查询,前端应按 `GET /list` 使用
- 写接口统一用 `PostCreate()``PostUpdate()``PostDelete()`,不要写成 `AnyCreate()``GetUpdate()` 这类不符合语义的命名
- 在上面的注册下,资源基础路径由外层 `dashboardGroup.Group("/quick-reply")` 决定,最终完整路径为 `/api/dashboard/quick-reply/list``/api/dashboard/quick-reply/{id}`
- handler 命名建议保留现有可读前缀:`XxxAnyList``XxxGetBy``XxxPostCreate``XxxPostUpdate``XxxPostDelete`
- handler 命名不产生路由;新增接口前必须修改对应 `register...Routes` 函数
- HTTP Method 必须由 Gin 注册方法决定
- 列表查询:优先 `group.Any("/list", XxxAnyList)`,前端按 `GET /list` 使用
- 详情查询:优先 `group.GET("/:id", XxxGetBy)`
- 写接口:统一 `group.POST("/create|/update|/delete", XxxPost...)`
- 业务动作:统一显式路径,如 `group.POST("/send_message", ConversationPostSend_message)`
- path param 只在详情或强路径语义场景使用;普通过滤条件继续使用 query/body
当前项目常见正确映射示例:
- `m.Party("/user").Handle(new(dashboard.UserController))`
- `AnyList()` -> `ANY /api/dashboard/user/list`
- `GetBy(id int64)` -> `GET /api/dashboard/user/{id}`
- `PostCreate()` -> `POST /api/dashboard/user/create`
- `PostUpdate()` -> `POST /api/dashboard/user/update`
- `PostDelete()` -> `POST /api/dashboard/user/delete`
- `m.Party("/conversation").Handle(new(dashboard.ConversationController))`
- `AnyList()` -> `ANY /api/dashboard/conversation/list`
- `GetBy(id int64)` -> `GET /api/dashboard/conversation/{id}`
- `AnyMessageList()` -> `ANY /api/dashboard/conversation/message/list`
- `PostSendMessage()` -> `POST /api/dashboard/conversation/send/message`
- 如果业务明确要求下划线路径,则方法名写成 `PostSend_message()`,对应 `POST /api/dashboard/conversation/send_message`
- `registerDashboardUserRoutes(dashboardGroup.Group("/user"))`
- `group.Any("/list", dashboard.UserAnyList)` -> `ANY /api/dashboard/user/list`
- `group.GET("/:id", dashboard.UserGetBy)` -> `GET /api/dashboard/user/{id}`
- `group.POST("/create", dashboard.UserPostCreate)` -> `POST /api/dashboard/user/create`
- `group.POST("/update", dashboard.UserPostUpdate)` -> `POST /api/dashboard/user/update`
- `group.POST("/delete", dashboard.UserPostDelete)` -> `POST /api/dashboard/user/delete`
- `registerDashboardConversationRoutes(dashboardGroup.Group("/conversation"))`
- `group.Any("/list", dashboard.ConversationAnyList)` -> `ANY /api/dashboard/conversation/list`
- `group.GET("/:id", dashboard.ConversationGetBy)` -> `GET /api/dashboard/conversation/{id}`
- `group.Any("/message/list", dashboard.ConversationAnyMessage_list)` -> `ANY /api/dashboard/conversation/message/list`
- `group.POST("/send_message", dashboard.ConversationPostSend_message)` -> `POST /api/dashboard/conversation/send_message`
容易写错的点:
- 不要以为 `GetList()` 会生成 `/list` 的通用查询接口;它只会是 `GET /list`,而当前项目统一使用 `AnyList()`
- 不要把详情接口写成 `GetDetail()`,那会生成 `GET /detail`不是 `GET /{id}`
- 不要把动作接口写成 `PostBy(id int64)` 这类混合命名,除非你真的需要 `POST /{id}`
- CamelCase 方法名会按单词切分成多段路径,不要想当然把 `PostSendMessage()` 理解成 `/sendMessage`
- 如果接口契约要求单段路径或下划线形式,优先显式使用下划线命名,例如 `PostSend_message()` -> `/send_message`
- controller 新增方法前,先根据方法名手工推导一次最终 URL,确认与前端约定一致再落代码
- 不要以为新增 `XxxGetList` 方法会自动出现 `/list` 路由;必须在 routes 文件显式注册
- 不要把详情接口注册成 `/detail`当前约定详情为 `GET /:id`
- 不要随意新增深层嵌套路由;从属资源优先通过 `projectId``conversationId` 等普通参数过滤
- 如果接口契约要求下划线路径,直接在 Gin 路由里写下划线路径,例如 `group.POST("/send_message", ...)`
- handler 新增方法前,先写出对应 Gin 路由注册,确认最终 URL 与前端约定一致再落代码
### 8.6 Controller 约定
### 8.6 Handler 约定
- 每个资源一个 controller 文件
- 结构体统一:`type XxxController struct { Ctx iris.Context }`
- 方法命名遵循 Iris MVC
- `AnyList()`
- `GetBy(id int64)`
- `PostCreate()`
- `PostUpdate()`
- `PostDelete()`
- 业务动作可扩展 `PostTest()``PostGenerate()`
- 每个资源一个 handler 文件,位置为 `internal/handlers/{api|dashboard|third}/*_handler.go`
- handler 统一形式:`func XxxPostCreate(ctx *gin.Context)`
- 方法命名建议
- `XxxAnyList(ctx *gin.Context)`
- `XxxGetBy(ctx *gin.Context)`
- `XxxPostCreate(ctx *gin.Context)`
- `XxxPostUpdate(ctx *gin.Context)`
- `XxxPostDelete(ctx *gin.Context)`
- 业务动作可扩展 `XxxPostTest(ctx *gin.Context)``XxxPostGenerate(ctx *gin.Context)`
- `AnyList()` 分页列表要求:
- `AnyList` 分页列表要求:
- 使用 `params.NewPagedSqlCnd(...)`
- 每个筛选字段通过 `params.QueryFilter` 显式声明
- 默认排序优先 `.Desc("id")`;特殊排序需明确理由
- service 层优先使用 `FindPageByCnd(...)`
- controller 层做 DTO 映射,禁止直接返回 model 列表
- handler 层做 DTO 映射,禁止直接返回 model 列表
- 分页返回统一为:
```go
return web.JsonData(&web.PageResult{Results: results, Page: paging})
httpx.WriteJSON(ctx, &web.PageResult{Results: results, Page: paging})
```
- 分页 `data` 结构必须为:
@@ -403,15 +401,15 @@ return web.JsonData(&web.PageResult{Results: results, Page: paging})
- `data.page.limit`
- `data.page.total`
- 详情优先返回 `web.JsonData(dto)`
- 删除优先返回 `web.JsonSuccess()`
- 详情优先返回 `httpx.WriteJSON(ctx, dto)`
- 删除优先返回 `httpx.WriteJSON(ctx, nil)`
- JSON body 优先使用 `params.ReadJSON`
- form 参数优先使用 `params.ReadForm`
- 获取单个参数可以使用 `params.GetInt64``params.GetInt64Arr``params.Get`
- 分页和 query 优先使用 `params.NewPagedSqlCnd`
- 登录态用户通过 `services.AuthService.GetAuthPrincipal(c.Ctx)` 获取
- 登录态用户通过 `services.AuthService.GetAuthPrincipal(ctx)``RequirePermission(ctx, ...)` 获取
- 权限判断统一通过 `services.AuthService.HasPermission(...)``RequirePermission(...)`
- 鉴权失败统一返回 `web.JsonErrorMsg(...)`
- 鉴权失败统一返回 `httpx.WriteJSON(ctx, err)`
- `gorm.ErrRecordNotFound` 等错误应转换成明确业务提示
- 后端数据返回时,将数据转换成Response DTO相关的逻辑可以放到 `internal/builders` 包下。