Compare commits

..
7 Commits
Author SHA1 Message Date
labmem c812cfb663 wip(lyra): 保存带选项列表查询的未完成设计
- 添加通用查询选项、分类过滤选项及动态缓存键生成
- 新增分类创建与更新时间字段

当前实现尚未完成,留待后续继续设计:
- 明确通用查询选项、模型过滤条件与缓存策略的边界
- 补齐数据库回源,修正分类返回类型及过滤条件生效问题
- 修正分页偏移计算,增加参数边界校验和稳定排序
- 完善缓存故障降级、空结果缓存及写后失效策略
- 修正缓存键参数编码歧义,补齐查询名和版本前缀
- 补充测试并验证编译与查询行为
2026-10-21 00:11:37 +08:00
labmem 97b26635fa feat(lyra): 为分类查询添加缓存并同步项目约定
- 实现分类列表缓存查询与故障降级
- 统一缓存键命名
- 同步日志、响应、依赖组装及测试约定
- 移除 postgres 占位目录
2026-10-20 17:42:41 +08:00
labmem bef30be3fe wip(lyra): 补充链接模型并注入分类缓存依赖 2026-10-20 16:47:22 +08:00
labmem cd0e13325f feat(lyra)!: 添加结构化日志与请求追踪
- 配置 slog JSON 输出和日志级别
- 添加请求 ID、访问日志及上下文日志工具
- 拆分启动初始化并统一启动失败处理

BREAKING CHANGE: 配置文件必须提供 log.level,缺失或非法将导致启动失败
2026-10-20 15:16:35 +08:00
labmem 759bc3fc35 feat(lyra)!: 接入 Redis 并添加缓存连通性实验
- 封装缓存读写、JSON 编解码、过期时间与操作超时
- 集中组装 Redis 依赖,在测试接口验证分类缓存读写
- 更新依赖并移除原 handler 测试

BREAKING CHANGE: 配置节由 redis 改为 cache,新增 db 和 timeout 配置项
2026-10-19 23:12:49 +08:00
labmem b72b14a818 feat(polaris): 新增三个导航网站并更新游戏资料图标
Polaris CI / verify (push) Successful in 3m40s
Polaris CI / publish (push) Successful in 5m7s
2026-10-19 11:35:53 +08:00
labmem 306107af83 docs(goose): 添加数据库迁移使用笔记 2026-10-17 17:47:40 +08:00
32 changed files with 935 additions and 206 deletions
+33
View File
@@ -0,0 +1,33 @@
# lyra 项目约定
## Redis 缓存键
- 格式:`<项目>:cache:<资源>:<查询>:v<版本>`,例如 `CategoryListCacheKey = "lyra:cache:category:list:v1"`。
- 常量命名:`<资源><查询>CacheKey`,集中定义在 `internal/pkg/constant/cacheKey.go`,业务代码引用常量,不硬编码键字符串。
- 分段含义:项目名(本项目为 `lyra`)隔离不同应用;`cache` 区分缓存与其他 Redis 用途;版本用于隔离结构不兼容的缓存数据。
- 参数化查询的键必须包含影响查询结果的条件参数;键中不得出现密码、令牌等敏感信息。
## 日志
- 请求链路(middleware、handler、service、model)需要日志时使用 `util.Logger(ctx)`,使用结构化属性记录上下文与错误。
- `util` 只从 context 读取 request ID;request ID 由 `RequestIDMiddleware` 注入,不由 `util` 生成。
- Gin 调用链向下传递 `c.Request.Context()`,不要继续传 `*gin.Context`。
- 禁止在请求处理中使用 `fmt.Printf` 或包级 `slog` 记录业务日志。
- 无请求上下文的进程启动、初始化日志可直接使用 `slog`,不强制统一为 `util.Logger`。
## 错误处理
- 底层错误在合适的处理边界记录一次,避免每层重复记录。
- 公开响应只返回约定的业务码与对外文案,不暴露底层错误、SQL、连接信息等内部细节。
## 启动与组装
- `cmd/lyra/bootstrap.go` 负责数据库、Redis 等基础设施初始化;`cmd/lyra/dependency.go` 负责依赖组装;`cmd/lyra/main.go` 负责入口流程编排。
- `internal/config` 只定义配置结构与加载,不创建客户端;数据库/Redis 初始化留在 `cmd/lyra`,不另建占位包。
- 不全局创建数据库或 Redis 客户端,实例由入口创建并注入。
- 运行路径以 module 根为工作目录解析(例如 `etc/lyra.toml`)。
## 测试
- 默认自动测试应独立于真实 PostgreSQL、Redis 和真实端口。
- 需要真实服务的联调测试单独说明和执行,不混入默认测试。
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"lyra/internal/config"
"lyra/internal/middleware"
"net"
"os"
"strconv"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"github.com/redis/go-redis/v9"
// 注册 pgx 驱动,供 sqlx 以 "pgx" 驱动名使用。
_ "github.com/jackc/pgx/v5/stdlib"
)
func setLogger(cfg *config.Config) error {
var level slog.Level
switch cfg.Log.Level {
case "debug":
level = slog.LevelDebug
case "info":
level = slog.LevelInfo
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
return fmt.Errorf("不支持的日志级别: %s", cfg.Log.Level)
}
logger := slog.New(
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
}),
)
slog.SetDefault(logger)
return nil
}
func newHTTPRouter(mode string) (*gin.Engine, error) {
gin.SetMode(mode)
r := gin.New()
r.Use(middleware.RequestIDMiddleware())
r.Use(middleware.AccessLogMiddleware())
r.Use(gin.Recovery())
// 当前没有反向代理,不信任客户端转发的 IP 头。
if err := r.SetTrustedProxies(nil); err != nil {
return nil, fmt.Errorf("设置代理信任失败: %w", err)
}
return r, nil
}
func newDatabase(ctx context.Context, cfg config.DatabaseConfig) (*sqlx.DB, error) {
dataSourceName := fmt.Sprintf(
"host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
cfg.Host, cfg.Port, cfg.User, cfg.Name, cfg.Password,
)
db, err := sqlx.ConnectContext(ctx, cfg.Driver, dataSourceName)
if err != nil {
return nil, errors.New("连接 PostgreSQL 失败,请检查连接配置、认证和网络")
}
return db, nil
}
func newCache(cfg config.CacheConfig) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
DB: cfg.DB,
})
}
+9 -3
View File
@@ -1,29 +1,35 @@
package main package main
import ( import (
"lyra/internal/cache"
"lyra/internal/config" "lyra/internal/config"
"lyra/internal/handler" "lyra/internal/handler"
"lyra/internal/model" "lyra/internal/model"
"lyra/internal/router" "lyra/internal/router"
"time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/redis/go-redis/v9"
) )
type Dependencies struct { type Dependencies struct {
Config *config.Config Config *config.Config
Cache *cache.Cache
CategoryModel model.CategoryModel CategoryModel model.CategoryModel
} }
// buildHandlers 集中组装各路由的处理器,入口启动时调用一次。 // buildHandlers 集中组装各路由的处理器,入口启动时调用一次。
func buildHandlers(dpd *Dependencies) router.Handlers { func buildHandlers(dpd *Dependencies) router.Handlers {
return router.Handlers{ return router.Handlers{
TestHandler: handler.NewTestHandler(dpd.CategoryModel).Hello(), TestHandler: handler.NewTestHandler(dpd.CategoryModel, dpd.Cache).Hello(),
} }
} }
func buildDependencies(cfg *config.Config, db *sqlx.DB) *Dependencies { func buildDependencies(cfg *config.Config, db *sqlx.DB, cacheClient *redis.Client) *Dependencies {
cache := cache.NewCache(cacheClient, time.Duration(cfg.Cache.Timeout)*time.Millisecond)
return &Dependencies{ return &Dependencies{
Config: cfg, Config: cfg,
CategoryModel: model.NewCategoryModel(db), Cache: cache,
CategoryModel: model.NewCategoryModel(db, cache),
} }
} }
+32 -24
View File
@@ -3,49 +3,57 @@ package main
import ( import (
"context" "context"
"fmt" "log/slog"
"log"
"lyra/internal/config" "lyra/internal/config"
"lyra/internal/router" "lyra/internal/router"
"net"
"os"
"strconv"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"github.com/joho/godotenv" "github.com/joho/godotenv"
// 注册 pgx 驱动,供 sqlx 以 "pgx" 驱动名使用。
_ "github.com/jackc/pgx/v5/stdlib"
) )
func main() { func main() {
// 加载 .env.local 文件,覆盖环境变量
if err := godotenv.Load(".env.local"); err != nil { if err := godotenv.Load(".env.local"); err != nil {
log.Fatal("加载环境变量失败,请检查文件是否存在") slog.Error("加载环境变量失败,请检查文件是否存在及格式是否正确")
os.Exit(1)
} }
// 加载配置(internal/config)
cfg, err := config.Load("etc/lyra.toml") cfg, err := config.Load("etc/lyra.toml")
if err != nil { if err != nil {
log.Fatal("加载配置失败,请检查配置文件和环境变量") slog.Error("加载配置失败,请检查配置文件和环境变量")
os.Exit(1)
} }
gin.SetMode(cfg.App.Env) if err := setLogger(cfg); err != nil {
ginEngine := gin.Default() slog.Error("设置日志失败", "error", err)
//没有反向代理,在启动前关闭代理信任: os.Exit(1)
if err := ginEngine.SetTrustedProxies(nil); err != nil { }
log.Fatal("设置代理信任失败,请检查配置") r, err := newHTTPRouter(cfg.App.Env)
if err != nil {
slog.Error("初始化 HTTP 路由失败", "error", err)
os.Exit(1)
} }
dataSourceName := fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable", cfg.Database.Host, cfg.Database.Port, cfg.Database.User, cfg.Database.Name, cfg.Database.Password)
ctx := context.Background() ctx := context.Background()
db, err := sqlx.ConnectContext(ctx, cfg.Database.Driver, dataSourceName)
db, err := newDatabase(ctx, cfg.Database)
if err != nil { if err != nil {
log.Fatal("连接 PostgreSQL 失败,请检查连接配置、认证和网络") slog.Error("初始化数据库失败", "error", err)
os.Exit(1)
} }
defer db.Close() defer db.Close()
deps := buildDependencies(cfg, db) cache := newCache(cfg.Cache)
defer cache.Close()
deps := buildDependencies(cfg, db, cache)
handlers := buildHandlers(deps) handlers := buildHandlers(deps)
router.RegisterRoutes(ginEngine, handlers)
addr := fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port) router.RegisterRoutes(r, handlers)
if err := ginEngine.Run(addr); err != nil {
log.Fatal("启动 HTTP 服务失败,请检查端口是否被占用") if err := r.Run(net.JoinHostPort(cfg.HTTP.Host, strconv.Itoa(cfg.HTTP.Port))); err != nil {
slog.Error("启动 HTTP 服务失败", "error", err)
os.Exit(1)
} }
} }
+6 -1
View File
@@ -2,6 +2,9 @@
name = "lyra" name = "lyra"
env = "debug" #gin支持 release | test | debug 3种mode,如果需要自定义,需要映射到这3种mode中去,gin的默认模式是debug模式 env = "debug" #gin支持 release | test | debug 3种mode,如果需要自定义,需要映射到这3种mode中去,gin的默认模式是debug模式
[log]
level = "info" #日志级别,支持 debug | info | warn | error |
[http] [http]
host = "0.0.0.0" host = "0.0.0.0"
port = 7777 port = 7777
@@ -13,6 +16,8 @@ port = 5432
user = "admin" user = "admin"
name = "lab" name = "lab"
[redis] [cache]
host = "localhost" host = "localhost"
port = 6379 port = 6379
db = 0
timeout = 200
+9 -5
View File
@@ -3,17 +3,21 @@ module lyra
go 1.27.1 go 1.27.1
require ( require (
github.com/Masterminds/squirrel v1.5.4
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0 github.com/jackc/pgx/v5 v5.11.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.23.0
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
) )
require ( require (
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
@@ -27,9 +31,8 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
@@ -48,12 +51,13 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.uber.org/atomic v1.12.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect
) )
+22 -12
View File
@@ -2,12 +2,18 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= 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/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -42,6 +48,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -56,8 +64,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -81,12 +89,13 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
@@ -112,20 +121,24 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
@@ -135,15 +148,12 @@ golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+85
View File
@@ -0,0 +1,85 @@
package cache
import (
"context"
"crypto/md5"
"encoding/json/v2"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type Entry[T any] struct {
Key string
Value T
TTL time.Duration
}
type Cache struct {
client *redis.Client
operationTimeout time.Duration
}
func NewCache(client *redis.Client, timeout time.Duration) *Cache {
return &Cache{
client: client,
operationTimeout: timeout,
}
}
func (c *Cache) Get[T any](ctx context.Context, key string) (T, bool, error) {
var zero T
operationCtx, cancel := context.WithTimeout(ctx, c.operationTimeout)
defer cancel()
payload, err := c.client.Get(operationCtx, key).Result()
cancel()
if errors.Is(err, redis.Nil) {
return zero, false, nil
}
if err != nil {
return zero, false, fmt.Errorf("读取缓存失败:%w", err)
}
var value T
if err := json.Unmarshal([]byte(payload), &value); err != nil {
return zero, false, fmt.Errorf("缓存解码失败:%w", err)
}
return value, true, nil
}
func (c *Cache) Set[T any](ctx context.Context, entry Entry[T]) error {
if entry.TTL < 0 {
return fmt.Errorf("缓存过期时间必须大于等于零: %v", entry.TTL)
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("写入缓存失败:%w", err)
}
payload, err := json.Marshal(entry.Value)
if err != nil {
return fmt.Errorf("缓存编码失败:%w", err)
}
operationCtx, cancel := context.WithTimeout(ctx, c.operationTimeout)
defer cancel()
if err := c.client.Set(operationCtx, entry.Key, payload, entry.TTL).Err(); err != nil {
return fmt.Errorf("写入缓存失败:%w", err)
}
return nil
}
func GenCacheKey(table, sql string, args ...any) (key string, err error) {
prefix := fmt.Sprintf("lyra:cache:%s:", table)
h := md5.New()
// 写入查询语句
h.Write([]byte(sql))
// 序列化并写入每个参数
for _, arg := range args {
jsonData, err := json.Marshal(arg)
if err != nil {
return "", fmt.Errorf("序列化参数失败:%w", err)
}
h.Write(jsonData)
}
return prefix + fmt.Sprintf("%x", h.Sum(nil)), nil
}
+10 -4
View File
@@ -8,9 +8,10 @@ import (
type Config struct { type Config struct {
App AppConfig `mapstructure:"app"` App AppConfig `mapstructure:"app"`
Log LogConfig `mapstructure:"log"`
HTTP HTTPConfig `mapstructure:"http"` HTTP HTTPConfig `mapstructure:"http"`
Database DatabaseConfig `mapstructure:"database"` Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"` Cache CacheConfig `mapstructure:"cache"`
} }
type AppConfig struct { type AppConfig struct {
@@ -18,6 +19,9 @@ type AppConfig struct {
Env string `mapstructure:"env"` Env string `mapstructure:"env"`
} }
type LogConfig struct {
Level string `mapstructure:"level"`
}
type HTTPConfig struct { type HTTPConfig struct {
Host string `mapstructure:"host"` Host string `mapstructure:"host"`
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
@@ -32,9 +36,11 @@ type DatabaseConfig struct {
Password string `mapstructure:"password"` Password string `mapstructure:"password"`
} }
type RedisConfig struct { type CacheConfig struct {
Host string `mapstructure:"host"` Host string `mapstructure:"host"`
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
DB int `mapstructure:"db"`
Timeout int `mapstructure:"timeout"`
} }
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
@@ -2,5 +2,6 @@
- 负责参数绑定、API DTO 与内部模型的相互转换、响应输出。 - 负责参数绑定、API DTO 与内部模型的相互转换、响应输出。
- 引用 internal/api 的 DTO,使用 Gin 完成绑定与响应。 - 引用 internal/api 的 DTO,使用 Gin 完成绑定与响应。
- 响应统一使用 `httpx.OK` / `httpx.Fail`,遵循 `internal/httpx/AGENTS.md` 的状态码、业务码与错误隔离规则。
- 调用 service 时传递标准 context.Context,不下传 gin.Context。 - 调用 service 时传递标准 context.Context,不下传 gin.Context。
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。 - 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
+32 -6
View File
@@ -1,30 +1,56 @@
package handler package handler
import ( import (
"fmt"
"lyra/internal/api" "lyra/internal/api"
"lyra/internal/cache"
"lyra/internal/httpx" "lyra/internal/httpx"
"lyra/internal/model" "lyra/internal/model"
"lyra/internal/util"
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type TestHandler struct { type TestHandler struct {
categoryModel model.CategoryModel categoryModel model.CategoryModel
cache *cache.Cache
} }
func NewTestHandler(categoryModel model.CategoryModel) *TestHandler { func NewTestHandler(categoryModel model.CategoryModel, cache *cache.Cache) *TestHandler {
return &TestHandler{categoryModel: categoryModel} return &TestHandler{categoryModel: categoryModel, cache: cache}
} }
func (h *TestHandler) Hello() gin.HandlerFunc { func (h *TestHandler) Hello() gin.HandlerFunc {
return func(c *gin.Context) { return func(ctx *gin.Context) {
categories, err := h.categoryModel.FindAll(c.Request.Context()) categories, err := h.categoryModel.FindAll(ctx.Request.Context())
if err != nil { if err != nil {
httpx.Fail(c, http.StatusInternalServerError, api.CodeInternalError, "获取分类列表失败") httpx.Fail(ctx, http.StatusInternalServerError, api.CodeInternalError, "获取分类列表失败")
return 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 {
util.Logger(ctx.Request.Context()).Error("设置缓存分类列表失败")
return
}
testResult, _, err := h.cache.Get[[]*model.Category](ctx.Request.Context(), "test:categories")
if err != nil {
util.Logger(ctx.Request.Context()).Error("获取缓存分类列表失败")
}
if len(testResult) == 0 {
util.Logger(ctx.Request.Context()).Error("缓存分类列表为空")
} else {
for _, v := range testResult {
fmt.Printf("%+v\n", v)
}
}
util.Logger(ctx.Request.Context()).Info("success")
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")
}
}
+4 -2
View File
@@ -1,4 +1,6 @@
# httpx 包 # httpx 包
- 提供公共 HTTP 响应辅助。 - 提供公共 HTTP 响应辅助,使用现有 `OK` / `Fail` 输出统一响应。
- 出现实际复用需求时再补充具体辅助函数,不预先设计无使用场景的抽象。 - `Fail` 区分 HTTP 状态码与业务错误码;公开 message 为空时回退到业务码的默认文案。
- 不向公开响应写入内部错误或调试信息,避免 debug 泄漏。
- 响应结构来自 internal/api,不在这里重复定义 DTO。
@@ -0,0 +1,30 @@
package middleware
import (
"lyra/internal/util"
"time"
"github.com/gin-gonic/gin"
)
func AccessLogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
ctx := c.Request.Context()
util.Logger(ctx).InfoContext(
ctx,
"http request",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"status", c.Writer.Status(),
"latency_ms", time.Since(start).Milliseconds(),
"client_ip", c.ClientIP(),
"user_agent", c.Request.UserAgent(),
"response_size", c.Writer.Size(),
)
}
}
@@ -0,0 +1,27 @@
package middleware
import (
"context"
"lyra/internal/pkg/constant"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = uuid.NewString()
}
c.Header("X-Request-ID", requestID)
ctx := context.WithValue(c.Request.Context(), constant.RequestIDKey, requestID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
+5 -1
View File
@@ -2,5 +2,9 @@
- 承载数据结构定义与数据访问:使用 Squirrel/SQL 构造查询、sqlx 执行与映射结果,驱动为 pgx stdlib。 - 承载数据结构定义与数据访问:使用 Squirrel/SQL 构造查询、sqlx 执行与映射结果,驱动为 pgx stdlib。
- SQL 与查询逻辑显式写在这一层,不使用 ORM 自动持久化。 - SQL 与查询逻辑显式写在这一层,不使用 ORM 自动持久化。
- 支持在 service 传入的同一事务内执行操作。 - 事务边界由 service 决定;需要同一事务时由 service 传入事务执行。这是设计目标,当前没有现成的事务接口封装。
- 查询缓存采用 cache-aside:先读缓存,命中直接返回(空列表也可命中);未命中或缓存读取失败时回源数据库。
- 仅数据库查询成功后回填缓存;数据库失败不回填。缓存读写故障只记录日志并降级,不改变数据库查询结果。
- 缓存键使用 `internal/pkg/constant` 中的常量,遵循根 `AGENTS.md` 的 Redis 缓存键约定。
- 未来写路径应在事务提交后使相关缓存失效;当前 `cache` 包没有删除接口,失效策略属于后续设计约束。
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。 - 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
+94 -8
View File
@@ -3,29 +3,51 @@ package model
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"lyra/internal/cache"
"lyra/internal/pkg/constant"
"lyra/internal/pkg/query"
"lyra/internal/util"
sq "github.com/Masterminds/squirrel" sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type Category struct { type Category struct {
ID int64 `db:"id"` ID int64 `db:"id"`
Slug string `db:"slug"` Slug string `db:"slug"`
Title string `db:"title"` Title string `db:"title"`
Sort int `db:"sort"` Sort int `db:"sort"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
} }
type CategoryModel interface { type CategoryModel interface {
FindAll(ctx context.Context) ([]*Category, error) FindAll(ctx context.Context) ([]*Category, error)
ListWithOptions(ctx context.Context, queryOpt query.Option, modelOpts ...ModelOption) (resp []*Link, err error)
} }
type defaultCategoryModel struct { type defaultCategoryModel struct {
db *sqlx.DB db *sqlx.DB
table string
cache *cache.Cache
} }
func NewCategoryModel(db *sqlx.DB) CategoryModel { func NewCategoryModel(db *sqlx.DB, cache *cache.Cache) CategoryModel {
return &defaultCategoryModel{db: db} return &defaultCategoryModel{db: db, table: "categories", cache: cache}
} }
const categoryListCacheTTL = 5 * time.Minute
func (m *defaultCategoryModel) FindAll(ctx context.Context) ([]*Category, error) { func (m *defaultCategoryModel) FindAll(ctx context.Context) ([]*Category, error) {
cached, hit, err := m.cache.Get[[]*Category](ctx, constant.CategoryListCacheKey)
if err != nil {
util.Logger(ctx).Error("读取分类列表缓存失败", "key", constant.CategoryListCacheKey, "error", err)
}
if hit {
return cached, nil
}
query, args, err := sq. query, args, err := sq.
Select("id", "slug", "title", "sort"). Select("id", "slug", "title", "sort").
From("public.categories"). From("public.categories").
@@ -42,5 +64,69 @@ func (m *defaultCategoryModel) FindAll(ctx context.Context) ([]*Category, error)
if err != nil { if err != nil {
return nil, fmt.Errorf("数据库查询出错: %w", err) return nil, fmt.Errorf("数据库查询出错: %w", err)
} }
return rows, err if err := m.cache.Set(ctx, cache.Entry[[]*Category]{
Key: constant.CategoryListCacheKey,
Value: rows,
TTL: categoryListCacheTTL,
}); err != nil {
util.Logger(ctx).Error("写入分类列表缓存失败", "key", constant.CategoryListCacheKey, "error", err)
}
return rows, nil
}
type ModelOption func(*sq.SelectBuilder)
func (m *defaultCategoryModel) ListWithOptions(ctx context.Context, queryOpt query.Option, modelOpts ...ModelOption) (resp []*Link, err error) {
// 1.先构建查询语句(还没有deleted字段)
// 2.根据queryOpt的参数,设置分页、排序等
sqBuilder := sq.Select("id", "slug", "title", "sort").
From("categories").Limit(uint64(queryOpt.PageSize)).
Offset(uint64(queryOpt.Page)).
PlaceholderFormat(sq.Dollar)
for _, opt := range modelOpts {
opt(new(sqBuilder))
}
query, args, err := sqBuilder.ToSql()
if err != nil {
return nil, fmt.Errorf("squirrel 构造SQL出错: %w", err)
}
cacheKey, err := cache.GenCacheKey(m.table, query, args...)
if err != nil {
return nil, err
}
result, hit, err := m.cache.Get[[]*Link](ctx, cacheKey)
if err != nil {
return nil, err
}
if hit {
return result, nil
}
if queryOpt.WithCacheWrite {
if err := m.cache.Set(ctx, cache.Entry[[]*Link]{
Key: cacheKey,
Value: result,
TTL: queryOpt.CacheTTL,
}); err != nil {
return nil, err
}
}
return result, nil
}
func WithCategorySlug(slug string) ModelOption {
return func(builder *sq.SelectBuilder) {
*builder = builder.Where(sq.Eq{"categories.slug": slug})
}
}
func WithCategoryTitle(title string) ModelOption {
return func(builder *sq.SelectBuilder) {
*builder = builder.Where(sq.Eq{"categories.title": title})
}
}
func WithCategorySort(sort int) ModelOption {
return func(builder *sq.SelectBuilder) {
*builder = builder.Where(sq.Eq{"categories.sort": sort})
}
} }
+34
View File
@@ -0,0 +1,34 @@
package model
import (
"lyra/internal/cache"
"time"
"github.com/jmoiron/sqlx"
)
type Link struct {
ID int64 `db:"id"`
CategoryID int64 `db:"category_id"`
Title string `db:"title"`
Description string `db:"description"`
URL string `db:"url"`
Icon *string `db:"icon"`
Keywords []string `db:"keywords"`
Sort int `db:"sort"`
Pinned bool `db:"pinned"`
PinnedSort *int `db:"pinned_sort"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type LinkModel interface {
}
type defaultLinkModel struct {
db *sqlx.DB
cache *cache.Cache
}
func NewLinkModel(db *sqlx.DB, cache *cache.Cache) CategoryModel {
return &defaultCategoryModel{db: db, cache: cache}
}
@@ -0,0 +1,3 @@
package constant
const CategoryListCacheKey = "lyra:cache:category:list:v1"
@@ -0,0 +1,5 @@
package constant
type contextKey string
const RequestIDKey contextKey = "request_id"
@@ -0,0 +1,82 @@
package query
import "time"
const (
defaultPage = 1
defaultPageSize = 10
defaultIncludeDeleted = false
defaultWithCacheWrite = false
defaultCacheTTL = 5 * time.Minute
defaultNotFoundCacheTTL = 1 * time.Minute
)
type Option struct {
Page int
PageSize int
IncludeDeleted bool
WithCacheWrite bool
CacheTTL time.Duration
NotFoundCacheTTL time.Duration
}
type OptionFunc func(*Option)
func NewOption(opts ...OptionFunc) *Option {
queryOpntion := &Option{
Page: defaultPage,
PageSize: defaultPageSize,
IncludeDeleted: defaultIncludeDeleted,
WithCacheWrite: defaultWithCacheWrite,
CacheTTL: defaultCacheTTL,
NotFoundCacheTTL: defaultNotFoundCacheTTL,
}
for _, opt := range opts {
opt(queryOpntion)
}
return queryOpntion
}
func WithPage(page int) OptionFunc {
return func(o *Option) {
if page > 0 {
o.Page = page
}
}
}
func WithPageSize(pageSize int) OptionFunc {
return func(o *Option) {
if pageSize > 0 {
o.PageSize = pageSize
}
}
}
func WithIncludeDeleted(includeDeleted bool) OptionFunc {
return func(o *Option) {
o.IncludeDeleted = includeDeleted
}
}
func WithCacheWrite(withCacheWrite bool) OptionFunc {
return func(o *Option) {
o.WithCacheWrite = withCacheWrite
}
}
func WithCacheTTL(cacheTTL time.Duration) OptionFunc {
return func(o *Option) {
if cacheTTL >= 0 {
o.CacheTTL = cacheTTL
}
}
}
func WithNotFoundCacheTTL(notFoundCacheTTL time.Duration) OptionFunc {
return func(o *Option) {
if notFoundCacheTTL >= 0 {
o.NotFoundCacheTTL = notFoundCacheTTL
}
}
}
@@ -1,4 +0,0 @@
# postgres 包
- 负责建立与管理 PostgreSQL 连接,使用 pgx stdlib 驱动。
- 连接参数来自 internal/config,连接供 model 层通过 sqlx 执行查询使用。
+3 -2
View File
@@ -1,6 +1,7 @@
# service 包 # service 包
- 承载业务规则与事务协调,通过 model 完成数据访问。 - 当前目录为占位,尚无 Go 实现;新增代码前先确认职责边界。
- 需要原子性时,在同一事务内协调多次写入,不把各自独立提交的多次写入当成原子业务。 - 预期职责:业务规则与事务协调,通过 model 完成数据访问。
- 需要原子性时,在同一事务内协调多次写入;当前没有现成事务接口,需在实现时设计,不把各自独立提交的多次写入当成原子业务。
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。 - 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。 - 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
+17
View File
@@ -0,0 +1,17 @@
package util
import (
"context"
"log/slog"
"lyra/internal/pkg/constant"
)
func Logger(ctx context.Context) *slog.Logger {
logger := slog.Default()
if requestID, ok := ctx.Value(constant.RequestIDKey).(string); ok {
logger = logger.With(string(constant.RequestIDKey), requestID)
}
return logger
}
+175
View File
@@ -0,0 +1,175 @@
# Goose 数据库迁移笔记
Goose 用迁移文件管理数据库结构变更,并在数据库中记录已执行的迁移版本。
本文以 **PostgreSQL、Goose v3.29.0** 为例,命令使用 Bash 语法。
## 1. 基本概念
- **迁移文件**:按版本组织的数据库变更脚本。
- **Up**:应用变更,例如建表、增加字段。
- **Down**:撤销对应变更;能否恢复数据取决于具体 SQL。
- **版本记录**:Goose 默认通过 `goose_db_version` 表跟踪迁移状态。
- **工具版本与迁移版本不同**:`goose -version` 查看工具版本,`goose version` 查看数据库迁移版本。
通常将迁移文件放在 `migrations/` 目录。下文命令在该目录的上一级执行,目标数据库需事先创建。
## 2. 安装
需要先安装 Go,再安装指定版本的 Goose CLI:
```bash
go install github.com/pressly/goose/v3/cmd/goose@v3.29.0
goose -version
```
固定工具版本有助于保持不同环境的行为一致。Goose 可以作为独立命令行工具使用。
默认安装位置是 `$(go env GOPATH)/bin`;若设置了 `GOBIN`,则安装到该目录。找不到命令时,将实际安装目录加入 `PATH`:
```bash
# 未设置 GOBIN 时
export PATH="$PATH:$(go env GOPATH)/bin"
```
## 3. 配置数据库连接
在工作目录创建 `.env.local`,填写连接配置:
```dotenv
GOOSE_DRIVER=postgres
GOOSE_MIGRATION_DIR=./migrations
GOOSE_DBSTRING="postgres://example_user:URL_ENCODED_PASSWORD@127.0.0.1:5432/example_db?sslmode=disable"
```
以上用户名、密码和数据库名都是占位值,地址与端口也需按实际环境填写。
- `GOOSE_DRIVER`:数据库驱动;连接 PostgreSQL 时使用 `postgres`。
- `GOOSE_MIGRATION_DIR`:迁移文件目录。
- `GOOSE_DBSTRING`:数据库连接串。Goose 不会自动从应用配置文件或零散的应用环境变量中拼装它。
- Goose 默认读取 `.env`;本文统一使用 `-env=.env.local` 显式指定配置文件。
- `sslmode=disable` 适用于本地无 TLS 要求的场景;其他环境按实际 TLS 要求配置。
将含凭据的配置文件加入版本控制忽略规则。类 Unix 系统还可限制文件权限:
```bash
chmod 600 .env.local
```
系统中已有的同名环境变量可能优先于配置文件生效。修改文件后连接行为未改变时,可只列出相关变量名排查,避免打印连接串:
```bash
env | cut -d= -f1 | grep -i '^GOOSE'
```
## 4. 连接串中的 URL 编码
使用 URL 格式的连接串时,用户名、密码中的特殊字符需要进行百分号编码。
| 字符 | 编码 | 字符 | 编码 |
|---|---|---|---|
| `@` | `%40` | `:` | `%3A` |
| `#` | `%23` | `/` | `%2F` |
| `%` | `%25` | `?` | `%3F` |
例如,演示密码 `demo#pass` 在连接串中写成 `demo%23pass`。
- 分别编码用户名和密码,不要编码整个 URL。
- 避免对已编码的内容重复编码。
- 编码只改变连接串的写法,不改变数据库中的实际密码。
- 不要将真实密码提交给在线编码网站。
## 5. 常用命令
```bash
# 查看 Goose 工具版本
goose -version
# 离线检查迁移文件是否能被解析
goose -env=.env.local validate
# 查看迁移状态与数据库当前迁移版本
goose -env=.env.local status
goose -env=.env.local version
# 应用尚未执行的迁移
goose -env=.env.local up
# 回滚最近一个已应用的迁移;执行前检查对应 Down SQL
goose -env=.env.local down
```
`validate` 检查注解与语句拆分,不连接数据库,也不验证 SQL 在 PostgreSQL 上的语义。校验通过不代表数据库连接正常或迁移一定执行成功。
在 v3.29.0 中,`validate` 不接受位置路径参数。迁移目录通过 `GOOSE_MIGRATION_DIR` 或 `-dir` 指定:
```bash
goose -env=.env.local -dir=./migrations validate
```
## 6. 创建与执行迁移
### 创建迁移文件
```bash
goose -env=.env.local -s create create_example_items sql
```
`-s` 表示使用顺序编号,版本号按目录中已有迁移延续。例如,目录为空时,首个文件可命名为:
```text
migrations/00001_create_example_items.sql
```
### 编写 Up 与 Down
下面用一张示例表演示迁移结构:
```sql
-- +goose Up
CREATE TABLE public.example_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
-- +goose Down
DROP TABLE public.example_items;
```
该示例的 Down 会删除表及其中的数据。
### 检查并执行
```bash
goose -env=.env.local validate
goose -env=.env.local status
goose -env=.env.local up
goose -env=.env.local status
goose -env=.env.local version
```
执行前确认目标数据库已创建,且待创建的对象不存在。`status` 中的 `Pending` 表示对应迁移尚未应用。
Goose 会记录成功应用的迁移;再次执行 `up` 时,已应用版本会被跳过。
## 7. 变更与回滚原则
- **已执行的迁移文件不再修改**;后续结构变更通过新增迁移实现。
- 为需要回滚的迁移编写对应 Down,并检查它能否正确撤销结构变更。
- `down` 只回滚最近一个已应用迁移,不是清库或重建数据库的通用命令。
- 删除表或字段可能丢失数据;反向 SQL 不等于数据恢复。
- 回滚前阅读对应 SQL、评估影响并备份数据库。
- 避免手工执行与迁移文件重复的建表脚本,以免对象和版本记录不一致。
## 8. 常见问题
| 报错或现象 | 排查方向 |
|---|---|
| 找不到 `goose` 命令 | 检查安装是否成功,以及实际安装目录是否已加入 `PATH` |
| `hostname resolving error`,主机名混入非法字符 | 检查连接串分隔符,以及用户名、密码的 URL 编码 |
| `password authentication failed` | 核对用户名、密码及编码;检查系统同名环境变量是否覆盖配置 |
| `connection refused` / `i/o timeout` | 检查监听地址、端口、容器端口映射和防火墙 |
| `already exists` | 检查对象是否已被手工创建;先比较现有结构与迁移内容,再确定处理方式 |
| 修改 `.env.local` 后配置似乎未生效 | 确认使用了 `-env=.env.local`,并排查系统中已有的同名环境变量 |
遇到对象已存在的问题,不要直接删除现有数据,也不要手工插入版本记录来伪造迁移状态。
@@ -0,0 +1,30 @@
# 新增导航与游戏资料分类图标
## 范围与决定
- 用户已明确要求执行;从干净的 `main` 新建 `feat/polaris-navigation-additions` 分支,不提交代码。
- 新增 OpenRouter(AI 工具)、菜鸟教程(学习资料)、Lucide(设计资源),保持原有条目不变。
- 新条目沿用现有数据结构,填写简短描述、关键词和分类内排序,不置顶;图标沿用既有 favicon 与首字母兜底,不新增依赖或资源。
- 仅将游戏资料分类的 `icon` 改为 `book-search`,前端映射增加 Lucide 的 `BookSearch`,保留学习资料的 `BookOpen`。
- 不调整布局、样式或其他分类图标。
## 执行顺序与责任
1. 协调者创建分支并记录计划(已完成)。
2. fixer 负责 `src/data/links.json`、`src/data/categories.json`、`src/features/navigation/components/category-icon.tsx` 及必要的局部回归测试;编辑前确认编码、缩进与换行,阅读适用的 Next.js 本地文档。
3. fixer 运行数据与图标相关测试、类型检查、修改文件的 lint 和格式检查,报告证据;协调者复核最终差异和关键行为。
## 验证路径与预算
- 新增导航:断言三个 ID/URL 唯一、分类正确、字段满足现有契约,现有条目不变。
- 分类图标:断言游戏资料为 `book-search` 且渲染实际 `BookSearch`,学习资料仍为 `book-open`;类型检查确认当前 lucide-react 导出可用。
- 工程检查:优先运行现有测试套件(若成本较低则全部)、`pnpm typecheck`、修改文件的 ESLint 与 Prettier 检查、`git diff --check`。
- 不以外部站点的 favicon 网络可达性作为本次完成门槛;其失败沿用已有兜底机制。
- 无框架/API/布局变更,不重复执行完整生产构建或截图验证,除非检查暴露相关风险。
## 状态
- 实施完成,未提交代码。
- fixer 验证:全量测试 17 个文件、117 个用例通过;类型检查、修改文件 ESLint 与 Prettier 检查通过。环境使用 `corepack pnpm` 执行项目命令。
- 协调者复核:原有 51 条链接与 HEAD 完全一致,恰好新增 3 条;分类数据除游戏资料的 `icon` 外与 HEAD 完全一致;图标组件测试覆盖 `BookSearch`、`BookOpen` 与默认兜底;`git diff --check` 通过。
- 外部 favicon 可达性未测试,不影响本次数据及图标映射变更结论。
@@ -45,7 +45,7 @@
"id": "game-resources", "id": "game-resources",
"title": "游戏资料", "title": "游戏资料",
"description": "游戏攻略维基、资料速查与实用规划工具。", "description": "游戏攻略维基、资料速查与实用规划工具。",
"icon": "book-open", "icon": "book-search",
"sort": 65 "sort": 65
}, },
{ {
+48 -4
View File
@@ -30,9 +30,9 @@ describe("Polaris 静态数据", () => {
} }
}) })
it("包含 51 条合法链接且分类引用均存在", () => { it("包含 54 条合法链接且分类引用均存在", () => {
const categoryIds = new Set(categories.map(({ id }) => id)) const categoryIds = new Set(categories.map(({ id }) => id))
expect(links).toHaveLength(51) expect(links).toHaveLength(54)
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true) expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
for (const link of links) { for (const link of links) {
@@ -225,8 +225,8 @@ describe("Polaris 静态数据", () => {
}, },
] as const ] as const
it("九条新链接全部存在且总数为 51", () => { it("九条新链接全部存在且总数为 54", () => {
expect(links).toHaveLength(51) expect(links).toHaveLength(54)
expect( expect(
expectedNewLinks.every(({ id }) => expectedNewLinks.every(({ id }) =>
links.some((link) => link.id === id), links.some((link) => link.id === id),
@@ -248,4 +248,48 @@ describe("Polaris 静态数据", () => {
}, },
) )
}) })
describe("新增三条导航链接回归", () => {
const expectedNewLinks = [
{
id: "openrouter",
title: "OpenRouter",
url: "https://openrouter.ai",
categoryId: "ai",
},
{
id: "runoob",
title: "菜鸟教程",
url: "https://www.runoob.com/",
categoryId: "learning",
},
{
id: "lucide",
title: "Lucide",
url: "https://lucide.dev/",
categoryId: "design",
},
] as const
it("三条新链接字段与分类精确匹配且沿用 favicon 兜底", () => {
for (const expected of expectedNewLinks) {
const link = links.find(({ id }) => id === expected.id)
expect(link).toBeDefined()
expect(link).toMatchObject({
id: expected.id,
title: expected.title,
url: expected.url,
categoryId: expected.categoryId,
pinned: false,
})
expect(link?.icon).toBeUndefined()
}
})
it("三条新链接 ID 与 URL 唯一", () => {
expect(hasUniqueValues(expectedNewLinks.map(({ id }) => id))).toBe(true)
expect(hasUniqueValues(expectedNewLinks.map(({ url }) => url))).toBe(true)
})
})
}) })
+30
View File
@@ -534,5 +534,35 @@
"pinned": false, "pinned": false,
"keywords": ["阿里云", "控制台", "云服务", "管理"], "keywords": ["阿里云", "控制台", "云服务", "管理"],
"sort": 100 "sort": 100
},
{
"id": "openrouter",
"title": "OpenRouter",
"description": "统一大模型 API 聚合平台",
"url": "https://openrouter.ai",
"categoryId": "ai",
"pinned": false,
"keywords": ["ai", "大模型", "api", "聚合"],
"sort": 30
},
{
"id": "runoob",
"title": "菜鸟教程",
"description": "编程入门教程与在线学习网站",
"url": "https://www.runoob.com/",
"categoryId": "learning",
"pinned": false,
"keywords": ["教程", "编程", "学习", "入门"],
"sort": 20
},
{
"id": "lucide",
"title": "Lucide",
"description": "开源 SVG 图标库",
"url": "https://lucide.dev/",
"categoryId": "design",
"pinned": false,
"keywords": ["图标", "svg", "图标库", "开源"],
"sort": 50
} }
] ]
@@ -0,0 +1,29 @@
import { render } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { CategoryIcon } from "./category-icon"
describe("CategoryIcon", () => {
it("为 book-search 渲染实际的 BookSearch 图标", () => {
const { container } = render(<CategoryIcon name="book-search" />)
const svg = container.querySelector("svg")
expect(svg).not.toBeNull()
expect(svg).toHaveClass("lucide-book-search")
})
it("book-open 仍渲染 BookOpen 而非 BookSearch", () => {
const { container } = render(<CategoryIcon name="book-open" />)
const svg = container.querySelector("svg")
expect(svg).not.toBeNull()
expect(svg).toHaveClass("lucide-book-open")
expect(svg).not.toHaveClass("lucide-book-search")
})
it("未知图标回退到 Sparkles", () => {
const { container } = render(<CategoryIcon name="not-exist" />)
expect(container.querySelector("svg")).toHaveClass("lucide-sparkles")
})
})
@@ -1,5 +1,6 @@
import { import {
BookOpen, BookOpen,
BookSearch,
CirclePlay, CirclePlay,
Code2, Code2,
Coffee, Coffee,
@@ -14,6 +15,7 @@ const CATEGORY_ICONS: Readonly<Record<string, LucideIcon>> = {
code: Code2, code: Code2,
palette: Palette, palette: Palette,
"book-open": BookOpen, "book-open": BookOpen,
"book-search": BookSearch,
zap: Zap, zap: Zap,
"circle-play": CirclePlay, "circle-play": CirclePlay,
coffee: Coffee, coffee: Coffee,
@@ -57,6 +57,7 @@ describe("静态数据 service", () => {
"haowallpaper", "haowallpaper",
"iodraw", "iodraw",
"pixiv", "pixiv",
"lucide",
]) ])
expect(getLinksByCategory("life").map(({ id }) => id)).toEqual(["amap"]) expect(getLinksByCategory("life").map(({ id }) => id)).toEqual(["amap"])
}) })