feat(lyra)!: 接入分类查询并集中组装依赖

- 使用 Squirrel 与 sqlx 实现分类查询
- 集中组装依赖并向 handler 注入 model 接口
- 显式转换分类响应 DTO,统一空列表输出
- 补充路由与 handler 测试,同步架构文档

BREAKING CHANGE: 将 GET /hello 替换为 GET /test,响应由问候字符串改为分类列表
This commit is contained in:
2026-10-17 17:06:20 +08:00
parent 8c0c9978e5
commit ab83ad96b4
16 changed files with 546 additions and 89 deletions
@@ -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"`
}
@@ -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!")
}
}
+43
View File
@@ -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
}
@@ -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")
}
}
@@ -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
}
+8 -4
View File
@@ -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)
}
+59 -10
View File
@@ -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)
}
})
}
}