Compare commits
7
Commits
ab83ad96b4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c812cfb663 | ||
|
|
97b26635fa | ||
|
|
bef30be3fe | ||
|
|
cd0e13325f | ||
|
|
759bc3fc35 | ||
|
|
b72b14a818 | ||
|
|
306107af83 |
@@ -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 和真实端口。
|
||||||
|
- 需要真实服务的联调测试单独说明和执行,不混入默认测试。
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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。
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ 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"
|
||||||
@@ -13,19 +19,35 @@ type Category struct {
|
|||||||
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})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 执行查询使用。
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# service 包
|
# service 包
|
||||||
|
|
||||||
- 承载业务规则与事务协调,通过 model 完成数据访问。
|
- 当前目录为占位,尚无 Go 实现;新增代码前先确认职责边界。
|
||||||
- 需要原子性时,在同一事务内协调多次写入,不把各自独立提交的多次写入当成原子业务。
|
- 预期职责:业务规则与事务协调,通过 model 完成数据访问。
|
||||||
|
- 需要原子性时,在同一事务内协调多次写入;当前没有现成事务接口,需在实现时设计,不把各自独立提交的多次写入当成原子业务。
|
||||||
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
|
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
|
||||||
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"])
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user