Compare commits

...
10 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
labmem ab83ad96b4 feat(lyra)!: 接入分类查询并集中组装依赖
- 使用 Squirrel 与 sqlx 实现分类查询
- 集中组装依赖并向 handler 注入 model 接口
- 显式转换分类响应 DTO,统一空列表输出
- 补充路由与 handler 测试,同步架构文档

BREAKING CHANGE: 将 GET /hello 替换为 GET /test,响应由问候字符串改为分类列表
2026-10-17 17:06:20 +08:00
labmem 8c0c9978e5 feat(lyra): 接入 PostgreSQL 与 Goose 迁移
- 使用 pgx/sqlx 建立连接,从环境变量读取数据库密码
- 添加本地 PostgreSQL Compose 配置与初始迁移
- 补充 Goose 操作手册和连接串排错说明
2026-10-17 15:09:46 +08:00
labmem 3f16384376 feat(lyra): 启动 HTTP 服务并添加示例接口
- 注册 GET /hello,使用统一响应结构并添加路由回归测试
- 使用 lyra.toml 配置 Gin 模式与监听地址,处理启动错误
- 明确 handler/service 业务分包约定并同步文档
2026-10-17 10:32:44 +08:00
50 changed files with 1965 additions and 124 deletions
+1
View File
@@ -46,3 +46,4 @@ Thumbs.db
# ---- Note 目录 ---- # ---- Note 目录 ----
note/项目/ note/项目/
docs/plans docs/plans
go/projects/lyra/data
+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 和真实端口。
- 需要真实服务的联调测试单独说明和执行,不混入默认测试。
+44 -20
View File
@@ -1,42 +1,66 @@
# Lyra # Lyra
链接、分类、设置等资源的后端服务(规划中)。目标结构与技术选型见 链接、分类、设置等资源的后端服务(实现中)。目标结构与技术选型见
[docs/architecture.md](docs/architecture.md)。 [docs/architecture.md](docs/architecture.md)。
## 当前状态 ## 当前状态
已搭建目录骨架与程序入口 `main.go`,各内部目录的职责与依赖约束记录在 `AGENTS.md` 中。 HTTP 通路已延伸到数据库:启动时加载本地 `.env.local` 与 `etc/lyra.toml`,经
`internal/api/response.go` 定义公共响应结构;仅含 `AGENTS.md` 的目录暂不构成 Go 包。 sqlx 连接 PostgreSQL(注册 pgx stdlib 驱动,驱动名取自配置),组装依赖后注册
业务逻辑、数据库访问、HTTP API 与迁移文件均未实现;架构文档中标注示例的 `GET /test`——由 `handler.TestHandler` 调用 `model.CategoryModel.FindAll` 查询
文件(links 等)尚未创建。当前运行程序后直接退出——不连接数据库, `public.categories`(当前固定 `sort >= 1`、按 sort/id 排序、limit 10),并经
也不启动 HTTP 服务。 显式转换为本轮新增的 `api.CategoryResponse` 响应 DTO 后输出。该接口用于验证
handler→model→数据库链路,不是完整业务 API。
数据库连接代码当前在入口 `main` 中,`internal/postgres` 与 `internal/service`
仍是占位(仅 `AGENTS.md`),业务 service 未实现;连接与查询未设置超时,连接
真实数据库的端到端验证本轮也未做。
`internal/model` 已落地 `Category` 结构、`CategoryModel` 接口与 Squirrel +
sqlx 实现。路由与 handler 均有基于替身的 httptest 回归测试(不监听实际端口、
不连接数据库),不证明真实 SQL 可用。各内部目录的职责与依赖约束记录在各目录
`AGENTS.md` 中。数据库迁移 SQL 已就位:`migrations/00001_init.sql`(初始函数
与三张表),通过手工 goose CLI 执行,操作手册见
[docs/goose.md](docs/goose.md),迁移目录说明见
[migrations/README.md](migrations/README.md);Goose 的 Go 依赖仍未引入。
架构文档中标注“待新增”的文件(links 等)尚未创建。
## 目录职责 ## 目录职责
| 路径 | 职责 | | 路径 | 职责 |
|---|---| |---|---|
| `cmd/lyra/` | 程序入口:启动、依赖组装、退出清理(待实现) | | `cmd/lyra/` | 程序入口:启动、依赖组装、退出清理(数据库连接当前建在此处) |
| `internal/api/` | HTTP 请求/响应 DTO,按资源分文件 | | `internal/api/` | HTTP 请求/响应 DTO,按资源分文件(平铺) |
| `internal/config/` | 配置定义与加载 | | `internal/config/` | 配置定义与加载 |
| `internal/postgres/` | PostgreSQL 连接(pgx stdlib) | | `internal/postgres/` | PostgreSQL 连接的目标位置(占位:连接暂在入口 main) |
| `internal/router/` | 路由注册、项目路径前缀 | | `internal/router/` | 路由注册、项目路径前缀(router.go,平铺) |
| `internal/handler/` | 参数绑定、DTO 转换、响应输出 | | `internal/handler/` | 参数绑定、DTO 转换、响应输出;可按业务分包,服务级简单接口平铺在根包 |
| `internal/service/` | 业务规则、事务协调 | | `internal/service/` | 业务规则、事务协调;可按业务分包,服务级简单接口平铺在根包(占位) |
| `internal/model/` | 数据结构定义与数据访问(SQL) | | `internal/model/` | 数据结构定义与数据访问(SQL,平铺) |
| `internal/httpx/` | 公共响应辅助,按需实现 | | `internal/httpx/` | 公共响应辅助 |
| `migrations/` | Goose SQL 迁移文件(暂无) | | `migrations/` | Goose SQL 迁移文件(00001 初始迁移已就位) |
| `docs/` | 架构与计划文档 | | `docs/` | 架构与计划文档 |
## 运行 ## 运行
Go 命令在本目录(module 根)下执行: Go 命令在本目录(module 根)下执行;`.env.local` 与配置路径 `etc/lyra.toml`
均按当前工作目录解析,换目录运行会找不到它们:
```bash ```bash
go run ./cmd/lyra # 当前直接退出,不连接数据库、不启动 HTTP go run ./cmd/lyra # 启动 HTTP 服务,监听地址与端口见 etc/lyra.toml
go test ./... # 当前无测试文件,仅验证各包可编译 go test ./... # 回归测试(不监听实际端口、不连接数据库)
``` ```
启动前提:本地存在 `.env.local`(提供数据库口令等环境变量,已被 gitignore、
不入库,内容不在文档展开)、`etc/lyra.toml` 配置完整,且其指向的 PostgreSQL
可达;连接失败时启动即退出。
配置中的 `app.env` 直接传给 `gin.SetMode`,仅支持 `debug`/`release`/`test`。
环境变量方面当前仅 `database.password` 绑定了 `PG_PASSWORD`,其余配置项不
支持环境变量覆盖,也没有校验与默认值。
## 技术栈 ## 技术栈
已引入 Gin 与 Viper(读取 TOML 配置),go.sum 已生成; 已引入并使用:Gin(HTTP 路由)、Viper(读取 TOML 配置)、godotenv(加载
PostgreSQL、Squirrel + sqlx(pgx stdlib 驱动)与 Goose 为目标选型,随后续功能引入。 `.env.local`)、sqlx + Squirrel(查询构造与执行)、pgx stdlib(PostgreSQL
驱动)。Goose 仅以独立 CLI(v3.29.0)手工执行迁移,Go 依赖尚未引入。
+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,
})
}
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"lyra/internal/cache"
"lyra/internal/config"
"lyra/internal/handler"
"lyra/internal/model"
"lyra/internal/router"
"time"
"github.com/jmoiron/sqlx"
"github.com/redis/go-redis/v9"
)
type Dependencies struct {
Config *config.Config
Cache *cache.Cache
CategoryModel model.CategoryModel
}
// buildHandlers 集中组装各路由的处理器,入口启动时调用一次。
func buildHandlers(dpd *Dependencies) router.Handlers {
return router.Handlers{
TestHandler: handler.NewTestHandler(dpd.CategoryModel, dpd.Cache).Hello(),
}
}
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{
Config: cfg,
Cache: cache,
CategoryModel: model.NewCategoryModel(db, cache),
}
}
+48 -9
View File
@@ -1,20 +1,59 @@
// Command lyra 是 Lyra 的程序入口,负责启动、依赖组装与退出清理。 // Command lyra 是 Lyra 的程序入口,负责启动、依赖组装与退出清理。
//
// 当前仅搭建目录骨架:运行后直接退出,不连接数据库,也不启动 HTTP 服务。
package main package main
import ( import (
"fmt" "context"
"log/slog"
"lyra/internal/config" "lyra/internal/config"
"lyra/internal/router"
"net"
"os"
"strconv"
"github.com/joho/godotenv"
) )
func main() { func main() {
// 加载配置(internal/config) if err := godotenv.Load(".env.local"); err != nil {
cfg, err := config.Load("etc/config.toml") slog.Error("加载环境变量失败,请检查文件是否存在及格式是否正确")
os.Exit(1)
}
cfg, err := config.Load("etc/lyra.toml")
if err != nil { if err != nil {
panic(err) slog.Error("加载配置失败,请检查配置文件和环境变量")
os.Exit(1)
}
if err := setLogger(cfg); err != nil {
slog.Error("设置日志失败", "error", err)
os.Exit(1)
}
r, err := newHTTPRouter(cfg.App.Env)
if err != nil {
slog.Error("初始化 HTTP 路由失败", "error", err)
os.Exit(1)
}
ctx := context.Background()
db, err := newDatabase(ctx, cfg.Database)
if err != nil {
slog.Error("初始化数据库失败", "error", err)
os.Exit(1)
}
defer db.Close()
cache := newCache(cfg.Cache)
defer cache.Close()
deps := buildDependencies(cfg, db, cache)
handlers := buildHandlers(deps)
router.RegisterRoutes(r, handlers)
if err := r.Run(net.JoinHostPort(cfg.HTTP.Host, strconv.Itoa(cfg.HTTP.Port))); err != nil {
slog.Error("启动 HTTP 服务失败", "error", err)
os.Exit(1)
} }
fmt.Printf("%+v\n", cfg)
// TODO: 组装依赖——建立 PostgreSQL 连接(internal/postgres)、注册路由(internal/router)。
// TODO: 启动 HTTP 服务,并在退出时关闭连接、释放资源。
} }
+36
View File
@@ -0,0 +1,36 @@
version: "3.8"
services:
postgres:
image: abcfy2/zhparser:16-alpine
container_name: lab-postgres
restart: unless-stopped
environment:
POSTGRES_DB: lab
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${PG_PASSWORD}
volumes:
- ./data/postgres:/var/lib/postgresql/data
# 测试环境不用刻意限制内存,让 Docker 自然调度即可
networks:
- net-db # 只加入数据库专用网络
# 如果外部需要直连调试,可临时 expose 端口
ports:
- "127.0.0.1:5432:5432"
redis:
image: redis:7-alpine
container_name: lab-redis
restart: unless-stopped
# 测试环境保留持久化更省心,不用关闭
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- ./data/redis:/data
networks:
- net-db
ports:
- "127.0.0.1:6379:6379"
networks:
net-db:
external: true # 使用预先创建的外部网络
+47 -30
View File
@@ -1,53 +1,65 @@
# Lyra 目标结构 # Lyra 目标结构
日期:2026-10-16 日期:2026-10-17
本文记录已确认的目标目录结构与职责边界,并与当前真实实现同步。目录树是**目标结构**,并非全部已存在:当前只有 `cmd/lyra/main.go`、`internal/config/config.go`、`internal/api/{code,response}.go`、`internal/httpx/response.go` 是 Go 代码;`internal/{handler,model,postgres,router,service}` 仅含 `AGENTS.md`(记录职责与依赖约束),暂不构成 Go 包。选型与讨论过程见 [plans/2026-10-16-lyra-architecture.md](plans/2026-10-16-lyra-architecture.md)。 本文记录已确认的目标目录结构与职责边界,并与当前真实实现同步。目录树是**目标结构**,并非全部已存在:标注“待新增”的文件尚未创建;标注“占位”的目录(`internal/postgres`、`internal/service`)仅含 `AGENTS.md`(记录职责与依赖约束),暂不构成 Go 包。当前已有 Go 代码的是 `cmd/lyra` 与 `internal/{api,config,handler,httpx,model,router}`。仓库内另有本地 `.env.local`(已被 gitignore、不入库)等文件,不属于目标结构,不在此展开。选型与讨论过程见 [plans/2026-10-16-lyra-architecture.md](plans/2026-10-16-lyra-architecture.md);handler/service 业务分包的调整记录见 [plans/2026-10-17-business-package-layout.md](plans/2026-10-17-business-package-layout.md)。
## 已确认技术栈 ## 已确认技术栈
- 已引入:Gin v1.12.0(HTTP 路由)、Viper v1.21.0(配置读取,TOML)。`go.sum` 已由 Go 工具生成。 - 已引入并使用:Gin v1.12.0(HTTP 路由)、Viper v1.21.0(配置读取,TOML)、godotenv v1.5.1(启动时加载本地 `.env.local`)、sqlx v1.4.0 + Squirrel v1.5.4(查询构造与执行)、pgx v5.11.0 stdlib(PostgreSQL 驱动,由 sqlx 以 "pgx" 驱动名使用)。`go.sum` 已由 Go 工具生成。
- 目标选型(选型已确认,尚未作为代码依赖引入):PostgreSQL、Squirrel + sqlx(pgx stdlib 驱动)、Goose(数据库迁移)。 - Goose:迁移 SQL 已就位,以独立 goose CLI(v3.29.0)执行,Go 依赖尚未引入。
- PostgreSQL 已在启动时连接;连接代码当前在入口 `main`,收拢到 `internal/postgres` 是目标结构。
## 当前进度 ## 当前进度
- 程序入口按相对工作目录的 `etc/config.toml` 加载配置(Viper,`mapstructure` 标签),打印结果后退出;不启动 HTTP 服务,也不连接数据库。 - 程序入口在 module 根目录以 `go run ./cmd/lyra` 启动,流程为:`godotenv.Load` 读取本地 `.env.local`(不覆盖已存在的进程环境变量)→ `config.Load` 读取相对当前工作目录的 `etc/lyra.toml`(Viper,`mapstructure` 标签)→ `gin.SetMode` → 以 `gin.Default()` 创建引擎并 `SetTrustedProxies(nil)` 关闭代理信任 → `sqlx.ConnectContext` 建立 PostgreSQL 连接(失败即退出,`defer db.Close()`)→ `buildDependencies`/`buildHandlers` 组装依赖 → `RegisterRoutes` → 监听配置的地址与端口。`.env.local` 与 `etc/lyra.toml` 均按当前工作目录解析,换目录执行会找不到。连接与查询当前未设置超时。
- 已定义公共响应契约:`api.Response`(success/data/error)、`api.ErrorInfo`(code/message/debug)与错误码类型 `api.ErrorCode` 及其默认文案 `Message()`。 - 已注册 `GET /test`:`internal/router/router.go` 的 `RegisterRoutes` 将其指向入口注入的 `router.Handlers.TestHandler`(`gin.HandlerFunc`,由 `handler.NewTestHandler(...).Hello()` 构造)。处理器调用 `model.CategoryModel.FindAll` 查询 `public.categories`——当前固定 `sort >= 1`、按 `sort`/`id` 排序、`limit 10`——经显式转换为本轮新增的 `api.CategoryResponse` 后由 `httpx.OK` 输出;查询出错经 `httpx.Fail` 返回 500 与 `api.CodeInternalError`。该接口用于验证 handler→model→数据库链路,不是完整业务 API。
- 已实现响应输出辅助 `httpx.OK`、`httpx.Fail`。 - `internal/model` 已落地:`Category` 结构(id/slug/title/sort)、`CategoryModel` 接口与 `defaultCategoryModel` 实现——Squirrel 构造 SQL(Dollar 占位符),sqlx `SelectContext` 执行与映射。
- 尚未实现:业务逻辑、路由注册、数据库访问与迁移 SQL;配置校验、环境变量覆盖与默认值;`ErrorInfo.Debug` 的填充与按环境开关。日志方案未定。 - 已定义公共响应契约:`api.Response`(success/data/error)、`api.ErrorInfo`(code/message/debug)与错误码类型 `api.ErrorCode` 及其默认文案 `Message()`;响应输出辅助 `httpx.OK`、`httpx.Fail` 已实现并被 handler 使用。
- 配置:`database.password` 绑定了环境变量 `PG_PASSWORD`(可经 `.env.local` 或进程环境提供),其余配置项暂不支持环境变量覆盖;没有校验与默认值。`Redis` 配置项已定义但暂无消费方。
- 测试:路由注册有 httptest 回归测试,覆盖 `GET /test` 注入替身处理器的调用,以及未知路径/不支持方法返回 404;handler 有基于假 `CategoryModel` 的替身测试,覆盖 DTO 转换、空结果输出 `data:[]`、模型出错返回 500 且不泄漏内部错误信息、请求 context 透传。两类测试均不监听实际端口、不连接数据库,因此不证明真实 SQL 可用——连接真实数据库的端到端验证本轮未做。
- 数据库迁移已就位:`migrations/00001_init.sql`(`set_updated_at()` 函数、users/categories/links 三张表、约束、注释、索引与触发器,建库内容与 [demo.sql](demo.sql) 一致),由手工 goose CLI(v3.29.0)执行,见 [../migrations/README.md](../migrations/README.md)。
- 尚未实现:业务 service 层与完整业务 API;连接收拢到 `internal/postgres`(当前连接代码在入口 `main`);Goose Go 依赖;配置校验与默认值;`ErrorInfo.Debug` 的填充与按环境开关;连接/查询超时。日志方案未定。
## 目录结构 ## 目录结构
```text ```text
lyra/ lyra/
├── cmd/lyra/main.go # 启动、依赖组装、退出清理 ├── cmd/lyra/
│ ├── main.go # 启动流程:加载 .env.local 与配置、连接数据库、组装依赖
│ └── dependency.go # Dependencies 与 handler 组装
├── internal/ ├── internal/
│ ├── api/ # HTTP 请求/响应 DTO、错误码与响应契约 │ ├── api/ # HTTP 请求/响应 DTO、错误码与响应契约(平铺,按资源分文件)
│ │ ├── response.go # Response/ErrorInfo 结构 │ │ ├── response.go # Response/ErrorInfo 结构
│ │ ├── code.go # ErrorCode 与错误码常量、默认文案 │ │ ├── code.go # ErrorCode 与错误码常量、默认文案
│ │ ├── category.go # CategoryResponse 分类响应 DTO
│ │ ├── links.go # links 资源 DTO 示例(待新增) │ │ ├── links.go # links 资源 DTO 示例(待新增)
│ │ ├── categories.go # 待新增
│ │ └── settings.go # 待新增 │ │ └── settings.go # 待新增
│ ├── config/ │ ├── config/
│ │ └── config.go # 配置定义与加载 │ │ └── config.go # 配置定义与加载
│ ├── postgres/ │ ├── postgres/ # 数据库连接的目标位置(占位:仅 AGENTS.md,连接暂在 cmd/lyra/main.go)
│ │ └── postgres.go # 数据库连接(待新增)
│ ├── router/ │ ├── router/
│ │ └── router.go # 路由注册、项目路径前缀(待新增) │ │ ├── router.go # 路由注册、项目路径前缀
│ │ └── router_test.go # 路由 httptest 回归测试
│ ├── handler/ │ ├── handler/
│ │ └── links.go # 参数绑定、DTO 转换、响应输出(待新增) │ │ ├── test.go # GET /test 处理:调用 model、显式 DTO 转换、响应输出
│ ├── service/ │ │ ├── test_test.go # handler 替身测试
│ │ ├── links.go # 参数绑定、DTO 转换、响应输出(待新增)
│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包)
│ ├── service/ # 业务规则、事务协调(占位:仅 AGENTS.md)
│ │ ├── links.go # 业务规则、事务协调(待新增) │ │ ├── links.go # 业务规则、事务协调(待新增)
│ │ └── links_test.go # 测试与源文件同目录(待新增) │ │ ├── links_test.go # 测试与源文件同目录(待新增)
│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包)
│ ├── model/ │ ├── model/
│ │ ├── category.go # Category 结构、CategoryModel 接口与 Squirrel+sqlx 实现
│ │ ├── link.go # Link 数据结构定义(待新增) │ │ ├── link.go # Link 数据结构定义(待新增)
│ │ ├── link_model.go # 数据访问对象:CRUD 与 SQL(待新增) │ │ ├── link_model.go # 数据访问对象:CRUD 与 SQL(待新增)
│ │ └── link_model_test.go # 查询测试(待新增) │ │ └── link_model_test.go # 查询测试(待新增)
│ └── httpx/ │ └── httpx/
│ └── response.go # 公共响应输出辅助(OK/Fail) │ └── response.go # 公共响应输出辅助(OK/Fail)
├── etc/ ├── etc/
│ └── config.toml # 运行配置(App/HTTP) │ └── lyra.toml # 运行配置(App/HTTP/Database/Redis)
├── migrations/ # Goose SQL 迁移文件(暂无) ├── migrations/ # Goose SQL 迁移文件(00001_init.sql 初始迁移 + README)
├── docs/ ├── docs/
│ ├── plans/ │ ├── plans/
│ └── architecture.md # 本文 │ └── architecture.md # 本文
@@ -56,33 +68,38 @@ lyra/
└── go.sum # 已生成 └── go.sum # 已生成
``` ```
- 标注“待新增”的文件只是职责分布示例,随实现按需新增,不预建空文件或空目录;`internal/` 各目录均以 `AGENTS.md` 记录职责与依赖约束。 - 标注“待新增”的文件只是职责分布示例,随实现按需新增,不预建空文件或空目录;`internal/` 各目录均以 `AGENTS.md` 记录职责与依赖约束(树中未逐一画出)。`internal/postgres`、`internal/service` 目前仅有 `AGENTS.md`,为占位目录,不构成 Go 包。
- model 下的 `link*.go` 展示一个资源在各层的文件分布;分类、设置等资源按同样方式在 api/handler/service/model 新增对应文件。具体迁移文件名随首个迁移确定。 - model 下的 `link*.go` 展示一个资源在各层的文件分布;分类资源已按此方式在 api/model 落地(`api/category.go`、`model/category.go`,handler 为链路验证用的 `test.go`),设置等资源按同样方式新增。首个迁移已落地为 `migrations/00001_init.sql`。
- `handler/polaris/`、`service/polaris/` 仅示意业务分包的形态:实际分包与否随业务边界明确时决定,路由、api、model 不照搬同样的分包。
## 组织原则 ## 组织原则
- 不按业务项目建代码子目录(无 polaris 等项目目录);项目通过 `/api/polaris` 等路由前缀区分,代码按技术职责分组,包内文件按资源划分。 - handler、service 可以按明确业务分包(如 `polaris/`),仅在业务边界清晰时新增;服务级简单接口(如 `hello`)保留在各自根包内平铺,不强制分包。
- 不保留 platform/modules 分类层;配置在 internal/config,连接在 internal/postgres,HTTP 辅助在 internal/httpx。 - router、api、model 等其他层暂时平铺,不按业务项目建代码子目录;项目通过 `/api/polaris` 等路由前缀区分,包内文件按资源划分。
- 简单接口不强制走完整三层,也不按每张表机械复制层级。 - 公共基础能力全局唯一:配置在 internal/config,连接在 internal/postgres,HTTP 辅助在 internal/httpx,不随业务项目复制或按项目再拆。
- 不保留 platform/modules 分类层;简单接口不强制走完整三层,也不按每张表机械复制层级。
- 不预建空业务包,分包随实际业务代码一起落地。
## 层间边界 ## 层间边界
- 主调用链为 handler→service→model→PostgreSQL;service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。 - 真实业务的主调用链为 handler→service→model→PostgreSQL;简单接口可以不经过 service——当前 `GET /test` 即由 handler 直接调用 model(service 层尚未实现)。service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。
- 请求/响应 DTO 集中在 internal/api,与内部模型分开;即使初期字段相近,也不默认把数据库模型直接暴露为响应,接受必要的显式转换代码。 - 请求/响应 DTO 集中在 internal/api,与内部模型分开;即使初期字段相近,也不默认把数据库模型直接暴露为响应,接受必要的显式转换代码(分类列表已经 handler 的 `toCategoryResponses` 转为 `api.CategoryResponse`)。
- handler 引用 API DTO 完成绑定与转换;service/model 不依赖 Gin,也不依赖 internal/api DTO;api 包不反向引用 handler/service/model,避免传输契约与实现耦合或循环引用。 - handler 引用 API DTO 完成绑定与转换;service/model 不依赖 Gin,也不依赖 internal/api DTO;api 包不反向引用 handler/service/model,避免传输契约与实现耦合或循环引用。
- internal/api 拥有响应契约与业务错误码(`Response`/`ErrorInfo`/`ErrorCode`);不依赖 Gin。 - internal/api 拥有响应契约与业务错误码(`Response`/`ErrorInfo`/`ErrorCode`);不依赖 Gin。
- internal/httpx 负责响应输出:`OK` 固定 HTTP 200;`Fail` 接收 HTTP 状态码与业务错误码,`message` 为空时回退错误码默认文案。HTTP 状态码表示传输/结果类别,`ErrorCode` 表示自定义业务错误,两者分开。 - internal/httpx 负责响应输出:`OK` 固定 HTTP 200;`Fail` 接收 HTTP 状态码与业务错误码,`message` 为空时回退错误码默认文案。HTTP 状态码表示传输/结果类别,`ErrorCode` 表示自定义业务错误,两者分开。
- 配置由入口 `main` 加载后组装并传递(约定/目标):`internal/config` 只负责配置定义与加载,运行配置来自 `etc/config.toml`;当前 `main` 仅加载并打印,尚未向其他组件传递。 - internal/router 负责把 handler 挂到路由上:当前注册 `GET /test`,指向入口组装注入的 `Handlers.TestHandler`,后续按业务项目路径前缀(如 `/api/polaris`)扩展。
- model 承载数据结构定义与数据访问:Squirrel/SQL 构造、sqlx 执行与结果映射,驱动为 pgx stdlib;不是 ORM 自动持久化,也不要求每种类型机械建立一套结构。 - 配置由入口 `main` 加载后组装并传递:`internal/config` 只负责配置定义与加载,运行配置来自 `etc/lyra.toml`;当前 `app.env` 直接传给 `gin.SetMode`(仅 `debug`/`release`/`test`),HTTP 地址端口供启动使用,数据库相关配置由入口 `main` 组装 DSN 并用于连接(`database.password` 可来自 `PG_PASSWORD`),`Redis` 配置暂无消费方。
- model 承载数据结构定义与数据访问:Squirrel/SQL 构造、sqlx 执行与结果映射,驱动为 pgx stdlib(已按此落地 `internal/model/category.go`);不是 ORM 自动持久化,也不要求每种类型机械建立一套结构。
- service/model 使用标准 context.Context,不接收 gin.Context。 - service/model 使用标准 context.Context,不接收 gin.Context。
## 尚未确定 / 待实现 ## 尚未确定 / 待实现
以下均为计划中的推荐或待确认项,尚未实现,也未定为已决: 以下均为计划中的推荐或待确认项,尚未实现,也未定为已决:
- 配置校验、环境变量覆盖与默认值:尚未实现(当前无任何校验、覆盖或默认值)。 - 配置校验与默认值:尚未实现。环境变量覆盖当前仅 `database.password` 绑定 `PG_PASSWORD`,其余配置项不支持。
- 连接与查询超时:尚未设置,也未确定超时策略。
- `ErrorInfo.Debug` 的填充与按环境(dev)开关:尚未实现,目前仅有字段。 - `ErrorInfo.Debug` 的填充与按环境(dev)开关:尚未实现,目前仅有字段。
- 分页响应约定(如 data/meta 结构)与统一错误处理中间件:尚未引入,仅为历史讨论。 - 分页响应约定(如 data/meta 结构)与统一错误处理中间件:尚未引入,仅为历史讨论。
- Goose 的部署执行入口与并发控制(串行执行或数据库锁)方案。 - Goose 迁移的部署执行入口已确定为手工 CLI(见 [../migrations/README.md](../migrations/README.md)),并发控制以“单操作者串行执行”为约定,数据库锁未引入,服务启动不自动迁移。
- PostgreSQL 按项目划分 schema 的做法(组织边界,不等于权限隔离)。 - PostgreSQL 按项目划分 schema 的做法(组织边界,不等于权限隔离)。
- 日志方案(候选标准库 slog)与中间件集成方式。 - 日志方案(候选标准库 slog)与中间件集成方式。
+3 -7
View File
@@ -71,16 +71,12 @@ CREATE TABLE public.links (
pinned_sort integer, pinned_sort integer,
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT links_category_fk CONSTRAINT links_category_fk FOREIGN KEY (category_id) REFERENCES public.categories (id) ON DELETE RESTRICT,
FOREIGN KEY (category_id) REFERENCES public.categories (id)
ON DELETE RESTRICT,
CONSTRAINT links_title_not_empty CHECK (btrim(title) <> ''), CONSTRAINT links_title_not_empty CHECK (btrim(title) <> ''),
CONSTRAINT links_url_not_empty CHECK (btrim(url) <> ''), CONSTRAINT links_url_not_empty CHECK (btrim(url) <> ''),
CONSTRAINT links_pinned_sort_consistent CHECK (pinned = (pinned_sort IS NOT NULL)), CONSTRAINT links_pinned_sort_consistent CHECK (pinned = (pinned_sort IS NOT NULL)),
CONSTRAINT links_keywords_one_dimension CONSTRAINT links_keywords_one_dimension CHECK (cardinality(keywords) = 0 OR array_ndims(keywords) = 1),
CHECK (cardinality(keywords) = 0 OR array_ndims(keywords) = 1), CONSTRAINT links_keywords_no_null_elements CHECK (array_position(keywords, NULL) IS NULL)
CONSTRAINT links_keywords_no_null_elements
CHECK (array_position(keywords, NULL) IS NULL)
); );
COMMENT ON TABLE public.links IS '链接表,归属分类'; COMMENT ON TABLE public.links IS '链接表,归属分类';
+179
View File
@@ -0,0 +1,179 @@
# Goose 迁移操作手册
Lyra 数据库迁移的**唯一完整操作手册**。`migrations/` 目录是数据库结构的
唯一正式执行来源;[demo.sql](demo.sql) 仅保留作参考,不要再手工执行它。
迁移目录本身的说明见 [migrations/README.md](../migrations/README.md)。
## 目录与前提
- 所有 goose 命令都在 Lyra module 根目录(`migrations/` 的上一级)执行:
```bash
cd go/projects/lyra # 从仓库根出发
```
- 目标数据库**已创建**:迁移不负责建库,`lab` 库需先存在。
- 首次迁移要求目标对象**尚不存在**:如果之前手工执行过 demo.sql 建过
同名对象,`up` 会报 `already exists`,先按故障表评估处理。
## 安装 goose CLI(固定 v3.29.0)
goose 只作为独立命令行工具使用(Go 应用尚未引入 goose 依赖):
```bash
go install -tags='no_clickhouse no_libsql no_mssql no_mysql no_sqlite3 no_vertica no_ydb' github.com/pressly/goose/v3/cmd/goose@v3.29.0
goose -version # 应输出 v3.29.0
```
- 固定 `@v3.29.0`,保证各机器行为一致。
- `-tags=...` 是可选的 PostgreSQL 专用精简构建(`no_*` 标签已在 goose
v3.29.0 源码中核实);不追求精简可省略。
- 二进制装到 `$(go env GOPATH)/bin`(设了 `GOBIN` 则装到 `GOBIN`)。
`goose -version` 提示找不到命令时,把该目录加入 `PATH`:
```bash
export PATH="$PATH:$(go env GOPATH)/bin" # 可写入 ~/.bashrc
```
## 配置连接:.env.local
goose **不读取**应用的 `etc/lyra.toml`,也不会只凭 `PG_PASSWORD` 之类的
应用环境变量自动拼出连接串——它只认自己环境变量里的 `GOOSE_DBSTRING`。
在 module 根创建 `.env.local` 并追加以下内容(**全部是占位值**,换成实际
信息;`lab` 库需已创建):
```bash
GOOSE_DRIVER=postgres
GOOSE_MIGRATION_DIR=./migrations
GOOSE_DBSTRING="postgres://example_user:URL_ENCODED_PASSWORD@127.0.0.1:5432/lab?sslmode=disable"
```
要点:
- `GOOSE_DRIVER=postgres` 是 **goose 的驱动名**;Go 应用内用的是 pgx
(驱动名 `pgx`),两边名字不同但都连 PostgreSQL,不要混填。
- 本仓库 `.gitignore` 已忽略 `.env*`,`.env.local` 不会提交;建议再执行
`chmod 600 .env.local` 收紧文件权限。
- `127.0.0.1` 与端口必须换成**实际可达**的地址和端口(远程/容器按实际
映射填写)。
- `sslmode=disable` 仅适用于本地确认无 TLS 要求的场景;远程环境按部署的
TLS 要求调整该参数。
- 本手册所有 goose 命令统一用 `-env=.env.local` 显式加载该文件:goose
默认读的是 `.env`,`-env=.env.local` 既明确来源、又避免误用其他本地
配置。连接信息配置一次即可,无需每次交互输入密码。
- 系统环境里已有的同名变量(如曾 `export` 过 `GOOSE_DBSTRING`)可能优先
于 `.env.local` 生效:改了 `.env.local` 仍行为异常时,先查是否存在同名
环境变量——只列变量名、不打印值,避免泄露连接串:
```bash
env | cut -d= -f1 | grep -i '^GOOSE'
```
## 连接串特殊字符:URL 编码
`GOOSE_DBSTRING` 是 URL 格式:**用户名、密码**中的特殊字符必须逐个做
百分号编码,否则主机名/路径解析会错位。
- 对用户名和密码分别编码,**不要把整串 URL 一起编码**,也不要为迁就 DSN
去修改数据库里的实际密码。避免对已经编码的内容重复编码。
- 不要把真实密码交给在线网站转换;按下表手工替换即可。
| 字符 | 编码 | 字符 | 编码 |
|---|---|---|---|
| `@` | `%40` | `:` | `%3A` |
| `#` | `%23` | `/` | `%2F` |
| `%` | `%25` | `?` | `%3F` |
示例:密码 `demo#pass`(演示值)在 DSN 中写成 `demo%23pass`。
## 常用命令
```bash
# 离线检查迁移文件能否被 goose 解析(注解与语句拆分);
# 不连接数据库,也不验证 SQL 在 PostgreSQL 上的语义
goose -env=.env.local validate
# goose 工具自身版本(与数据库里的迁移版本是两回事)
goose -version
# 连接数据库:查看迁移状态 / 当前版本
goose -env=.env.local status
goose -env=.env.local version
# 执行迁移(连接数据库)
goose -env=.env.local up
```
注意:v3.29.0 的 `validate` **不接受位置路径参数**,迁移目录只由
`GOOSE_MIGRATION_DIR`(`.env.local` 已设 `./migrations`)或 `-dir` 标志
决定。`validate` 通过只证明文件能解析,不证明连接正常或 SQL 语义正确。
## 首次初始化
前提:`lab` 库已创建,且 00001 中的对象(函数、三张表、索引、触发器)
都不存在——**不要**先手工执行 demo.sql 再 `up`。
```bash
goose -env=.env.local validate # 离线确认文件可解析
goose -env=.env.local status # 显示 00001 Pending:连接成功,该迁移尚未应用
goose -env=.env.local up # 执行 00001,并在 goose_db_version 中记录版本:
# set_updated_at() 函数、users/categories/links 三张表、
# 约束、注释、索引与触发器
goose -env=.env.local status # 00001 显示应用时间
goose -env=.env.local version # 显示 version 1
```
- `up` 可重复执行:已应用版本按 `goose_db_version` 自动跳过。
- **已执行过的迁移文件不再修改**;要变更结构就新增迁移文件(见下节)。
## 日常新增迁移
```bash
goose -env=.env.local -s create add_example_column sql
```
`-s` 表示顺序编号:在现有 00001 基础上生成
`migrations/00002_add_example_column.sql`(实际编号按目录中现有最大版本
延续)。例如给 users 表加一列,编辑生成的文件:
```sql
-- +goose Up
ALTER TABLE public.users
ADD COLUMN avatar_url text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE public.users
DROP COLUMN avatar_url;
```
然后按顺序执行:
```bash
goose -env=.env.local validate
goose -env=.env.local status
goose -env=.env.local up
goose -env=.env.local status
```
Up/Down 成对编写,保证迁移可回滚。
## 回滚警告
```bash
goose -env=.env.local down # ⚠ 危险操作,见下
```
- `down` 只回滚**最近一个**已应用迁移的 Down 部分,**不是**清库或重建的
通用命令。
- **回滚 00001 会删除 `links`/`categories`/`users` 三张表及表中全部数据。**
- 执行任何 `down` 之前:阅读对应迁移 SQL、评估数据损失、备份数据库。
不建议随手“试一下” `down`,也不要把它列入日常维护流程。
## 故障速查
| 报错特征 | 常见原因与处理 |
|---|---|
| `hostname resolving error`(主机名里混入非法字符) | DBSTRING 格式或 URL 编码不对:检查用户名/密码特殊字符是否已按上表编码、`://` 与 `@` `:` 分隔符是否完整 |
| `password authentication failed` | 用户名或密码不对:核对 `.env.local`(编码后)的值;再排查系统环境同名变量是否优先生效(只查变量名,不打印值) |
| `connection refused` / `i/o timeout` | 主机端口不可达:确认 PostgreSQL 实际监听地址、端口、容器端口映射与防火墙 |
| `already exists`(对象已存在) | 目标对象曾被手工创建(如执行过 demo.sql):先评估现有对象与迁移的差异再决定处理方式;**不要直接删数据**,也不要手工往 `goose_db_version` 插版本记录伪造状态 |
@@ -0,0 +1,42 @@
# 架构审查与提交前收尾
日期:2026-10-17
状态:审查及已批准修正完成,父会话已复查代码与文档;等待更新暂存区及确认提交信息。
## 当前结论
- 当前实际调用链为 main → buildDependencies → buildHandlers → router 的 GET /test → TestHandler.Hello → CategoryModel.FindAll → PostgreSQL。
- 显式注入、入口容器边界、model 接口和路由依赖方向成立;当前单条只读查询不需要额外 service 或事务框架。
- 审查发现的 DTO 边界与当前状态文档问题已修正:handler 显式转换为 api.CategoryResponse,README 与架构文档已同步。
- 超时、DSN 编码、优雅关闭和查询语义命名列为后续增强,不扩入本轮修正。
## 已批准的最小方案
1. 增加分类响应 DTO,以明确 JSON 字段,由现有 TestHandler 显式转换;空列表保持 [],不改变 SQL。
2. 用假的 CategoryModel 补 handler 测试,覆盖成功、空列表、失败和 context 传递,不连接数据库。
3. 更新 README.md 和 docs/architecture.md 的当前实现说明,历史计划保留为历史。
4. 最终执行格式检查、go test ./... -count=1、go build ./...、go vet ./...,父会话审阅最终差异。
5. 依 git-commit 流程确认暂存范围;任何重新暂存后重新检查全部索引文件的完整内容,再展示提交信息并等待明确确认。
## 实施分工与范围
- 代码 lane(复用 fixer):仅写 internal/api/category.go、internal/handler/test.go、internal/handler/test_test.go,负责 DTO 转换与成功/空结果/失败/context 测试及 Go 验证。
- 文档 lane(独立 fixer):仅写 README.md、docs/architecture.md,按当前磁盘源代码更新现状;不得声称真实数据库查询已验证。
- 父会话维护本计划、整合终态并检查实际差异;不修改 SQL、路由名称、连接逻辑、依赖版本或其他历史计划。
- 两个写入范围互不重叠;全部终态后确认验证证据仍适用,再进入重新暂存与完整索引安全检查。
## 已有验证及边界
- 当前未修正版本的 go test ./... -count=1、go build ./...、go vet ./...、gofmt -l . 全部通过。
- 当前路由测试不连接数据库,仅验证路由分发;真实 handler 与 SQL 查询尚未验证。
- 当前暂存的十个非删除文件完整内容已检查,未发现新增疑似真实敏感值或意外生成物;未暂存新的修正内容。
- 审查所有者:oracle 静态审查、fixer 独立验证、父会话负责取舍及提交安全检查。
## 修正后的证据
- 新增分类响应 DTO 和 handler 替身测试;成功映射、空切片与 nil 结果输出 []、失败返回通用 500、请求 context 透传均已验证。
- 代码 lane 在最终 Go 代码上执行 go test ./internal/handler -v -count=1、go test ./... -count=1、go build ./...、go vet ./... 均通过,新增及修改 Go 文件的格式检查通过。
- 文档 lane 仅修改 README.md 与 docs/architecture.md;父会话已读取 DTO、handler、测试与两份文档并复查,未发现新的阻断问题。
- 不重复运行未发生变化的 Go 代码验证;真实 SQL 与数据库端到端路径仍未验证。
- 更新暂存区后必须重新检查全部暂存文件的完整版本,之前索引安全检查不自动适用于新内容。
@@ -0,0 +1,30 @@
# Handler 与 Service 的业务分包调整
## 已确认范围
- handler、service 可以按明确业务分包;服务级简单接口保留在根包。
- 将 `/hello` 从 `internal/handler/lyra` 移至 `internal/handler`,同步路由引用;入口路由文件使用 `router.go`。
- router、api、model 等其他层暂时保持平铺,公共基础能力不随业务重复拆分。
- 不预建空业务包,不改变 `/hello` 路径、响应契约或配置环境语义,不顺带改变启动错误处理。
- 同步架构文档、README、相关目录约定与过时的入口注释;历史方案保留为历史记录。
## 工作分工与顺序
1. 代码执行 lane:负责 handler 移动、router 引用和文件名、入口过时注释,以及路由回归测试。
2. 文档执行 lane:并行负责 architecture.md、README.md、必要的目录 AGENTS.md,解释业务分包与当前实现状态。
3. 父代理:整合两条 lane 的结果,审查最终差异并登记验证结论。
## 验证路径与归属
- 代码 lane:新增或利用 `httptest` 路由测试,不监听实际端口、不连接数据库,确认 `GET /hello` 返回 HTTP 200 且维持现有响应契约。
- 父代理:在 Lyra module 根执行 `go test ./...`、`go vet ./...`,确认移动后的包引用与编译正确;若执行 lane 已完成同等检查且最终代码未变则复用证据。
- 文档 lane:核对当前配置文件名、HTTP 启动状态与目录组织说明,不把未来规划写成已实现能力。
- 父代理:检查最终 diff、旧包路径残留和敏感信息风险,保留用户既有未提交改动。
## 状态
- 代码与文档 lane 均已完成,最终差异已审查。
- `/hello` 已移至 handler 根包,路由引用已更新;未创建空业务包。
- 架构文档、README 与 handler/service/router 的目录约定已同步。
- 验证通过:`go test ./...`、`go vet ./...`、`git diff --check`;路由测试确认 HTTP 200 与原有 JSON 响应一致。
- 验证未监听实际端口、未连接数据库;保留工作区既有配置与其他用户改动。
@@ -0,0 +1,71 @@
# Goose 初始迁移
日期:2026-10-17
## 目标与范围
用户已同意创建首个 Goose 迁移并提供后续自行管理的说明。以当前工作区的
`docs/demo.sql` 为来源,保留已有未提交改动,不修改其表结构设计。
本次不连接或修改用户数据库,不把迁移嵌入服务启动,也不修改 Go 应用依赖。
## 实施步骤
1. 核对 Goose 官方 SQL 注解、事务行为及 CLI 命令。
2. 新建 `migrations/00001_init.sql`,Up 保留示例中的函数、三张表、约束、
注释、索引与触发器;去掉外层事务,以 StatementBegin/StatementEnd
包裹 PL/pgSQL 函数。Down 按依赖逆序删除表,最后删除函数,不使用 CASCADE。
3. 更新 `migrations/README.md`,说明 CLI 使用、连接信息的安全提供、首次
初始化、状态检查、后续迁移及回滚风险。采用固定 CLI 版本 v3.29.0,命令在 Lyra
module 根执行;数据库必须已创建,首个迁移要求目标对象尚不存在。
4. 同步项目 README 与架构文档的迁移进度。`docs/demo.sql` 保留为参考,
正式迁移以 migrations 为准,避免双重执行。
## 工作分工与验证
- librarian:只读核对官方规则与 CLI 能力。
- fixer:仅负责首个 SQL 迁移及上述三个说明文档;不修改其他代码或配置,
不运行任何连接数据库的命令。
- 父会话:维护计划、检查最终 diff 与文案,确认无意外改动及敏感信息。
- fixer 验证:静态对比 Up 与当前 demo 的 SQL 内容,检查 Down 依赖顺序;
使用 v3.29.0 的 `goose -env=none -dir ./migrations validate` 检查文件解析,
此命令不连接数据库。校验用工具仅构建到 `/tmp/opencode`,不修改应用依赖。
- 不为仅 SQL/文档变更重复运行无关 Go HTTP 测试。不宣称数据库集成验证通过;
未在 PostgreSQL 实际执行的限制在交付时明确说明。
## 已核对的规则
- 官方 v3.29.0:Up/Down 默认使用事务;函数体需 StatementBegin/StatementEnd。
- 后续新建迁移使用 `goose -s create <名称> sql`,延续顺序编号。
- CLI 默认读取 `.env`;说明中的命令统一加 `-env=none`,显式使用环境变量,
避免误用本地配置。`-version` 为工具版本,`version` 为数据库版本。
- 官方来源:https://pressly.github.io/goose/documentation/annotations/
与 https://github.com/pressly/goose/releases/tag/v3.29.0。
## 实施与验收记录
- 已完成初始迁移与使用文档;父会话已检查 SQL 与相关文档差异。
- 实测修正:v3.29.0 的 validate 不使用位置参数,必须通过 `-dir` 或
`GOOSE_MIGRATION_DIR` 选择目录;已按实际行为编写说明。
- fixer 已构建 v3.29.0 PostgreSQL 专用 CLI 至 `/tmp/opencode/lyra-goose-bin`,
离线解析得到 Up=40、Down=4;与 demo 的 Up 内容逐行对比完全一致。
- 后续编号 00002 已在临时目录验证;没有修改应用依赖或连接数据库。
- 最终文案校正:目标库为空是执行前提,不当作已验证事实;普通 down 是否
丢数据取决于迁移内容,00001 的 Down 则确定会删除三张表及其数据。
- `git diff --check` 通过。PostgreSQL 上的实际执行尚未验证。
## 后续:整理日常操作手册
用户已要求将实际操作步骤写入 Lyra docs。以下为新增文档任务,不改已有迁移。
1. 新建 `docs/goose.md`,作为唯一完整操作手册:固定版本安装、项目根目录、
`.env.local` 配置、密码 URL 编码、validate/status/up/version、后续新增迁移、
回滚警告与常见故障。所有数据库操作命令使用 `-env=.env.local`。
2. 将 `migrations/README.md` 收敛为迁移目录说明和手册链接,移除旧的交互输入
密码流程,避免两份操作步骤不一致;项目 README 增加手册入口。
3. 仅使用演示占位值,不读取 .env.local、真实连接配置或既有错误中的疑似凭据。
不运行服务、不连接数据库、不更改 SQL、依赖和配置,也不暂存用户改动。
4. fixer 负责上述三个文档和静态链接/命令一致性检查;父会话负责最终差异与
文案审查。已有 CLI 验证证据复用,不为文档改动重复构建或执行数据库命令。
说明:用户已确认 status 成功并显示 00001 Pending;这是连接及迁移发现成功的
证据,不足以宣称 Up 或 Down 已完成数据库集成验证。
@@ -0,0 +1,32 @@
# PostgreSQL 启动连接检查
日期:2026-10-17
## 已批准目标
用户要求在 main.go 中通过 sqlx 查询数据库信息,检查尚无业务表的数据库连接。
查询只读取 current_database()、current_user、version(),不依赖业务表,不执行 DDL。
## 实施范围
1. 保留当前工作区所有用户改动,仅在 cmd/lyra/main.go 中注册项目已选定的
pgx stdlib 驱动,使用带超时的连接及 GetContext 查询,输出数据库信息。
2. 通过 go.mod/go.sum 加入 pgx/v5/stdlib 所需依赖;etc/lyra.toml 仅将
driver 的 postgres 改为 pgx,与驱动注册名保持一致,不读出或改动敏感配置。
3. 补充数据库连接关闭;不重构配置加载、不实现 dotenv、不涉及 Goose 或业务表。
## 分工与证据路径
- fixer:写入范围限定 main.go、go.mod、go.sum、TOML 的 driver 一行;
首次修改检查缩进和换行,保留已有修改。负责 gofmt、go test ./... 与构建验证。
- 父会话:检查新增查询、超时、驱动名与范围,检查无明文凭据及意外文件改动。
- 不打印配置文件完整内容或完整 DSN;不读取 .env.local 或 compose 中的敏感值。
- 不运行服务、不连接真实数据库;编译/测试只证明查询代码可编译和现有测试通过,
实际连接是否成功由用户本地启动后观察查询输出确认。
## 验收
- 已加入 pgx/v5 v5.11.0 驱动、五秒超时连接与系统信息查询,保留 HTTP 通路。
- fixer 执行 `go build ./...`、`go test ./...` 均通过,未连接真实数据库。
- 最终安全校正:连接与查询失败时只记录固定的阶段提示,不直接输出驱动原始
错误,避免连接串解析错误意外携带敏感配置。
@@ -0,0 +1,19 @@
# 重写当前路由回归测试
日期:2026-10-17
## 已批准范围
用户要求重写 router_test。当前路由为 GET /test,注册入口接收 router.Handlers,其中 TestHandler 为 gin.HandlerFunc。
- 仅新增或重写 internal/router/router_test.go,不修改生产代码、依赖或用户已调整的模型字段。
- 测试使用 package router_test、gin.New、httptest 和注入的替身 handler,不启动端口、不访问数据库。
- 覆盖 GET /test 调用注入处理器,并保留处理器自定义的状态码和响应体。
- 覆盖未知路径和不支持的方法均不调用处理器;使用 gin.New 默认设置,两者预期 404。
- 每个用例创建独立引擎;不使用 t.Parallel,避免 Gin 全局模式设置竞争。
## 验证所有者与证据
- fixer:只写测试文件,gofmt,执行 go test ./internal/router -v -count=1 与 go test ./...。
- 父会话:检查最终差异、替身调用次数断言、当前路由名以及无数据库依赖。
- 本轮证据仅覆盖路由注册与分发,不覆盖真实 TestHandler 或数据库查询。
@@ -0,0 +1,66 @@
# Squirrel + sqlx 最小查询练习
日期:2026-10-17
## 范围与状态
- 前期由用户自行实现;2026-10-17 用户明确批准代改依赖组装示例。本轮仍不启动服务、不连接数据库。
- 使用隔离的本地 PostgreSQL 测试库,不复用真实或共享环境。
- 当前范围调整:用户先在已有 /hello handler 中手动验证查询;下方 categories 集成测试步骤保留为后续练习。
- 临时在 handler 中只读查询 current_database(),不依赖业务表;正式业务查询再迁至 model。
## 当前 Hello 实验
### 本轮已批准实施:集中依赖组装
- 以用户现有代码为准:Hello 已通过 CategoryModel.FindAll 查询分类,保留该 SQL、过滤条件和结果映射,不改成系统信息查询。
- 新增 cmd/lyra/dependencies.go,buildHandlers(db) 返回 router.Handlers,当前只装配 Hello(db)。
- router.go 定义仅包含 Hello gin.HandlerFunc 的 Handlers,RegisterRoutes 接收它,不再导入 handler 或 sqlx。
- main.go 仅把路由注册改为先 buildHandlers(db),再 RegisterRoutes;保留配置、连接和其他用户改动。
- Hello 保持 Hello(db) 闭包接口,在返回闭包前创建 CategoryModel,避免每个请求重新构造;修复先返回问候再返回列表的重复响应,错误分支返回 HTTP 500 后立即 return。
- 路由测试注入固定响应的替身 handler,验证 /hello 确实调用传入的处理器并维持现有响应契约,不触碰数据库。
- 写入范围:cmd/lyra/main.go、cmd/lyra/dependencies.go、internal/router/router.go、internal/router/router_test.go、internal/handler/hello.go;不改 model、依赖文件或敏感配置。
- fixer 负责首次编辑前确认缩进与换行、gofmt、go test ./... 和 go build ./...;父会话负责审阅最终差异、依赖方向和用户改动保留情况。
- 验证边界:httptest 证明路由注入生效,构建证明组装链编译通过;不宣称真实数据库查询已验证。
实施结果:上述五个 Go 文件已完成调整。fixer 执行 gofmt、go test ./...、go build ./...、go vet ./... 均通过;父会话已审阅实际差异与新增 dependencies.go,git diff --check 通过。未启动服务或连接数据库,真实查询及 Hello 成功/失败分支尚无运行时验证。用户已有 model/category.go 和依赖文件改动保持不变。
### 历史练习建议(由上方现状覆盖)
1. 沿用闭包:Hello(db *sqlx.DB) gin.HandlerFunc,捕获 main 已创建的连接池。
2. router.RegisterRoutes 接收 gin.HandlerFunc,不依赖数据库;main 调用 handler.Hello(db) 完成组装。
3. 每个请求从 Request.Context 派生五秒超时,使用 Squirrel 构造 SELECT current_database(),sqlx.GetContext 执行并映射字符串。
4. 失败使用现有 httpx.Fail 与通用错误码,不返回数据库原始错误;成功使用 httpx.OK 返回当前数据库名。
5. 路由测试注入返回固定响应的替身 handler,不连接数据库;保留现有路径、状态码和响应体断言。该测试不覆盖真实 Hello 查询逻辑。
6. 用户在隔离本地环境运行服务并请求 /hello 验证真实查询;实验不要对外暴露数据库信息。
## 后续 categories 练习顺序
### 依赖组装组织建议(讨论记录,未代改代码)
- 继续使用显式构造函数注入,不引入 Wire、全局连接或通用依赖容器。
- 组装代码可以放在 cmd/lyra/dependencies.go,仍属于 package main;main 只调用 buildHandlers(db) 并注册路由。
- router 可定义只包含 gin.HandlerFunc 的 Handlers 结构,RegisterRoutes 接收该结构,避免路由增加导致参数列表不断增长。
- 当前只需要 Hello 字段,其他路由随实际实现添加,不提前创建 model/service 等占位代码。
- 后续组装顺序为 db → model → 按需 service → handler;每层只接收实际需要的依赖,不传递整个应用容器。
- 该方式减少 main 的杂乱,但不消除新增组件时必须更新组装关系的工作;对象在启动时构造,请求复用连接池。
- Dependencies 位置建议:当前若确需该结构,直接定义在 cmd/lyra/dependencies.go,不为单个类型新建包。以后多个入口确实需要复用组装逻辑时,再迁移到 internal/app;该包作为组装上层可引用 model/service/handler/router,业务包不得反向引用 app。当前不创建新目录或依赖容器。
1. 在 module 根目录引入 github.com/Masterminds/squirrel。
2. 准备已执行现有迁移的隔离测试库;如需数据,用户单独准备至少一个分类,测试本身不写数据。
3. 测试从 LYRA_TEST_DATABASE_URL 读取连接串;缺失时跳过,不回退到开发配置。
4. 注册 pgx stdlib 驱动,创建五秒超时 Context,以 sqlx.ConnectContext 连接并清理连接。
5. 定义带 db 标签的局部结果结构,使用 Squirrel 显式声明列、可选 sort 条件、sort/id 排序、有限条数和 Dollar 占位符。
6. 调用 ToSql 获取 SQL 和 args,通过 db.SelectContext 执行并映射切片。
7. 先验证 SQL/args,再验证真实查询;通过后再提取正式 model 方法。
## 验证与注意事项
- Go 命令在 go/projects/lyra 执行。
- 查询构造测试应验证使用 $1 而非 ?,过滤值位于 args 中而非拼入 SQL。
- 集成测试验证目标库身份、查询无错误、字段映射、过滤与排序。
- 空表返回空切片属于成功,但不能证明非空数据映射和排序正确;需要已知数据才能完成这些验收。
- 环境变量缺失导致 SKIP 不算数据库查询已通过。
- 不记录 DSN、密码或可能包含凭据的连接错误原文。
- 完成后执行 go test ./internal/model -run TestCategoryQuery -v -count=1。
- 正式复用时使用 sqlx.SelectContext(ctx, executor, ...) 支持 *sqlx.DB 和 *sqlx.Tx;sqlx.ExtContext 接口本身没有 SelectContext 方法。
-7
View File
@@ -1,7 +0,0 @@
[app]
name = "lyra"
env = "dev"
[http]
host = "0.0.0.0"
port = 7777
+23
View File
@@ -0,0 +1,23 @@
[app]
name = "lyra"
env = "debug" #gin支持 release | test | debug 3种mode,如果需要自定义,需要映射到这3种mode中去,gin的默认模式是debug模式
[log]
level = "info" #日志级别,支持 debug | info | warn | error |
[http]
host = "0.0.0.0"
port = 7777
[database]
driver = "pgx"
host = "localhost"
port = 5432
user = "admin"
name = "lab"
[cache]
host = "localhost"
port = 6379
db = 0
timeout = 200
+17 -3
View File
@@ -3,7 +3,13 @@ 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/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
) )
@@ -11,6 +17,7 @@ require (
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
@@ -21,8 +28,13 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // 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/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
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -39,11 +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/sys v0.41.0 // indirect golang.org/x/sync v0.19.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
) )
+52 -12
View File
@@ -1,9 +1,19 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
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/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=
@@ -27,6 +37,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -36,18 +48,40 @@ 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/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/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
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=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -55,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=
@@ -79,41 +114,46 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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/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=
@@ -0,0 +1,9 @@
package api
// CategoryResponse 分类列表响应 DTO,字段名显式约定小写 JSON 键。
type CategoryResponse struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Sort int `json:"sort"`
}
+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
}
+26 -1
View File
@@ -8,7 +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"`
Cache CacheConfig `mapstructure:"cache"`
} }
type AppConfig struct { type AppConfig struct {
@@ -16,11 +19,30 @@ 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"`
} }
type DatabaseConfig struct {
Driver string `mapstructure:"driver"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Name string `mapstructure:"name"`
Password string `mapstructure:"password"`
}
type CacheConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
DB int `mapstructure:"db"`
Timeout int `mapstructure:"timeout"`
}
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
v := viper.New() v := viper.New()
// 指定文件,按 .toml 扩展名识别格式 // 指定文件,按 .toml 扩展名识别格式
@@ -29,7 +51,10 @@ func Load(path string) (*Config, error) {
if err := v.ReadInConfig(); err != nil { if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("读取配置失败: %w", err) return nil, fmt.Errorf("读取配置失败: %w", err)
} }
v.AllowEmptyEnv(true)
if err := v.BindEnv("database.password", "PG_PASSWORD"); err != nil {
return nil, fmt.Errorf("绑定数据库密码环境变量失败: %w", err)
}
var cfg Config var cfg Config
if err := v.Unmarshal(&cfg); err != nil { if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("解析配置失败: %w", err) return nil, fmt.Errorf("解析配置失败: %w", err)
@@ -2,4 +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/`);服务级简单接口保留在根包平铺,不预建空业务包。
+69
View File
@@ -0,0 +1,69 @@
package handler
import (
"fmt"
"lyra/internal/api"
"lyra/internal/cache"
"lyra/internal/httpx"
"lyra/internal/model"
"lyra/internal/util"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
type TestHandler struct {
categoryModel model.CategoryModel
cache *cache.Cache
}
func NewTestHandler(categoryModel model.CategoryModel, cache *cache.Cache) *TestHandler {
return &TestHandler{categoryModel: categoryModel, cache: cache}
}
func (h *TestHandler) Hello() gin.HandlerFunc {
return func(ctx *gin.Context) {
categories, err := h.categoryModel.FindAll(ctx.Request.Context())
if err != nil {
httpx.Fail(ctx, http.StatusInternalServerError, api.CodeInternalError, "获取分类列表失败")
return
}
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))
}
}
// toCategoryResponses 显式把内部模型转换为响应 DTO;make 保证空结果输出 data:[] 而非 null。
func toCategoryResponses(categories []*model.Category) []api.CategoryResponse {
responses := make([]api.CategoryResponse, 0, len(categories))
for _, category := range categories {
responses = append(responses, api.CategoryResponse{
ID: category.ID,
Slug: category.Slug,
Title: category.Title,
Sort: category.Sort,
})
}
return responses
}
+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。
+132
View File
@@ -0,0 +1,132 @@
package model
import (
"context"
"fmt"
"time"
"lyra/internal/cache"
"lyra/internal/pkg/constant"
"lyra/internal/pkg/query"
"lyra/internal/util"
sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
)
type Category struct {
ID int64 `db:"id"`
Slug string `db:"slug"`
Title string `db:"title"`
Sort int `db:"sort"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type CategoryModel interface {
FindAll(ctx context.Context) ([]*Category, error)
ListWithOptions(ctx context.Context, queryOpt query.Option, modelOpts ...ModelOption) (resp []*Link, err error)
}
type defaultCategoryModel struct {
db *sqlx.DB
table string
cache *cache.Cache
}
func NewCategoryModel(db *sqlx.DB, cache *cache.Cache) CategoryModel {
return &defaultCategoryModel{db: db, table: "categories", cache: cache}
}
const categoryListCacheTTL = 5 * time.Minute
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.
Select("id", "slug", "title", "sort").
From("public.categories").
Where(sq.GtOrEq{"sort": 1}).
OrderBy("sort ASC", "id ASC").
Limit(10).
PlaceholderFormat(sq.Dollar).
ToSql()
if err != nil {
return nil, fmt.Errorf("squirrel 出错: %w", err)
}
rows := make([]*Category, 0)
err = m.db.SelectContext(ctx, &rows, query, args...)
if err != nil {
return nil, fmt.Errorf("数据库查询出错: %w", err)
}
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,5 +1,6 @@
# router 包 # router 包
- 负责路由注册与项目路径前缀,例如 `/api/polaris`。 - 负责路由注册与项目路径前缀,例如 `/api/polaris`;入口文件为 `router.go`,对外暴露 `RegisterRoutes`。
- Gin 的路由与请求处理逻辑留在 HTTP 层;service 和 model 不依赖 Gin。 - Gin 的路由与请求处理逻辑留在 HTTP 层;service 和 model 不依赖 Gin。
- 路由随资源实现逐步补充,不按业务项目建立代码子目录。 - 路由随资源实现逐步补充;包暂时平铺,不按业务项目建立代码子目录。
- 路由变更以 httptest 回归测试守护,不监听实际端口、不连接数据库。
@@ -0,0 +1,15 @@
package router
import (
"github.com/gin-gonic/gin"
)
// Handlers 汇总各路由所需的处理器,由入口在启动时组装注入。
type Handlers struct {
TestHandler gin.HandlerFunc
}
// RegisterRoutes 将处理器挂载到引擎的路由表。
func RegisterRoutes(engine *gin.Engine, handlers Handlers) {
engine.GET("/test", handlers.TestHandler)
}
@@ -0,0 +1,77 @@
package router_test
import (
"net/http"
"net/http/httptest"
"testing"
"lyra/internal/router"
"github.com/gin-gonic/gin"
)
const (
stubStatus = http.StatusAccepted
stubBody = "injected test handler"
)
// newStubHandlers 返回记录调用次数并输出固定状态码与响应体的替身处理器。
func newStubHandlers(calls *int) router.Handlers {
return router.Handlers{
TestHandler: func(c *gin.Context) {
*calls++
c.String(stubStatus, stubBody)
},
}
}
func TestRouteInvokesInjectedHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
var calls int
engine := gin.New()
router.RegisterRoutes(engine, newStubHandlers(&calls))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))
if calls != 1 {
t.Fatalf("GET /test 替身调用次数 = %d, want 1", calls)
}
if recorder.Code != stubStatus {
t.Fatalf("GET /test status = %d, want %d", recorder.Code, stubStatus)
}
if got := recorder.Body.String(); got != stubBody {
t.Fatalf("GET /test body = %q, want %q", got, stubBody)
}
}
func TestRouteSkipsHandlerForUnknownPathOrMethod(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
method string
path string
}{
{name: "未知路径", method: http.MethodGet, path: "/unknown"},
{name: "不支持的方法", method: http.MethodPost, path: "/test"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var calls int
engine := gin.New()
router.RegisterRoutes(engine, newStubHandlers(&calls))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(tt.method, tt.path, nil))
if calls != 0 {
t.Fatalf("%s %s 替身调用次数 = %d, want 0", tt.method, tt.path, calls)
}
if recorder.Code != http.StatusNotFound {
t.Fatalf("%s %s status = %d, want %d", tt.method, tt.path, recorder.Code, http.StatusNotFound)
}
})
}
}
+4 -2
View File
@@ -1,5 +1,7 @@
# service 包 # service 包
- 承载业务规则与事务协调,通过 model 完成数据访问。 - 当前目录为占位,尚无 Go 实现;新增代码前先确认职责边界。
- 需要原子性时,在同一事务内协调多次写入,不把各自独立提交的多次写入当成原子业务。 - 预期职责:业务规则与事务协调,通过 model 完成数据访问。
- 需要原子性时,在同一事务内协调多次写入;当前没有现成事务接口,需在实现时设计,不把各自独立提交的多次写入当成原子业务。
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。 - 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
- 可按明确业务分包(如 `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
}
+134
View File
@@ -0,0 +1,134 @@
-- Lyra 初始迁移:与 docs/demo.sql 的建库内容保持一致(函数、三张表、约束、注释、索引、触发器)。
-- +goose Up
-- updated_at 维护函数
-- +goose StatementBegin
CREATE FUNCTION public.set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
-- +goose StatementEnd
-- 用户表
CREATE TABLE public.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account text NOT NULL UNIQUE,
password_hash text NOT NULL,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_account_not_empty CHECK (btrim(account) <> ''),
CONSTRAINT users_display_name_not_empty CHECK (btrim(display_name) <> '')
);
COMMENT ON TABLE public.users IS '后台管理用户表';
COMMENT ON COLUMN public.users.id IS '用户 ID';
COMMENT ON COLUMN public.users.account IS '登录账号,当前大小写敏感';
COMMENT ON COLUMN public.users.password_hash IS '密码哈希';
COMMENT ON COLUMN public.users.display_name IS '展示昵称';
COMMENT ON COLUMN public.users.created_at IS '创建时间';
COMMENT ON COLUMN public.users.updated_at IS '更新时间,触发器自动刷新';
-- 分类表
CREATE TABLE public.categories (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text NOT NULL UNIQUE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
icon text NOT NULL,
sort integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT categories_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
CONSTRAINT categories_title_not_empty CHECK (btrim(title) <> '')
);
COMMENT ON TABLE public.categories IS '链接分类表';
COMMENT ON COLUMN public.categories.id IS '分类 ID,内部关联用';
COMMENT ON COLUMN public.categories.slug IS '页面地址(路由)标识';
COMMENT ON COLUMN public.categories.title IS '分类标题';
COMMENT ON COLUMN public.categories.description IS '分类描述';
COMMENT ON COLUMN public.categories.icon IS '分类图标标识';
COMMENT ON COLUMN public.categories.sort IS '分类排序,越小越靠前';
COMMENT ON COLUMN public.categories.created_at IS '创建时间';
COMMENT ON COLUMN public.categories.updated_at IS '更新时间,触发器自动刷新';
-- 链接表
CREATE TABLE public.links (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category_id bigint NOT NULL,
title text NOT NULL,
description text NOT NULL DEFAULT '',
url text NOT NULL,
icon text,
keywords text[] NOT NULL DEFAULT ARRAY[]::text[],
sort integer NOT NULL DEFAULT 0,
pinned boolean NOT NULL DEFAULT false,
pinned_sort integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT links_category_fk FOREIGN KEY (category_id) REFERENCES public.categories (id) ON DELETE RESTRICT,
CONSTRAINT links_title_not_empty CHECK (btrim(title) <> ''),
CONSTRAINT links_url_not_empty CHECK (btrim(url) <> ''),
CONSTRAINT links_pinned_sort_consistent CHECK (pinned = (pinned_sort IS NOT NULL)),
CONSTRAINT links_keywords_one_dimension CHECK (cardinality(keywords) = 0 OR array_ndims(keywords) = 1),
CONSTRAINT links_keywords_no_null_elements CHECK (array_position(keywords, NULL) IS NULL)
);
COMMENT ON TABLE public.links IS '链接表,归属分类';
COMMENT ON COLUMN public.links.id IS '链接 ID';
COMMENT ON COLUMN public.links.category_id IS '所属分类 ID,删分类前需先处理链接';
COMMENT ON COLUMN public.links.title IS '链接标题';
COMMENT ON COLUMN public.links.description IS '链接描述';
COMMENT ON COLUMN public.links.url IS '链接地址,应用层校验,去尾斜杠后全局唯一';
COMMENT ON COLUMN public.links.icon IS '链接图标,可空,前端回退';
COMMENT ON COLUMN public.links.keywords IS '搜索关键词数组,供本地搜索匹配';
COMMENT ON COLUMN public.links.sort IS '分类内排序,越小越靠前';
COMMENT ON COLUMN public.links.pinned IS '首页快捷入口(手动固定),非点击排行';
COMMENT ON COLUMN public.links.pinned_sort IS '首页快捷入口顺序,越小越靠前';
COMMENT ON COLUMN public.links.created_at IS '创建时间';
COMMENT ON COLUMN public.links.updated_at IS '更新时间,触发器自动刷新';
-- 索引
CREATE INDEX categories_order_idx
ON public.categories (sort, id);
CREATE INDEX links_category_order_idx
ON public.links (category_id, sort, id);
CREATE INDEX links_pinned_order_idx
ON public.links (pinned_sort, id)
WHERE pinned;
CREATE UNIQUE INDEX links_normalized_url_unique
ON public.links (rtrim(url, '/'));
-- 触发器
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON public.users
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER categories_set_updated_at
BEFORE UPDATE ON public.categories
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER links_set_updated_at
BEFORE UPDATE ON public.links
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- +goose Down
-- 按依赖逆序删除:链接表 → 分类表 → 用户表 → 函数。
-- 删表会一并移除表上的触发器与索引,故无需单独 DROP;
-- 不用 CASCADE / IF EXISTS,出现意外依赖时让删除直接报错。
DROP TABLE public.links;
DROP TABLE public.categories;
DROP TABLE public.users;
DROP FUNCTION public.set_updated_at();
+18 -4
View File
@@ -1,7 +1,21 @@
# migrations # migrations
未来存放 [Goose](https://pressly.github.io/goose/) SQL 迁移文件的目录。 Lyra 的 [Goose](https://pressly.github.io/goose/) SQL 迁移目录,是数据库结构的**唯一正式执行来源**。
[docs/demo.sql](../docs/demo.sql) 仅保留作参考,不要再手工执行它,避免与迁移重复建表。
当前没有任何迁移文件;首个迁移的文件名与写法随实现确定。部署执行入口、 ## 操作手册
并发控制(串行执行或数据库锁)与 schema 划分方案尚未确定,见
[../docs/architecture.md](../docs/architecture.md) 的"尚未确定"部分。 安装 goose CLI、配置 `.env.local` 连接、首次初始化、日常新增迁移、回滚警告与
故障排查等全部操作步骤,见唯一完整手册:[docs/goose.md](../docs/goose.md)。
## 现有迁移
| 文件 | 内容 |
|---|---|
| `00001_init.sql` | 初始结构:`set_updated_at()` 函数、`users`/`categories`/`links` 三张表、约束、注释、索引与触发器,建库内容与 `docs/demo.sql` 一致 |
## 执行模型约定
- 迁移只作用于 `public` schema,沿用现有设计,暂不做 schema 划分。
- 单个操作者串行执行,不引入数据库锁等并发控制。
- 部署执行入口是手工 CLI,服务启动时**不会**自动执行迁移。
+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"])
}) })