feat(lyra)!: 接入分类查询并集中组装依赖
- 使用 Squirrel 与 sqlx 实现分类查询 - 集中组装依赖并向 handler 注入 model 接口 - 显式转换分类响应 DTO,统一空列表输出 - 补充路由与 handler 测试,同步架构文档 BREAKING CHANGE: 将 GET /hello 替换为 GET /test,响应由问候字符串改为分类列表
This commit is contained in:
@@ -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!")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user