feat(lyra)!: 接入 Redis 并添加缓存连通性实验

- 封装缓存读写、JSON 编解码、过期时间与操作超时
- 集中组装 Redis 依赖,在测试接口验证分类缓存读写
- 更新依赖并移除原 handler 测试

BREAKING CHANGE: 配置节由 redis 改为 cache,新增 db 和 timeout 配置项
This commit is contained in:
2026-10-19 23:12:49 +08:00
parent b72b14a818
commit 759bc3fc35
9 changed files with 154 additions and 160 deletions
+32 -6
View File
@@ -1,30 +1,56 @@
package handler
import (
"fmt"
"log/slog"
"lyra/internal/api"
"lyra/internal/cache"
"lyra/internal/httpx"
"lyra/internal/model"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
type TestHandler struct {
categoryModel model.CategoryModel
cache *cache.Cache
}
func NewTestHandler(categoryModel model.CategoryModel) *TestHandler {
return &TestHandler{categoryModel: categoryModel}
func NewTestHandler(categoryModel model.CategoryModel, cache *cache.Cache) *TestHandler {
return &TestHandler{categoryModel: categoryModel, cache: cache}
}
func (h *TestHandler) Hello() gin.HandlerFunc {
return func(c *gin.Context) {
categories, err := h.categoryModel.FindAll(c.Request.Context())
return func(ctx *gin.Context) {
categories, err := h.categoryModel.FindAll(ctx.Request.Context())
if err != nil {
httpx.Fail(c, http.StatusInternalServerError, api.CodeInternalError, "获取分类列表失败")
httpx.Fail(ctx, http.StatusInternalServerError, api.CodeInternalError, "获取分类列表失败")
return
}
httpx.OK(c, toCategoryResponses(categories))
if err := h.cache.Set(ctx.Request.Context(), cache.Entry[[]*model.Category]{
Key: "test:categories",
Value: categories,
TTL: 5 * time.Minute,
}); err != nil {
slog.ErrorContext(ctx.Request.Context(), "缓存分类列表失败")
return
}
testResult, _, err := h.cache.Get[[]*model.Category](ctx.Request.Context(), "test:categories")
if err != nil {
slog.ErrorContext(ctx.Request.Context(), "获取缓存分类列表失败")
}
if len(testResult) == 0 {
slog.ErrorContext(ctx.Request.Context(), "缓存分类列表为空")
} else {
for _, v := range testResult {
fmt.Printf("%+v\n", v)
}
}
httpx.OK(ctx, toCategoryResponses(categories))
}
}
@@ -1,128 +0,0 @@
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")
}
}