From ab83ad96b4ccda3843c5b59e6f6b60de8dafb83d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E8=82=B2=E6=96=B0?= <2635131263@qq.com> Date: Sat, 17 Oct 2026 17:06:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(lyra)!:=20=E6=8E=A5=E5=85=A5=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E6=9F=A5=E8=AF=A2=E5=B9=B6=E9=9B=86=E4=B8=AD=E7=BB=84?= =?UTF-8?q?=E8=A3=85=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 Squirrel 与 sqlx 实现分类查询 - 集中组装依赖并向 handler 注入 model 接口 - 显式转换分类响应 DTO,统一空列表输出 - 补充路由与 handler 测试,同步架构文档 BREAKING CHANGE: 将 GET /hello 替换为 GET /test,响应由问候字符串改为分类列表 --- go/projects/lyra/README.md | 52 ++++--- go/projects/lyra/cmd/lyra/dependency.go | 29 ++++ go/projects/lyra/cmd/lyra/main.go | 4 +- go/projects/lyra/docs/architecture.md | 93 +++++++------ ...2026-10-17-architecture-review-followup.md | 42 ++++++ .../2026-10-17-router-regression-test.md | 19 +++ ...2026-10-17-squirrel-sqlx-query-practice.md | 66 +++++++++ go/projects/lyra/go.mod | 3 + go/projects/lyra/go.sum | 7 + go/projects/lyra/internal/api/category.go | 9 ++ go/projects/lyra/internal/handler/hello.go | 13 -- go/projects/lyra/internal/handler/test.go | 43 ++++++ .../lyra/internal/handler/test_test.go | 128 ++++++++++++++++++ go/projects/lyra/internal/model/category.go | 46 +++++++ go/projects/lyra/internal/router/router.go | 12 +- .../lyra/internal/router/router_test.go | 69 ++++++++-- 16 files changed, 546 insertions(+), 89 deletions(-) create mode 100644 go/projects/lyra/cmd/lyra/dependency.go create mode 100644 go/projects/lyra/docs/plans/2026-10-17-architecture-review-followup.md create mode 100644 go/projects/lyra/docs/plans/2026-10-17-router-regression-test.md create mode 100644 go/projects/lyra/docs/plans/2026-10-17-squirrel-sqlx-query-practice.md create mode 100644 go/projects/lyra/internal/api/category.go delete mode 100644 go/projects/lyra/internal/handler/hello.go create mode 100644 go/projects/lyra/internal/handler/test.go create mode 100644 go/projects/lyra/internal/handler/test_test.go create mode 100644 go/projects/lyra/internal/model/category.go diff --git a/go/projects/lyra/README.md b/go/projects/lyra/README.md index 94c0963..7c0ab59 100644 --- a/go/projects/lyra/README.md +++ b/go/projects/lyra/README.md @@ -1,31 +1,41 @@ # Lyra -链接、分类、设置等资源的后端服务(规划中)。目标结构与技术选型见 +链接、分类、设置等资源的后端服务(实现中)。目标结构与技术选型见 [docs/architecture.md](docs/architecture.md)。 ## 当前状态 -已搭建目录骨架、程序入口 `main.go` 与 HTTP 通路:启动时加载 `etc/lyra.toml`, -注册 `GET /hello`(经 `httpx.OK` 输出响应)并启动 HTTP 服务;路由注册配有 -httptest 回归测试,不监听实际端口、不连接数据库。各内部目录的职责与依赖 -约束记录在 `AGENTS.md` 中。数据库迁移 SQL 已就位:`migrations/00001_init.sql` -(初始函数与三张表),通过手工 goose CLI 执行,操作手册见 +HTTP 通路已延伸到数据库:启动时加载本地 `.env.local` 与 `etc/lyra.toml`,经 +sqlx 连接 PostgreSQL(注册 pgx stdlib 驱动,驱动名取自配置),组装依赖后注册 +`GET /test`——由 `handler.TestHandler` 调用 `model.CategoryModel.FindAll` 查询 +`public.categories`(当前固定 `sort >= 1`、按 sort/id 排序、limit 10),并经 +显式转换为本轮新增的 `api.CategoryResponse` 响应 DTO 后输出。该接口用于验证 +handler→model→数据库链路,不是完整业务 API。 + +数据库连接代码当前在入口 `main` 中,`internal/postgres` 与 `internal/service` +仍是占位(仅 `AGENTS.md`),业务 service 未实现;连接与查询未设置超时,连接 +真实数据库的端到端验证本轮也未做。 + +`internal/model` 已落地 `Category` 结构、`CategoryModel` 接口与 Squirrel + +sqlx 实现。路由与 handler 均有基于替身的 httptest 回归测试(不监听实际端口、 +不连接数据库),不证明真实 SQL 可用。各内部目录的职责与依赖约束记录在各目录 +`AGENTS.md` 中。数据库迁移 SQL 已就位:`migrations/00001_init.sql`(初始函数 +与三张表),通过手工 goose CLI 执行,操作手册见 [docs/goose.md](docs/goose.md),迁移目录说明见 -[migrations/README.md](migrations/README.md);Go 应用侧的数据库连接与 -Goose Go 依赖仍未实现,真实业务逻辑同样未实现;架构文档中标注“待新增”的 -文件(links 等)尚未创建。 +[migrations/README.md](migrations/README.md);Goose 的 Go 依赖仍未引入。 +架构文档中标注“待新增”的文件(links 等)尚未创建。 ## 目录职责 | 路径 | 职责 | |---|---| -| `cmd/lyra/` | 程序入口:启动、依赖组装、退出清理 | +| `cmd/lyra/` | 程序入口:启动、依赖组装、退出清理(数据库连接当前建在此处) | | `internal/api/` | HTTP 请求/响应 DTO,按资源分文件(平铺) | | `internal/config/` | 配置定义与加载 | -| `internal/postgres/` | PostgreSQL 连接(pgx stdlib,待实现) | +| `internal/postgres/` | PostgreSQL 连接的目标位置(占位:连接暂在入口 main) | | `internal/router/` | 路由注册、项目路径前缀(router.go,平铺) | | `internal/handler/` | 参数绑定、DTO 转换、响应输出;可按业务分包,服务级简单接口平铺在根包 | -| `internal/service/` | 业务规则、事务协调;可按业务分包,服务级简单接口平铺在根包 | +| `internal/service/` | 业务规则、事务协调;可按业务分包,服务级简单接口平铺在根包(占位) | | `internal/model/` | 数据结构定义与数据访问(SQL,平铺) | | `internal/httpx/` | 公共响应辅助 | | `migrations/` | Goose SQL 迁移文件(00001 初始迁移已就位) | @@ -33,18 +43,24 @@ Goose Go 依赖仍未实现,真实业务逻辑同样未实现;架构文档 ## 运行 -Go 命令在本目录(module 根)下执行;配置路径 `etc/lyra.toml` 按当前工作目录 -解析,换目录运行会找不到配置: +Go 命令在本目录(module 根)下执行;`.env.local` 与配置路径 `etc/lyra.toml` +均按当前工作目录解析,换目录运行会找不到它们: ```bash go run ./cmd/lyra # 启动 HTTP 服务,监听地址与端口见 etc/lyra.toml -go test ./... # 含路由回归测试 +go test ./... # 回归测试(不监听实际端口、不连接数据库) ``` +启动前提:本地存在 `.env.local`(提供数据库口令等环境变量,已被 gitignore、 +不入库,内容不在文档展开)、`etc/lyra.toml` 配置完整,且其指向的 PostgreSQL +可达;连接失败时启动即退出。 + 配置中的 `app.env` 直接传给 `gin.SetMode`,仅支持 `debug`/`release`/`test`。 +环境变量方面当前仅 `database.password` 绑定了 `PG_PASSWORD`,其余配置项不 +支持环境变量覆盖,也没有校验与默认值。 ## 技术栈 -已引入 Gin 与 Viper(读取 TOML 配置),go.sum 已生成; -PostgreSQL、Squirrel + sqlx(pgx stdlib 驱动)与 Goose 为目标选型,随后续功能引入; -数据库迁移已用独立 goose CLI(v3.29.0)管理,Goose Go 依赖尚未引入。 +已引入并使用:Gin(HTTP 路由)、Viper(读取 TOML 配置)、godotenv(加载 +`.env.local`)、sqlx + Squirrel(查询构造与执行)、pgx stdlib(PostgreSQL +驱动)。Goose 仅以独立 CLI(v3.29.0)手工执行迁移,Go 依赖尚未引入。 diff --git a/go/projects/lyra/cmd/lyra/dependency.go b/go/projects/lyra/cmd/lyra/dependency.go new file mode 100644 index 0000000..6d5e05c --- /dev/null +++ b/go/projects/lyra/cmd/lyra/dependency.go @@ -0,0 +1,29 @@ +package main + +import ( + "lyra/internal/config" + "lyra/internal/handler" + "lyra/internal/model" + "lyra/internal/router" + + "github.com/jmoiron/sqlx" +) + +type Dependencies struct { + Config *config.Config + CategoryModel model.CategoryModel +} + +// buildHandlers 集中组装各路由的处理器,入口启动时调用一次。 +func buildHandlers(dpd *Dependencies) router.Handlers { + return router.Handlers{ + TestHandler: handler.NewTestHandler(dpd.CategoryModel).Hello(), + } +} + +func buildDependencies(cfg *config.Config, db *sqlx.DB) *Dependencies { + return &Dependencies{ + Config: cfg, + CategoryModel: model.NewCategoryModel(db), + } +} diff --git a/go/projects/lyra/cmd/lyra/main.go b/go/projects/lyra/cmd/lyra/main.go index 4267a4a..a32c01d 100644 --- a/go/projects/lyra/cmd/lyra/main.go +++ b/go/projects/lyra/cmd/lyra/main.go @@ -41,7 +41,9 @@ func main() { } defer db.Close() - router.RegisterRoutes(ginEngine) + deps := buildDependencies(cfg, db) + handlers := buildHandlers(deps) + router.RegisterRoutes(ginEngine, handlers) addr := fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port) if err := ginEngine.Run(addr); err != nil { log.Fatal("启动 HTTP 服务失败,请检查端口是否被占用") diff --git a/go/projects/lyra/docs/architecture.md b/go/projects/lyra/docs/architecture.md index f7237a1..4b6e09e 100644 --- a/go/projects/lyra/docs/architecture.md +++ b/go/projects/lyra/docs/architecture.md @@ -2,68 +2,74 @@ 日期:2026-10-17 -本文记录已确认的目标目录结构与职责边界,并与当前真实实现同步。目录树是**目标结构**,并非全部已存在:标注“待新增”的文件或目录尚未创建,当前已有 Go 代码的是 `cmd/lyra`、`internal/{api,config,handler,httpx,router}`;`internal/{model,postgres,service}` 仅含 `AGENTS.md`(记录职责与依赖约束),暂不构成 Go 包。选型与讨论过程见 [plans/2026-10-16-lyra-architecture.md](plans/2026-10-16-lyra-architecture.md);handler/service 业务分包的调整记录见 [plans/2026-10-17-business-package-layout.md](plans/2026-10-17-business-package-layout.md)。 +本文记录已确认的目标目录结构与职责边界,并与当前真实实现同步。目录树是**目标结构**,并非全部已存在:标注“待新增”的文件尚未创建;标注“占位”的目录(`internal/postgres`、`internal/service`)仅含 `AGENTS.md`(记录职责与依赖约束),暂不构成 Go 包。当前已有 Go 代码的是 `cmd/lyra` 与 `internal/{api,config,handler,httpx,model,router}`。仓库内另有本地 `.env.local`(已被 gitignore、不入库)等文件,不属于目标结构,不在此展开。选型与讨论过程见 [plans/2026-10-16-lyra-architecture.md](plans/2026-10-16-lyra-architecture.md);handler/service 业务分包的调整记录见 [plans/2026-10-17-business-package-layout.md](plans/2026-10-17-business-package-layout.md)。 ## 已确认技术栈 -- 已引入:Gin v1.12.0(HTTP 路由)、Viper v1.21.0(配置读取,TOML)。`go.sum` 已由 Go 工具生成。 -- 目标选型(选型已确认,尚未作为代码依赖引入):PostgreSQL、Squirrel + sqlx(pgx stdlib 驱动)、Goose(数据库迁移;迁移 SQL 已就位,以独立 goose CLI v3.29.0 执行,Go 依赖未引入)。 +- 已引入并使用:Gin v1.12.0(HTTP 路由)、Viper v1.21.0(配置读取,TOML)、godotenv v1.5.1(启动时加载本地 `.env.local`)、sqlx v1.4.0 + Squirrel v1.5.4(查询构造与执行)、pgx v5.11.0 stdlib(PostgreSQL 驱动,由 sqlx 以 "pgx" 驱动名使用)。`go.sum` 已由 Go 工具生成。 +- Goose:迁移 SQL 已就位,以独立 goose CLI(v3.29.0)执行,Go 依赖尚未引入。 +- PostgreSQL 已在启动时连接;连接代码当前在入口 `main`,收拢到 `internal/postgres` 是目标结构。 ## 当前进度 -- 程序入口在 module 根目录以 `go run ./cmd/lyra` 启动:按相对当前工作目录的 `etc/lyra.toml` 加载配置(Viper,`mapstructure` 标签),已启动 HTTP 服务,监听地址与端口来自配置文件;换目录执行会找不到配置。 -- `app.env` 当前直接传给 `gin.SetMode`,取值仅 `debug`/`release`/`test`(Gin 的合法值);没有 dev/prod 等环境映射。 -- 已注册 `GET /hello`:`internal/router/router.go` 的 `RegisterRoutes` 将其指向 `handler.Hello`,经 `httpx.OK` 输出 `api.Response`;路由注册配有 httptest 回归测试,不监听实际端口、不连接数据库。 -- 已定义公共响应契约:`api.Response`(success/data/error)、`api.ErrorInfo`(code/message/debug)与错误码类型 `api.ErrorCode` 及其默认文案 `Message()`。 -- 已实现响应输出辅助 `httpx.OK`、`httpx.Fail`。 +- 程序入口在 module 根目录以 `go run ./cmd/lyra` 启动,流程为:`godotenv.Load` 读取本地 `.env.local`(不覆盖已存在的进程环境变量)→ `config.Load` 读取相对当前工作目录的 `etc/lyra.toml`(Viper,`mapstructure` 标签)→ `gin.SetMode` → 以 `gin.Default()` 创建引擎并 `SetTrustedProxies(nil)` 关闭代理信任 → `sqlx.ConnectContext` 建立 PostgreSQL 连接(失败即退出,`defer db.Close()`)→ `buildDependencies`/`buildHandlers` 组装依赖 → `RegisterRoutes` → 监听配置的地址与端口。`.env.local` 与 `etc/lyra.toml` 均按当前工作目录解析,换目录执行会找不到。连接与查询当前未设置超时。 +- 已注册 `GET /test`:`internal/router/router.go` 的 `RegisterRoutes` 将其指向入口注入的 `router.Handlers.TestHandler`(`gin.HandlerFunc`,由 `handler.NewTestHandler(...).Hello()` 构造)。处理器调用 `model.CategoryModel.FindAll` 查询 `public.categories`——当前固定 `sort >= 1`、按 `sort`/`id` 排序、`limit 10`——经显式转换为本轮新增的 `api.CategoryResponse` 后由 `httpx.OK` 输出;查询出错经 `httpx.Fail` 返回 500 与 `api.CodeInternalError`。该接口用于验证 handler→model→数据库链路,不是完整业务 API。 +- `internal/model` 已落地:`Category` 结构(id/slug/title/sort)、`CategoryModel` 接口与 `defaultCategoryModel` 实现——Squirrel 构造 SQL(Dollar 占位符),sqlx `SelectContext` 执行与映射。 +- 已定义公共响应契约:`api.Response`(success/data/error)、`api.ErrorInfo`(code/message/debug)与错误码类型 `api.ErrorCode` 及其默认文案 `Message()`;响应输出辅助 `httpx.OK`、`httpx.Fail` 已实现并被 handler 使用。 +- 配置:`database.password` 绑定了环境变量 `PG_PASSWORD`(可经 `.env.local` 或进程环境提供),其余配置项暂不支持环境变量覆盖;没有校验与默认值。`Redis` 配置项已定义但暂无消费方。 +- 测试:路由注册有 httptest 回归测试,覆盖 `GET /test` 注入替身处理器的调用,以及未知路径/不支持方法返回 404;handler 有基于假 `CategoryModel` 的替身测试,覆盖 DTO 转换、空结果输出 `data:[]`、模型出错返回 500 且不泄漏内部错误信息、请求 context 透传。两类测试均不监听实际端口、不连接数据库,因此不证明真实 SQL 可用——连接真实数据库的端到端验证本轮未做。 - 数据库迁移已就位:`migrations/00001_init.sql`(`set_updated_at()` 函数、users/categories/links 三张表、约束、注释、索引与触发器,建库内容与 [demo.sql](demo.sql) 一致),由手工 goose CLI(v3.29.0)执行,见 [../migrations/README.md](../migrations/README.md)。 -- 尚未实现:真实业务逻辑(`/hello` 只是 HTTP 通路示例)、Go 应用侧数据库访问与 Goose Go 依赖;配置校验、环境变量覆盖与默认值;`ErrorInfo.Debug` 的填充与按环境开关。日志方案未定。 +- 尚未实现:业务 service 层与完整业务 API;连接收拢到 `internal/postgres`(当前连接代码在入口 `main`);Goose Go 依赖;配置校验与默认值;`ErrorInfo.Debug` 的填充与按环境开关;连接/查询超时。日志方案未定。 ## 目录结构 ```text lyra/ -├── cmd/lyra/main.go # 启动、依赖组装、退出清理 +├── cmd/lyra/ +│ ├── main.go # 启动流程:加载 .env.local 与配置、连接数据库、组装依赖 +│ └── dependency.go # Dependencies 与 handler 组装 ├── internal/ -│ ├── api/ # HTTP 请求/响应 DTO、错误码与响应契约(平铺,按资源分文件) -│ │ ├── response.go # Response/ErrorInfo 结构 -│ │ ├── code.go # ErrorCode 与错误码常量、默认文案 -│ │ ├── links.go # links 资源 DTO 示例(待新增) -│ │ ├── categories.go # 待新增 -│ │ └── settings.go # 待新增 +│ ├── api/ # HTTP 请求/响应 DTO、错误码与响应契约(平铺,按资源分文件) +│ │ ├── response.go # Response/ErrorInfo 结构 +│ │ ├── code.go # ErrorCode 与错误码常量、默认文案 +│ │ ├── category.go # CategoryResponse 分类响应 DTO +│ │ ├── links.go # links 资源 DTO 示例(待新增) +│ │ └── settings.go # 待新增 │ ├── config/ -│ │ └── config.go # 配置定义与加载 -│ ├── postgres/ -│ │ └── postgres.go # 数据库连接(待新增) +│ │ └── config.go # 配置定义与加载 +│ ├── postgres/ # 数据库连接的目标位置(占位:仅 AGENTS.md,连接暂在 cmd/lyra/main.go) │ ├── router/ -│ │ └── router.go # 路由注册、项目路径前缀 +│ │ ├── router.go # 路由注册、项目路径前缀 +│ │ └── router_test.go # 路由 httptest 回归测试 │ ├── handler/ -│ │ ├── hello.go # GET /hello 处理(服务级简单接口,平铺在根包) -│ │ ├── links.go # 参数绑定、DTO 转换、响应输出(待新增) -│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包) -│ ├── service/ -│ │ ├── links.go # 业务规则、事务协调(待新增) -│ │ ├── links_test.go # 测试与源文件同目录(待新增) -│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包) +│ │ ├── test.go # GET /test 处理:调用 model、显式 DTO 转换、响应输出 +│ │ ├── test_test.go # handler 替身测试 +│ │ ├── links.go # 参数绑定、DTO 转换、响应输出(待新增) +│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包) +│ ├── service/ # 业务规则、事务协调(占位:仅 AGENTS.md) +│ │ ├── links.go # 业务规则、事务协调(待新增) +│ │ ├── links_test.go # 测试与源文件同目录(待新增) +│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包) │ ├── model/ -│ │ ├── link.go # Link 数据结构定义(待新增) -│ │ ├── link_model.go # 数据访问对象:CRUD 与 SQL(待新增) -│ │ └── link_model_test.go # 查询测试(待新增) +│ │ ├── category.go # Category 结构、CategoryModel 接口与 Squirrel+sqlx 实现 +│ │ ├── link.go # Link 数据结构定义(待新增) +│ │ ├── link_model.go # 数据访问对象:CRUD 与 SQL(待新增) +│ │ └── link_model_test.go # 查询测试(待新增) │ └── httpx/ -│ └── response.go # 公共响应输出辅助(OK/Fail) +│ └── response.go # 公共响应输出辅助(OK/Fail) ├── etc/ -│ └── lyra.toml # 运行配置(App/HTTP) -├── migrations/ # Goose SQL 迁移文件(00001_init.sql 初始迁移 + README) +│ └── lyra.toml # 运行配置(App/HTTP/Database/Redis) +├── migrations/ # Goose SQL 迁移文件(00001_init.sql 初始迁移 + README) ├── docs/ │ ├── plans/ -│ └── architecture.md # 本文 +│ └── architecture.md # 本文 ├── README.md ├── go.mod -└── go.sum # 已生成 +└── go.sum # 已生成 ``` -- 标注“待新增”的文件只是职责分布示例,随实现按需新增,不预建空文件或空目录;`internal/` 各目录均以 `AGENTS.md` 记录职责与依赖约束。 -- model 下的 `link*.go` 展示一个资源在各层的文件分布;分类、设置等资源按同样方式在 api/handler/service/model 新增对应文件。首个迁移已落地为 `migrations/00001_init.sql`。 +- 标注“待新增”的文件只是职责分布示例,随实现按需新增,不预建空文件或空目录;`internal/` 各目录均以 `AGENTS.md` 记录职责与依赖约束(树中未逐一画出)。`internal/postgres`、`internal/service` 目前仅有 `AGENTS.md`,为占位目录,不构成 Go 包。 +- model 下的 `link*.go` 展示一个资源在各层的文件分布;分类资源已按此方式在 api/model 落地(`api/category.go`、`model/category.go`,handler 为链路验证用的 `test.go`),设置等资源按同样方式新增。首个迁移已落地为 `migrations/00001_init.sql`。 - `handler/polaris/`、`service/polaris/` 仅示意业务分包的形态:实际分包与否随业务边界明确时决定,路由、api、model 不照搬同样的分包。 ## 组织原则 @@ -76,21 +82,22 @@ lyra/ ## 层间边界 -- 真实业务的主调用链为 handler→service→model→PostgreSQL;服务级简单接口可以不经过 service/model(当前 `/hello` 即是)。service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。 -- 请求/响应 DTO 集中在 internal/api,与内部模型分开;即使初期字段相近,也不默认把数据库模型直接暴露为响应,接受必要的显式转换代码。 +- 真实业务的主调用链为 handler→service→model→PostgreSQL;简单接口可以不经过 service——当前 `GET /test` 即由 handler 直接调用 model(service 层尚未实现)。service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。 +- 请求/响应 DTO 集中在 internal/api,与内部模型分开;即使初期字段相近,也不默认把数据库模型直接暴露为响应,接受必要的显式转换代码(分类列表已经 handler 的 `toCategoryResponses` 转为 `api.CategoryResponse`)。 - handler 引用 API DTO 完成绑定与转换;service/model 不依赖 Gin,也不依赖 internal/api DTO;api 包不反向引用 handler/service/model,避免传输契约与实现耦合或循环引用。 - internal/api 拥有响应契约与业务错误码(`Response`/`ErrorInfo`/`ErrorCode`);不依赖 Gin。 - internal/httpx 负责响应输出:`OK` 固定 HTTP 200;`Fail` 接收 HTTP 状态码与业务错误码,`message` 为空时回退错误码默认文案。HTTP 状态码表示传输/结果类别,`ErrorCode` 表示自定义业务错误,两者分开。 -- internal/router 负责把 handler 挂到路由上:当前注册 `GET /hello` 指向 `handler.Hello`,后续按业务项目路径前缀(如 `/api/polaris`)扩展。 -- 配置由入口 `main` 加载后组装并传递:`internal/config` 只负责配置定义与加载,运行配置来自 `etc/lyra.toml`;当前 `app.env` 直接传给 `gin.SetMode`(仅 `debug`/`release`/`test`),HTTP 地址端口供启动使用,数据库相关配置尚未有消费方。 -- model 承载数据结构定义与数据访问:Squirrel/SQL 构造、sqlx 执行与结果映射,驱动为 pgx stdlib;不是 ORM 自动持久化,也不要求每种类型机械建立一套结构。 +- internal/router 负责把 handler 挂到路由上:当前注册 `GET /test`,指向入口组装注入的 `Handlers.TestHandler`,后续按业务项目路径前缀(如 `/api/polaris`)扩展。 +- 配置由入口 `main` 加载后组装并传递:`internal/config` 只负责配置定义与加载,运行配置来自 `etc/lyra.toml`;当前 `app.env` 直接传给 `gin.SetMode`(仅 `debug`/`release`/`test`),HTTP 地址端口供启动使用,数据库相关配置由入口 `main` 组装 DSN 并用于连接(`database.password` 可来自 `PG_PASSWORD`),`Redis` 配置暂无消费方。 +- model 承载数据结构定义与数据访问:Squirrel/SQL 构造、sqlx 执行与结果映射,驱动为 pgx stdlib(已按此落地 `internal/model/category.go`);不是 ORM 自动持久化,也不要求每种类型机械建立一套结构。 - service/model 使用标准 context.Context,不接收 gin.Context。 ## 尚未确定 / 待实现 以下均为计划中的推荐或待确认项,尚未实现,也未定为已决: -- 配置校验、环境变量覆盖与默认值:尚未实现(当前无任何校验、覆盖或默认值)。 +- 配置校验与默认值:尚未实现。环境变量覆盖当前仅 `database.password` 绑定 `PG_PASSWORD`,其余配置项不支持。 +- 连接与查询超时:尚未设置,也未确定超时策略。 - `ErrorInfo.Debug` 的填充与按环境(dev)开关:尚未实现,目前仅有字段。 - 分页响应约定(如 data/meta 结构)与统一错误处理中间件:尚未引入,仅为历史讨论。 - Goose 迁移的部署执行入口已确定为手工 CLI(见 [../migrations/README.md](../migrations/README.md)),并发控制以“单操作者串行执行”为约定,数据库锁未引入,服务启动不自动迁移。 diff --git a/go/projects/lyra/docs/plans/2026-10-17-architecture-review-followup.md b/go/projects/lyra/docs/plans/2026-10-17-architecture-review-followup.md new file mode 100644 index 0000000..c0b50e5 --- /dev/null +++ b/go/projects/lyra/docs/plans/2026-10-17-architecture-review-followup.md @@ -0,0 +1,42 @@ +# 架构审查与提交前收尾 + +日期:2026-10-17 + +状态:审查及已批准修正完成,父会话已复查代码与文档;等待更新暂存区及确认提交信息。 + +## 当前结论 + +- 当前实际调用链为 main → buildDependencies → buildHandlers → router 的 GET /test → TestHandler.Hello → CategoryModel.FindAll → PostgreSQL。 +- 显式注入、入口容器边界、model 接口和路由依赖方向成立;当前单条只读查询不需要额外 service 或事务框架。 +- 审查发现的 DTO 边界与当前状态文档问题已修正:handler 显式转换为 api.CategoryResponse,README 与架构文档已同步。 +- 超时、DSN 编码、优雅关闭和查询语义命名列为后续增强,不扩入本轮修正。 + +## 已批准的最小方案 + +1. 增加分类响应 DTO,以明确 JSON 字段,由现有 TestHandler 显式转换;空列表保持 [],不改变 SQL。 +2. 用假的 CategoryModel 补 handler 测试,覆盖成功、空列表、失败和 context 传递,不连接数据库。 +3. 更新 README.md 和 docs/architecture.md 的当前实现说明,历史计划保留为历史。 +4. 最终执行格式检查、go test ./... -count=1、go build ./...、go vet ./...,父会话审阅最终差异。 +5. 依 git-commit 流程确认暂存范围;任何重新暂存后重新检查全部索引文件的完整内容,再展示提交信息并等待明确确认。 + +## 实施分工与范围 + +- 代码 lane(复用 fixer):仅写 internal/api/category.go、internal/handler/test.go、internal/handler/test_test.go,负责 DTO 转换与成功/空结果/失败/context 测试及 Go 验证。 +- 文档 lane(独立 fixer):仅写 README.md、docs/architecture.md,按当前磁盘源代码更新现状;不得声称真实数据库查询已验证。 +- 父会话维护本计划、整合终态并检查实际差异;不修改 SQL、路由名称、连接逻辑、依赖版本或其他历史计划。 +- 两个写入范围互不重叠;全部终态后确认验证证据仍适用,再进入重新暂存与完整索引安全检查。 + +## 已有验证及边界 + +- 当前未修正版本的 go test ./... -count=1、go build ./...、go vet ./...、gofmt -l . 全部通过。 +- 当前路由测试不连接数据库,仅验证路由分发;真实 handler 与 SQL 查询尚未验证。 +- 当前暂存的十个非删除文件完整内容已检查,未发现新增疑似真实敏感值或意外生成物;未暂存新的修正内容。 +- 审查所有者:oracle 静态审查、fixer 独立验证、父会话负责取舍及提交安全检查。 + +## 修正后的证据 + +- 新增分类响应 DTO 和 handler 替身测试;成功映射、空切片与 nil 结果输出 []、失败返回通用 500、请求 context 透传均已验证。 +- 代码 lane 在最终 Go 代码上执行 go test ./internal/handler -v -count=1、go test ./... -count=1、go build ./...、go vet ./... 均通过,新增及修改 Go 文件的格式检查通过。 +- 文档 lane 仅修改 README.md 与 docs/architecture.md;父会话已读取 DTO、handler、测试与两份文档并复查,未发现新的阻断问题。 +- 不重复运行未发生变化的 Go 代码验证;真实 SQL 与数据库端到端路径仍未验证。 +- 更新暂存区后必须重新检查全部暂存文件的完整版本,之前索引安全检查不自动适用于新内容。 diff --git a/go/projects/lyra/docs/plans/2026-10-17-router-regression-test.md b/go/projects/lyra/docs/plans/2026-10-17-router-regression-test.md new file mode 100644 index 0000000..75f8109 --- /dev/null +++ b/go/projects/lyra/docs/plans/2026-10-17-router-regression-test.md @@ -0,0 +1,19 @@ +# 重写当前路由回归测试 + +日期:2026-10-17 + +## 已批准范围 + +用户要求重写 router_test。当前路由为 GET /test,注册入口接收 router.Handlers,其中 TestHandler 为 gin.HandlerFunc。 + +- 仅新增或重写 internal/router/router_test.go,不修改生产代码、依赖或用户已调整的模型字段。 +- 测试使用 package router_test、gin.New、httptest 和注入的替身 handler,不启动端口、不访问数据库。 +- 覆盖 GET /test 调用注入处理器,并保留处理器自定义的状态码和响应体。 +- 覆盖未知路径和不支持的方法均不调用处理器;使用 gin.New 默认设置,两者预期 404。 +- 每个用例创建独立引擎;不使用 t.Parallel,避免 Gin 全局模式设置竞争。 + +## 验证所有者与证据 + +- fixer:只写测试文件,gofmt,执行 go test ./internal/router -v -count=1 与 go test ./...。 +- 父会话:检查最终差异、替身调用次数断言、当前路由名以及无数据库依赖。 +- 本轮证据仅覆盖路由注册与分发,不覆盖真实 TestHandler 或数据库查询。 diff --git a/go/projects/lyra/docs/plans/2026-10-17-squirrel-sqlx-query-practice.md b/go/projects/lyra/docs/plans/2026-10-17-squirrel-sqlx-query-practice.md new file mode 100644 index 0000000..d578021 --- /dev/null +++ b/go/projects/lyra/docs/plans/2026-10-17-squirrel-sqlx-query-practice.md @@ -0,0 +1,66 @@ +# Squirrel + sqlx 最小查询练习 + +日期:2026-10-17 + +## 范围与状态 + +- 前期由用户自行实现;2026-10-17 用户明确批准代改依赖组装示例。本轮仍不启动服务、不连接数据库。 +- 使用隔离的本地 PostgreSQL 测试库,不复用真实或共享环境。 +- 当前范围调整:用户先在已有 /hello handler 中手动验证查询;下方 categories 集成测试步骤保留为后续练习。 +- 临时在 handler 中只读查询 current_database(),不依赖业务表;正式业务查询再迁至 model。 + +## 当前 Hello 实验 + +### 本轮已批准实施:集中依赖组装 + +- 以用户现有代码为准:Hello 已通过 CategoryModel.FindAll 查询分类,保留该 SQL、过滤条件和结果映射,不改成系统信息查询。 +- 新增 cmd/lyra/dependencies.go,buildHandlers(db) 返回 router.Handlers,当前只装配 Hello(db)。 +- router.go 定义仅包含 Hello gin.HandlerFunc 的 Handlers,RegisterRoutes 接收它,不再导入 handler 或 sqlx。 +- main.go 仅把路由注册改为先 buildHandlers(db),再 RegisterRoutes;保留配置、连接和其他用户改动。 +- Hello 保持 Hello(db) 闭包接口,在返回闭包前创建 CategoryModel,避免每个请求重新构造;修复先返回问候再返回列表的重复响应,错误分支返回 HTTP 500 后立即 return。 +- 路由测试注入固定响应的替身 handler,验证 /hello 确实调用传入的处理器并维持现有响应契约,不触碰数据库。 +- 写入范围:cmd/lyra/main.go、cmd/lyra/dependencies.go、internal/router/router.go、internal/router/router_test.go、internal/handler/hello.go;不改 model、依赖文件或敏感配置。 +- fixer 负责首次编辑前确认缩进与换行、gofmt、go test ./... 和 go build ./...;父会话负责审阅最终差异、依赖方向和用户改动保留情况。 +- 验证边界:httptest 证明路由注入生效,构建证明组装链编译通过;不宣称真实数据库查询已验证。 + +实施结果:上述五个 Go 文件已完成调整。fixer 执行 gofmt、go test ./...、go build ./...、go vet ./... 均通过;父会话已审阅实际差异与新增 dependencies.go,git diff --check 通过。未启动服务或连接数据库,真实查询及 Hello 成功/失败分支尚无运行时验证。用户已有 model/category.go 和依赖文件改动保持不变。 + +### 历史练习建议(由上方现状覆盖) + +1. 沿用闭包:Hello(db *sqlx.DB) gin.HandlerFunc,捕获 main 已创建的连接池。 +2. router.RegisterRoutes 接收 gin.HandlerFunc,不依赖数据库;main 调用 handler.Hello(db) 完成组装。 +3. 每个请求从 Request.Context 派生五秒超时,使用 Squirrel 构造 SELECT current_database(),sqlx.GetContext 执行并映射字符串。 +4. 失败使用现有 httpx.Fail 与通用错误码,不返回数据库原始错误;成功使用 httpx.OK 返回当前数据库名。 +5. 路由测试注入返回固定响应的替身 handler,不连接数据库;保留现有路径、状态码和响应体断言。该测试不覆盖真实 Hello 查询逻辑。 +6. 用户在隔离本地环境运行服务并请求 /hello 验证真实查询;实验不要对外暴露数据库信息。 + +## 后续 categories 练习顺序 + +### 依赖组装组织建议(讨论记录,未代改代码) + +- 继续使用显式构造函数注入,不引入 Wire、全局连接或通用依赖容器。 +- 组装代码可以放在 cmd/lyra/dependencies.go,仍属于 package main;main 只调用 buildHandlers(db) 并注册路由。 +- router 可定义只包含 gin.HandlerFunc 的 Handlers 结构,RegisterRoutes 接收该结构,避免路由增加导致参数列表不断增长。 +- 当前只需要 Hello 字段,其他路由随实际实现添加,不提前创建 model/service 等占位代码。 +- 后续组装顺序为 db → model → 按需 service → handler;每层只接收实际需要的依赖,不传递整个应用容器。 +- 该方式减少 main 的杂乱,但不消除新增组件时必须更新组装关系的工作;对象在启动时构造,请求复用连接池。 +- Dependencies 位置建议:当前若确需该结构,直接定义在 cmd/lyra/dependencies.go,不为单个类型新建包。以后多个入口确实需要复用组装逻辑时,再迁移到 internal/app;该包作为组装上层可引用 model/service/handler/router,业务包不得反向引用 app。当前不创建新目录或依赖容器。 + +1. 在 module 根目录引入 github.com/Masterminds/squirrel。 +2. 准备已执行现有迁移的隔离测试库;如需数据,用户单独准备至少一个分类,测试本身不写数据。 +3. 测试从 LYRA_TEST_DATABASE_URL 读取连接串;缺失时跳过,不回退到开发配置。 +4. 注册 pgx stdlib 驱动,创建五秒超时 Context,以 sqlx.ConnectContext 连接并清理连接。 +5. 定义带 db 标签的局部结果结构,使用 Squirrel 显式声明列、可选 sort 条件、sort/id 排序、有限条数和 Dollar 占位符。 +6. 调用 ToSql 获取 SQL 和 args,通过 db.SelectContext 执行并映射切片。 +7. 先验证 SQL/args,再验证真实查询;通过后再提取正式 model 方法。 + +## 验证与注意事项 + +- Go 命令在 go/projects/lyra 执行。 +- 查询构造测试应验证使用 $1 而非 ?,过滤值位于 args 中而非拼入 SQL。 +- 集成测试验证目标库身份、查询无错误、字段映射、过滤与排序。 +- 空表返回空切片属于成功,但不能证明非空数据映射和排序正确;需要已知数据才能完成这些验收。 +- 环境变量缺失导致 SKIP 不算数据库查询已通过。 +- 不记录 DSN、密码或可能包含凭据的连接错误原文。 +- 完成后执行 go test ./internal/model -run TestCategoryQuery -v -count=1。 +- 正式复用时使用 sqlx.SelectContext(ctx, executor, ...) 支持 *sqlx.DB 和 *sqlx.Tx;sqlx.ExtContext 接口本身没有 SelectContext 方法。 diff --git a/go/projects/lyra/go.mod b/go/projects/lyra/go.mod index 8374625..2485f58 100644 --- a/go/projects/lyra/go.mod +++ b/go/projects/lyra/go.mod @@ -10,6 +10,7 @@ require ( ) require ( + github.com/Masterminds/squirrel v1.5.4 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -29,6 +30,8 @@ require ( github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go/projects/lyra/go.sum b/go/projects/lyra/go.sum index edc3b30..1a71135 100644 --- a/go/projects/lyra/go.sum +++ b/go/projects/lyra/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -60,6 +62,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -99,6 +105,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/go/projects/lyra/internal/api/category.go b/go/projects/lyra/internal/api/category.go new file mode 100644 index 0000000..d03df2e --- /dev/null +++ b/go/projects/lyra/internal/api/category.go @@ -0,0 +1,9 @@ +package api + +// CategoryResponse 分类列表响应 DTO,字段名显式约定小写 JSON 键。 +type CategoryResponse struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Sort int `json:"sort"` +} diff --git a/go/projects/lyra/internal/handler/hello.go b/go/projects/lyra/internal/handler/hello.go deleted file mode 100644 index 3d38317..0000000 --- a/go/projects/lyra/internal/handler/hello.go +++ /dev/null @@ -1,13 +0,0 @@ -package handler - -import ( - "lyra/internal/httpx" - - "github.com/gin-gonic/gin" -) - -func Hello() gin.HandlerFunc { - return func(c *gin.Context) { - httpx.OK(c, "Hello, World!") - } -} diff --git a/go/projects/lyra/internal/handler/test.go b/go/projects/lyra/internal/handler/test.go new file mode 100644 index 0000000..f52828c --- /dev/null +++ b/go/projects/lyra/internal/handler/test.go @@ -0,0 +1,43 @@ +package handler + +import ( + "lyra/internal/api" + "lyra/internal/httpx" + "lyra/internal/model" + "net/http" + + "github.com/gin-gonic/gin" +) + +type TestHandler struct { + categoryModel model.CategoryModel +} + +func NewTestHandler(categoryModel model.CategoryModel) *TestHandler { + return &TestHandler{categoryModel: categoryModel} +} + +func (h *TestHandler) Hello() gin.HandlerFunc { + return func(c *gin.Context) { + categories, err := h.categoryModel.FindAll(c.Request.Context()) + if err != nil { + httpx.Fail(c, http.StatusInternalServerError, api.CodeInternalError, "获取分类列表失败") + return + } + httpx.OK(c, toCategoryResponses(categories)) + } +} + +// toCategoryResponses 显式把内部模型转换为响应 DTO;make 保证空结果输出 data:[] 而非 null。 +func toCategoryResponses(categories []*model.Category) []api.CategoryResponse { + responses := make([]api.CategoryResponse, 0, len(categories)) + for _, category := range categories { + responses = append(responses, api.CategoryResponse{ + ID: category.ID, + Slug: category.Slug, + Title: category.Title, + Sort: category.Sort, + }) + } + return responses +} diff --git a/go/projects/lyra/internal/handler/test_test.go b/go/projects/lyra/internal/handler/test_test.go new file mode 100644 index 0000000..dc9bbd0 --- /dev/null +++ b/go/projects/lyra/internal/handler/test_test.go @@ -0,0 +1,128 @@ +package handler_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "lyra/internal/handler" + "lyra/internal/model" + + "github.com/gin-gonic/gin" +) + +type fakeCategoryModel struct { + categories []*model.Category + err error + calls int + gotCtx context.Context +} + +func (f *fakeCategoryModel) FindAll(ctx context.Context) ([]*model.Category, error) { + f.calls++ + f.gotCtx = ctx + return f.categories, f.err +} + +func serveHello(t *testing.T, fake *fakeCategoryModel, req *http.Request) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.GET("/test", handler.NewTestHandler(fake).Hello()) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + return recorder +} + +func newHelloRequest() *http.Request { + return httptest.NewRequest(http.MethodGet, "/test", nil) +} + +func TestHelloMapsCategoriesToResponse(t *testing.T) { + fake := &fakeCategoryModel{categories: []*model.Category{ + {ID: 1, Slug: "go-basics", Title: "Go 基础", Sort: 1}, + {ID: 2, Slug: "sql-practice", Title: "SQL 练习", Sort: 2}, + }} + + recorder := serveHello(t, fake, newHelloRequest()) + + if fake.calls != 1 { + t.Fatalf("模型调用次数 = %d, want 1", fake.calls) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + want := `{"success":true,"data":[{"id":1,"slug":"go-basics","title":"Go 基础","sort":1},{"id":2,"slug":"sql-practice","title":"SQL 练习","sort":2}]}` + if got := recorder.Body.String(); got != want { + t.Fatalf("body = %q, want %q", got, want) + } +} + +func TestHelloReturnsEmptyArrayForNoCategories(t *testing.T) { + tests := []struct { + name string + categories []*model.Category + }{ + {name: "空切片", categories: []*model.Category{}}, + {name: "nil结果", categories: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &fakeCategoryModel{categories: tt.categories} + + recorder := serveHello(t, fake, newHelloRequest()) + + if fake.calls != 1 { + t.Fatalf("模型调用次数 = %d, want 1", fake.calls) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + want := `{"success":true,"data":[]}` + if got := recorder.Body.String(); got != want { + t.Fatalf("body = %q, want %q", got, want) + } + }) + } +} + +func TestHelloReturnsInternalErrorOnModelFailure(t *testing.T) { + modelErr := errors.New("数据库连接被拒绝: 10.0.0.8:5432") + fake := &fakeCategoryModel{err: modelErr} + + recorder := serveHello(t, fake, newHelloRequest()) + + if fake.calls != 1 { + t.Fatalf("模型调用次数 = %d, want 1", fake.calls) + } + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError) + } + want := `{"success":false,"error":{"code":10002,"message":"获取分类列表失败"}}` + if got := recorder.Body.String(); got != want { + t.Fatalf("body = %q, want %q", got, want) + } + if strings.Contains(recorder.Body.String(), modelErr.Error()) { + t.Fatalf("响应泄漏了内部错误信息: %q", recorder.Body.String()) + } +} + +type contextKey struct{} + +func TestHelloPassesRequestContextToModel(t *testing.T) { + fake := &fakeCategoryModel{categories: []*model.Category{}} + reqCtx := context.WithValue(context.Background(), contextKey{}, "req-42") + req := newHelloRequest().WithContext(reqCtx) + + serveHello(t, fake, req) + + if fake.gotCtx != reqCtx { + t.Fatalf("模型收到非请求 context: %v", fake.gotCtx) + } + if got, _ := fake.gotCtx.Value(contextKey{}).(string); got != "req-42" { + t.Fatalf("context 值 = %q, want %q", got, "req-42") + } +} diff --git a/go/projects/lyra/internal/model/category.go b/go/projects/lyra/internal/model/category.go new file mode 100644 index 0000000..16db970 --- /dev/null +++ b/go/projects/lyra/internal/model/category.go @@ -0,0 +1,46 @@ +package model + +import ( + "context" + "fmt" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +type Category struct { + ID int64 `db:"id"` + Slug string `db:"slug"` + Title string `db:"title"` + Sort int `db:"sort"` +} + +type CategoryModel interface { + FindAll(ctx context.Context) ([]*Category, error) +} +type defaultCategoryModel struct { + db *sqlx.DB +} + +func NewCategoryModel(db *sqlx.DB) CategoryModel { + return &defaultCategoryModel{db: db} +} +func (m *defaultCategoryModel) FindAll(ctx context.Context) ([]*Category, error) { + query, args, err := sq. + Select("id", "slug", "title", "sort"). + From("public.categories"). + Where(sq.GtOrEq{"sort": 1}). + OrderBy("sort ASC", "id ASC"). + Limit(10). + PlaceholderFormat(sq.Dollar). + ToSql() + if err != nil { + return nil, fmt.Errorf("squirrel 出错: %w", err) + } + rows := make([]*Category, 0) + err = m.db.SelectContext(ctx, &rows, query, args...) + if err != nil { + return nil, fmt.Errorf("数据库查询出错: %w", err) + } + return rows, err +} diff --git a/go/projects/lyra/internal/router/router.go b/go/projects/lyra/internal/router/router.go index 1866232..546d930 100644 --- a/go/projects/lyra/internal/router/router.go +++ b/go/projects/lyra/internal/router/router.go @@ -1,11 +1,15 @@ package router import ( - "lyra/internal/handler" - "github.com/gin-gonic/gin" ) -func RegisterRoutes(router *gin.Engine) { - router.GET("/hello", handler.Hello()) +// Handlers 汇总各路由所需的处理器,由入口在启动时组装注入。 +type Handlers struct { + TestHandler gin.HandlerFunc +} + +// RegisterRoutes 将处理器挂载到引擎的路由表。 +func RegisterRoutes(engine *gin.Engine, handlers Handlers) { + engine.GET("/test", handlers.TestHandler) } diff --git a/go/projects/lyra/internal/router/router_test.go b/go/projects/lyra/internal/router/router_test.go index 6a4f8ef..2e6e58c 100644 --- a/go/projects/lyra/internal/router/router_test.go +++ b/go/projects/lyra/internal/router/router_test.go @@ -10,19 +10,68 @@ import ( "github.com/gin-gonic/gin" ) -func TestHelloRoute(t *testing.T) { - gin.SetMode(gin.TestMode) - engine := gin.New() - router.RegisterRoutes(engine) +const ( + stubStatus = http.StatusAccepted + stubBody = "injected test handler" +) +// newStubHandlers 返回记录调用次数并输出固定状态码与响应体的替身处理器。 +func newStubHandlers(calls *int) router.Handlers { + return router.Handlers{ + TestHandler: func(c *gin.Context) { + *calls++ + c.String(stubStatus, stubBody) + }, + } +} + +func TestRouteInvokesInjectedHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + + var calls int + engine := gin.New() + router.RegisterRoutes(engine, newStubHandlers(&calls)) + recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/hello", nil)) + engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil)) - if recorder.Code != http.StatusOK { - t.Fatalf("GET /hello status = %d, want %d", recorder.Code, http.StatusOK) + if calls != 1 { + t.Fatalf("GET /test 替身调用次数 = %d, want 1", calls) + } + if recorder.Code != stubStatus { + t.Fatalf("GET /test status = %d, want %d", recorder.Code, stubStatus) + } + if got := recorder.Body.String(); got != stubBody { + t.Fatalf("GET /test body = %q, want %q", got, stubBody) } - want := `{"success":true,"data":"Hello, World!"}` - if got := recorder.Body.String(); got != want { - t.Fatalf("GET /hello body = %q, want %q", got, want) +} + +func TestRouteSkipsHandlerForUnknownPathOrMethod(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + method string + path string + }{ + {name: "未知路径", method: http.MethodGet, path: "/unknown"}, + {name: "不支持的方法", method: http.MethodPost, path: "/test"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls int + engine := gin.New() + router.RegisterRoutes(engine, newStubHandlers(&calls)) + + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httptest.NewRequest(tt.method, tt.path, nil)) + + if calls != 0 { + t.Fatalf("%s %s 替身调用次数 = %d, want 0", tt.method, tt.path, calls) + } + if recorder.Code != http.StatusNotFound { + t.Fatalf("%s %s status = %d, want %d", tt.method, tt.path, recorder.Code, http.StatusNotFound) + } + }) } }