跟着一个请求走一遍:题目列表 · znzryb
## 第一站:路由表
所有接口都登记在一个文件里:`backend/internal/server/app/app.go` 的 `BuildRouter`。用的 Web 框架是 **Gin**。打开它,你会看到一百六十多行这样的东西:
```go
r.GET("/api/problems", auth.RequireUser(s), problems.List(s))
r.GET("/api/users/:username/blog", auth.OptionalUser(s), ladder.BlogListHandler(s))
r.POST("/api/ladder/chapters/:slug/quiz", auth.RequireUser(s), ladder.QuizSubmitHandler(s))
```
一行读法:**方法 + 路径 + 一串处理函数**。前面的 `auth.RequireUser(s)` 是中间件(先检查登录,没登录直接回 401),最后一个才是真正干活的 handler。
**想知道某个页面调的接口在哪实现,先在 `app.go` 里搜路径**,顺着最后那个函数名跳过去就行。
## 第二站:handler 是个「工厂函数」
`problems.List(s)` 不是 handler 本身,而是**返回** handler 的函数:
```go
// backend/internal/server/problems/problems.go
func List(s *state.App) gin.HandlerFunc {
return func(c *gin.Context) {
// ① 读参数、校验
// ② 直接写 SQL 查库
// ③ c.JSON(200, 结果)
}
}
```
为什么要包一层?因为 handler 需要连接池,而 Gin 规定 handler 的签名只能是 `func(c *gin.Context)`。外面那层把 `s` 「关」进闭包里,里面就能用 `s.DB` 了。整个后端的 handler 全是这个写法,看懂一个就看懂了全部。
## 第三站:SQL 就在 handler 里
`List` 里面大致是这样(省略了参数解析):
```go
if err := s.DB.QueryRow(ctx,
`SELECT count(*) FROM problems
WHERE is_published AND `+visibility.SQL(c, "")+`
AND ($1 = '' OR difficulty = $1) ...`,
diff, q, like, tags).Scan(&total); err != nil {
apierr.Abort(c, err)
return
}
rows, err := s.DB.Query(ctx, `SELECT p.slug, p.title, ...
FROM problems p LEFT JOIN accepted_records ar ... LEFT JOIN LATERAL (...)`, ...)
```
几个值得注意的小习惯:
- **参数一律用 `$1 $2` 占位**,不拼字符串 —— 防 SQL 注入;
- 能被管理员隐藏的东西(题目、章节),SQL 里都带一段**可见性条件**,这里是 `visibility.SQL(c, "")` 拼出来的;
- 出错统一交给 `apierr.Abort(c, err)`:它把错误变成 `{"error": {"code": ..., "message": ...}}` 这个固定形状,前端按 `code` 判断是哪种错。
## 一个请求的完整路径
把这三站串起来:
```text
浏览器 fetch('/api/problems?difficulty=pj')
→ nginx 看到 /api/ 前缀,转给 127.0.0.1:8080
→ Gin 按 app.go 的路由表找到这一行
→ auth.RequireUser:读 cookie 里的会话,确认是谁
→ problems.List 返回的那个闭包:校验参数 → s.DB.Query → c.JSON
→ 原路返回
```
**整条路上只有两个「层」**:中间件,和 handler。没有 Service 接口、没有 DAO 接口、没有依赖注入容器。