Compare commits
11
Commits
e4fc4af912
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c812cfb663 | ||
|
|
97b26635fa | ||
|
|
bef30be3fe | ||
|
|
cd0e13325f | ||
|
|
759bc3fc35 | ||
|
|
b72b14a818 | ||
|
|
306107af83 | ||
|
|
ab83ad96b4 | ||
|
|
8c0c9978e5 | ||
|
|
3f16384376 | ||
|
|
8b320817ed |
+2
-1
@@ -45,4 +45,5 @@ Thumbs.db
|
||||
|
||||
# ---- Note 目录 ----
|
||||
note/项目/
|
||||
docs/plans
|
||||
docs/plans
|
||||
go/projects/lyra/data
|
||||
@@ -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,66 @@
|
||||
# Lyra
|
||||
|
||||
链接、分类、设置等资源的后端服务(实现中)。目标结构与技术选型见
|
||||
[docs/architecture.md](docs/architecture.md)。
|
||||
|
||||
## 当前状态
|
||||
|
||||
HTTP 通路已延伸到数据库:启动时加载本地 `.env.local` 与 `etc/lyra.toml`,经
|
||||
sqlx 连接 PostgreSQL(注册 pgx stdlib 驱动,驱动名取自配置),组装依赖后注册
|
||||
`GET /test`——由 `handler.TestHandler` 调用 `model.CategoryModel.FindAll` 查询
|
||||
`public.categories`(当前固定 `sort >= 1`、按 sort/id 排序、limit 10),并经
|
||||
显式转换为本轮新增的 `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/` | 程序入口:启动、依赖组装、退出清理(数据库连接当前建在此处) |
|
||||
| `internal/api/` | HTTP 请求/响应 DTO,按资源分文件(平铺) |
|
||||
| `internal/config/` | 配置定义与加载 |
|
||||
| `internal/postgres/` | PostgreSQL 连接的目标位置(占位:连接暂在入口 main) |
|
||||
| `internal/router/` | 路由注册、项目路径前缀(router.go,平铺) |
|
||||
| `internal/handler/` | 参数绑定、DTO 转换、响应输出;可按业务分包,服务级简单接口平铺在根包 |
|
||||
| `internal/service/` | 业务规则、事务协调;可按业务分包,服务级简单接口平铺在根包(占位) |
|
||||
| `internal/model/` | 数据结构定义与数据访问(SQL,平铺) |
|
||||
| `internal/httpx/` | 公共响应辅助 |
|
||||
| `migrations/` | Goose SQL 迁移文件(00001 初始迁移已就位) |
|
||||
| `docs/` | 架构与计划文档 |
|
||||
|
||||
## 运行
|
||||
|
||||
Go 命令在本目录(module 根)下执行;`.env.local` 与配置路径 `etc/lyra.toml`
|
||||
均按当前工作目录解析,换目录运行会找不到它们:
|
||||
|
||||
```bash
|
||||
go run ./cmd/lyra # 启动 HTTP 服务,监听地址与端口见 etc/lyra.toml
|
||||
go test ./... # 回归测试(不监听实际端口、不连接数据库)
|
||||
```
|
||||
|
||||
启动前提:本地存在 `.env.local`(提供数据库口令等环境变量,已被 gitignore、
|
||||
不入库,内容不在文档展开)、`etc/lyra.toml` 配置完整,且其指向的 PostgreSQL
|
||||
可达;连接失败时启动即退出。
|
||||
|
||||
配置中的 `app.env` 直接传给 `gin.SetMode`,仅支持 `debug`/`release`/`test`。
|
||||
环境变量方面当前仅 `database.password` 绑定了 `PG_PASSWORD`,其余配置项不
|
||||
支持环境变量覆盖,也没有校验与默认值。
|
||||
|
||||
## 技术栈
|
||||
|
||||
已引入并使用:Gin(HTTP 路由)、Viper(读取 TOML 配置)、godotenv(加载
|
||||
`.env.local`)、sqlx + Squirrel(查询构造与执行)、pgx stdlib(PostgreSQL
|
||||
驱动)。Goose 仅以独立 CLI(v3.29.0)手工执行迁移,Go 依赖尚未引入。
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Command lyra 是 Lyra 的程序入口,负责启动、依赖组装与退出清理。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"lyra/internal/config"
|
||||
"lyra/internal/router"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := godotenv.Load(".env.local"); err != nil {
|
||||
slog.Error("加载环境变量失败,请检查文件是否存在及格式是否正确")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg, err := config.Load("etc/lyra.toml")
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 # 使用预先创建的外部网络
|
||||
@@ -1,71 +1,105 @@
|
||||
# Lyra 目标结构
|
||||
|
||||
日期:2026-10-16
|
||||
日期:2026-10-17
|
||||
|
||||
本文记录已确认的目标目录结构与职责边界。Lyra 当前仅有 go.mod 和文档,代码尚未实施;以下文件为计划形态,不代表已存在。选型与讨论过程见 [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(HTTP 路由)、PostgreSQL、Squirrel + sqlx(数据访问,pgx stdlib 驱动)、Goose(数据库迁移)。
|
||||
- 已引入并使用: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 工具生成。
|
||||
- Goose:迁移 SQL 已就位,以独立 goose CLI(v3.29.0)执行,Go 依赖尚未引入。
|
||||
- PostgreSQL 已在启动时连接;连接代码当前在入口 `main`,收拢到 `internal/postgres` 是目标结构。
|
||||
|
||||
## 当前进度
|
||||
|
||||
- 程序入口在 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` 均按当前工作目录解析,换目录执行会找不到。连接与查询当前未设置超时。
|
||||
- 已注册 `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。
|
||||
- `internal/model` 已落地:`Category` 结构(id/slug/title/sort)、`CategoryModel` 接口与 `defaultCategoryModel` 实现——Squirrel 构造 SQL(Dollar 占位符),sqlx `SelectContext` 执行与映射。
|
||||
- 已定义公共响应契约:`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
|
||||
lyra/
|
||||
├── cmd/lyra/main.go # 启动、依赖组装、退出清理
|
||||
├── cmd/lyra/
|
||||
│ ├── main.go # 启动流程:加载 .env.local 与配置、连接数据库、组装依赖
|
||||
│ └── dependency.go # Dependencies 与 handler 组装
|
||||
├── internal/
|
||||
│ ├── api/ # HTTP 请求/响应 DTO,按资源分文件
|
||||
│ │ ├── links.go # links 资源示例(下同)
|
||||
│ │ ├── categories.go
|
||||
│ │ └── settings.go
|
||||
│ ├── api/ # HTTP 请求/响应 DTO、错误码与响应契约(平铺,按资源分文件)
|
||||
│ │ ├── response.go # Response/ErrorInfo 结构
|
||||
│ │ ├── code.go # ErrorCode 与错误码常量、默认文案
|
||||
│ │ ├── category.go # CategoryResponse 分类响应 DTO
|
||||
│ │ ├── links.go # links 资源 DTO 示例(待新增)
|
||||
│ │ └── settings.go # 待新增
|
||||
│ ├── config/
|
||||
│ │ └── config.go # 配置定义与加载
|
||||
│ ├── postgres/
|
||||
│ │ └── postgres.go # 数据库连接
|
||||
│ │ └── config.go # 配置定义与加载
|
||||
│ ├── postgres/ # 数据库连接的目标位置(占位:仅 AGENTS.md,连接暂在 cmd/lyra/main.go)
|
||||
│ ├── router/
|
||||
│ │ └── router.go # 路由注册、项目路径前缀
|
||||
│ │ ├── router.go # 路由注册、项目路径前缀
|
||||
│ │ └── router_test.go # 路由 httptest 回归测试
|
||||
│ ├── handler/
|
||||
│ │ └── links.go # 参数绑定、DTO 转换、响应输出
|
||||
│ ├── service/
|
||||
│ │ ├── links.go # 业务规则、事务协调
|
||||
│ │ └── links_test.go # 测试与源文件同目录
|
||||
│ │ ├── test.go # GET /test 处理:调用 model、显式 DTO 转换、响应输出
|
||||
│ │ ├── test_test.go # handler 替身测试
|
||||
│ │ ├── links.go # 参数绑定、DTO 转换、响应输出(待新增)
|
||||
│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包)
|
||||
│ ├── service/ # 业务规则、事务协调(占位:仅 AGENTS.md)
|
||||
│ │ ├── links.go # 业务规则、事务协调(待新增)
|
||||
│ │ ├── links_test.go # 测试与源文件同目录(待新增)
|
||||
│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包)
|
||||
│ ├── model/
|
||||
│ │ ├── link.go # Link 数据结构定义
|
||||
│ │ ├── link_model.go # 数据访问对象:CRUD 与 SQL(示例)
|
||||
│ │ └── link_model_test.go # 查询测试(示例)
|
||||
│ │ ├── category.go # Category 结构、CategoryModel 接口与 Squirrel+sqlx 实现
|
||||
│ │ ├── link.go # Link 数据结构定义(待新增)
|
||||
│ │ ├── link_model.go # 数据访问对象:CRUD 与 SQL(待新增)
|
||||
│ │ └── link_model_test.go # 查询测试(待新增)
|
||||
│ └── httpx/
|
||||
│ └── response.go # 公共响应辅助,按需实现
|
||||
├── migrations/ # Goose SQL 迁移文件
|
||||
│ └── response.go # 公共响应输出辅助(OK/Fail)
|
||||
├── etc/
|
||||
│ └── lyra.toml # 运行配置(App/HTTP/Database/Redis)
|
||||
├── migrations/ # Goose SQL 迁移文件(00001_init.sql 初始迁移 + README)
|
||||
├── docs/
|
||||
│ ├── plans/
|
||||
│ └── architecture.md # 本文
|
||||
│ └── architecture.md # 本文
|
||||
├── README.md
|
||||
├── go.mod
|
||||
└── go.sum # 引入依赖后由 Go 工具生成
|
||||
└── go.sum # 已生成
|
||||
```
|
||||
|
||||
- 标注示例的文件展示一个资源在各层的文件分布;分类、设置等资源按同样方式在 api/handler/service/model 新增对应文件。model/link.go 定义 Link 数据结构,model/link_model.go 定义数据访问与 SQL,model/link_model_test.go 测试查询,均为示例,实际文件随实现新增。
|
||||
- 只创建实际需要的文件;不预建空目录或空测试文件,具体迁移文件名随首个迁移确定。
|
||||
- 标注“待新增”的文件只是职责分布示例,随实现按需新增,不预建空文件或空目录;`internal/` 各目录均以 `AGENTS.md` 记录职责与依赖约束(树中未逐一画出)。`internal/postgres`、`internal/service` 目前仅有 `AGENTS.md`,为占位目录,不构成 Go 包。
|
||||
- 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` 等路由前缀区分,代码按技术职责分组,包内文件按资源划分。
|
||||
- 不保留 platform/modules 分类层;配置在 internal/config,连接在 internal/postgres,HTTP 辅助在 internal/httpx。
|
||||
- 简单接口不强制走完整三层,也不按每张表机械复制层级。
|
||||
- handler、service 可以按明确业务分包(如 `polaris/`),仅在业务边界清晰时新增;服务级简单接口(如 `hello`)保留在各自根包内平铺,不强制分包。
|
||||
- router、api、model 等其他层暂时平铺,不按业务项目建代码子目录;项目通过 `/api/polaris` 等路由前缀区分,包内文件按资源划分。
|
||||
- 公共基础能力全局唯一:配置在 internal/config,连接在 internal/postgres,HTTP 辅助在 internal/httpx,不随业务项目复制或按项目再拆。
|
||||
- 不保留 platform/modules 分类层;简单接口不强制走完整三层,也不按每张表机械复制层级。
|
||||
- 不预建空业务包,分包随实际业务代码一起落地。
|
||||
|
||||
## 层间边界
|
||||
|
||||
- 主调用链为 handler→service→model→PostgreSQL;service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。
|
||||
- 请求/响应 DTO 集中在 internal/api,与内部模型分开;即使初期字段相近,也不默认把数据库模型直接暴露为响应,接受必要的显式转换代码。
|
||||
- 真实业务的主调用链为 handler→service→model→PostgreSQL;简单接口可以不经过 service——当前 `GET /test` 即由 handler 直接调用 model(service 层尚未实现)。service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。
|
||||
- 请求/响应 DTO 集中在 internal/api,与内部模型分开;即使初期字段相近,也不默认把数据库模型直接暴露为响应,接受必要的显式转换代码(分类列表已经 handler 的 `toCategoryResponses` 转为 `api.CategoryResponse`)。
|
||||
- handler 引用 API DTO 完成绑定与转换;service/model 不依赖 Gin,也不依赖 internal/api DTO;api 包不反向引用 handler/service/model,避免传输契约与实现耦合或循环引用。
|
||||
- model 承载数据结构定义与数据访问:Squirrel/SQL 构造、sqlx 执行与结果映射,驱动为 pgx stdlib;不是 ORM 自动持久化,也不要求每种类型机械建立一套结构。
|
||||
- internal/api 拥有响应契约与业务错误码(`Response`/`ErrorInfo`/`ErrorCode`);不依赖 Gin。
|
||||
- internal/httpx 负责响应输出:`OK` 固定 HTTP 200;`Fail` 接收 HTTP 状态码与业务错误码,`message` 为空时回退错误码默认文案。HTTP 状态码表示传输/结果类别,`ErrorCode` 表示自定义业务错误,两者分开。
|
||||
- internal/router 负责把 handler 挂到路由上:当前注册 `GET /test`,指向入口组装注入的 `Handlers.TestHandler`,后续按业务项目路径前缀(如 `/api/polaris`)扩展。
|
||||
- 配置由入口 `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。
|
||||
|
||||
## 尚未确定
|
||||
## 尚未确定 / 待实现
|
||||
|
||||
以下仍为计划中的推荐或待确认项,未定为已决:
|
||||
以下均为计划中的推荐或待确认项,尚未实现,也未定为已决:
|
||||
|
||||
- 代码尚未实施,也未进行数据库连接或结构修改。
|
||||
- Goose 的部署执行入口与并发控制(串行执行或数据库锁)方案。
|
||||
- 配置校验与默认值:尚未实现。环境变量覆盖当前仅 `database.password` 绑定 `PG_PASSWORD`,其余配置项不支持。
|
||||
- 连接与查询超时:尚未设置,也未确定超时策略。
|
||||
- `ErrorInfo.Debug` 的填充与按环境(dev)开关:尚未实现,目前仅有字段。
|
||||
- 分页响应约定(如 data/meta 结构)与统一错误处理中间件:尚未引入,仅为历史讨论。
|
||||
- Goose 迁移的部署执行入口已确定为手工 CLI(见 [../migrations/README.md](../migrations/README.md)),并发控制以“单操作者串行执行”为约定,数据库锁未引入,服务启动不自动迁移。
|
||||
- PostgreSQL 按项目划分 schema 的做法(组织边界,不等于权限隔离)。
|
||||
- 日志方案(候选标准库 slog)与中间件集成方式。
|
||||
|
||||
@@ -71,16 +71,12 @@ CREATE TABLE public.links (
|
||||
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_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)
|
||||
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 '链接表,归属分类';
|
||||
|
||||
@@ -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 方法。
|
||||
@@ -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
|
||||
@@ -1,3 +1,63 @@
|
||||
module lyra
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.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/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // 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/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/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // 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/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.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
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
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/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
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/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/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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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/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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
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/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/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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
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/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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/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/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/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/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
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.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.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.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.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
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/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
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/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
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/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/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
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/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
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/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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
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/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,5 @@
|
||||
# api 包
|
||||
|
||||
- 定义 HTTP 请求/响应 DTO,按资源分文件。
|
||||
- DTO 与内部模型分开:即使初期字段相近,也不把数据库模型直接暴露为响应,由 handler 层完成显式转换。
|
||||
- 不引用 handler、service、model,也不依赖 Gin,避免传输契约与实现耦合或循环引用。
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
// ErrCode 错误码,前3位数表示错误类型,后3位数表示具体错误
|
||||
type ErrorCode int
|
||||
|
||||
const (
|
||||
CodeInvalidArgument ErrorCode = 10001
|
||||
CodeInternalError ErrorCode = 10002
|
||||
CodeLinkNotFound ErrorCode = 10003
|
||||
)
|
||||
|
||||
func (e ErrorCode) Message() string {
|
||||
switch e {
|
||||
case CodeInvalidArgument:
|
||||
return "请求参数不正确"
|
||||
case CodeLinkNotFound:
|
||||
return "链接不存在"
|
||||
default:
|
||||
return "服务暂时不可用"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package api
|
||||
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error *ErrorInfo `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorInfo struct {
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Debug string `json:"debug,omitempty"`
|
||||
}
|
||||
+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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# config 包
|
||||
|
||||
- 负责配置定义与加载。
|
||||
- 具体配置项、来源与加载方式随实际需求确定,由程序入口调用配置加载逻辑。
|
||||
@@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
HTTP HTTPConfig `mapstructure:"http"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Env string `mapstructure:"env"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
}
|
||||
type HTTPConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
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) {
|
||||
v := viper.New()
|
||||
// 指定文件,按 .toml 扩展名识别格式
|
||||
v.SetConfigFile(path)
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
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
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置失败: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# handler 包
|
||||
|
||||
- 负责参数绑定、API DTO 与内部模型的相互转换、响应输出。
|
||||
- 引用 internal/api 的 DTO,使用 Gin 完成绑定与响应。
|
||||
- 响应统一使用 `httpx.OK` / `httpx.Fail`,遵循 `internal/httpx/AGENTS.md` 的状态码、业务码与错误隔离规则。
|
||||
- 调用 service 时传递标准 context.Context,不下传 gin.Context。
|
||||
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# httpx 包
|
||||
|
||||
- 提供公共 HTTP 响应辅助,使用现有 `OK` / `Fail` 输出统一响应。
|
||||
- `Fail` 区分 HTTP 状态码与业务错误码;公开 message 为空时回退到业务码的默认文案。
|
||||
- 不向公开响应写入内部错误或调试信息,避免 debug 泄漏。
|
||||
- 响应结构来自 internal/api,不在这里重复定义 DTO。
|
||||
@@ -0,0 +1,27 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"lyra/internal/api"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OK 成功响应
|
||||
func OK(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, api.Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Fail 错误响应
|
||||
func Fail(c *gin.Context, status int, code api.ErrorCode, message string) {
|
||||
if message == "" {
|
||||
message = code.Message()
|
||||
}
|
||||
c.JSON(status, api.Response{
|
||||
Success: false,
|
||||
Error: &api.ErrorInfo{Code: code, Message: message},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# model 包
|
||||
|
||||
- 承载数据结构定义与数据访问:使用 Squirrel/SQL 构造查询、sqlx 执行与映射结果,驱动为 pgx stdlib。
|
||||
- SQL 与查询逻辑显式写在这一层,不使用 ORM 自动持久化。
|
||||
- 事务边界由 service 决定;需要同一事务时由 service 传入事务执行。这是设计目标,当前没有现成的事务接口封装。
|
||||
- 查询缓存采用 cache-aside:先读缓存,命中直接返回(空列表也可命中);未命中或缓存读取失败时回源数据库。
|
||||
- 仅数据库查询成功后回填缓存;数据库失败不回填。缓存读写故障只记录日志并降级,不改变数据库查询结果。
|
||||
- 缓存键使用 `internal/pkg/constant` 中的常量,遵循根 `AGENTS.md` 的 Redis 缓存键约定。
|
||||
- 未来写路径应在事务提交后使相关缓存失效;当前 `cache` 包没有删除接口,失效策略属于后续设计约束。
|
||||
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# router 包
|
||||
|
||||
- 负责路由注册与项目路径前缀,例如 `/api/polaris`;入口文件为 `router.go`,对外暴露 `RegisterRoutes`。
|
||||
- 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# service 包
|
||||
|
||||
- 当前目录为占位,尚无 Go 实现;新增代码前先确认职责边界。
|
||||
- 预期职责:业务规则与事务协调,通过 model 完成数据访问。
|
||||
- 需要原子性时,在同一事务内协调多次写入;当前没有现成事务接口,需在实现时设计,不把各自独立提交的多次写入当成原子业务。
|
||||
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
|
||||
- 可按明确业务分包(如 `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,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();
|
||||
@@ -0,0 +1,21 @@
|
||||
# migrations
|
||||
|
||||
Lyra 的 [Goose](https://pressly.github.io/goose/) SQL 迁移目录,是数据库结构的**唯一正式执行来源**。
|
||||
[docs/demo.sql](../docs/demo.sql) 仅保留作参考,不要再手工执行它,避免与迁移重复建表。
|
||||
|
||||
## 操作手册
|
||||
|
||||
安装 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,服务启动时**不会**自动执行迁移。
|
||||
@@ -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",
|
||||
"title": "游戏资料",
|
||||
"description": "游戏攻略维基、资料速查与实用规划工具。",
|
||||
"icon": "book-open",
|
||||
"icon": "book-search",
|
||||
"sort": 65
|
||||
},
|
||||
{
|
||||
|
||||
@@ -30,9 +30,9 @@ describe("Polaris 静态数据", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("包含 51 条合法链接且分类引用均存在", () => {
|
||||
it("包含 54 条合法链接且分类引用均存在", () => {
|
||||
const categoryIds = new Set(categories.map(({ id }) => id))
|
||||
expect(links).toHaveLength(51)
|
||||
expect(links).toHaveLength(54)
|
||||
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
|
||||
|
||||
for (const link of links) {
|
||||
@@ -225,8 +225,8 @@ describe("Polaris 静态数据", () => {
|
||||
},
|
||||
] as const
|
||||
|
||||
it("九条新链接全部存在且总数为 51", () => {
|
||||
expect(links).toHaveLength(51)
|
||||
it("九条新链接全部存在且总数为 54", () => {
|
||||
expect(links).toHaveLength(54)
|
||||
expect(
|
||||
expectedNewLinks.every(({ 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,
|
||||
"keywords": ["阿里云", "控制台", "云服务", "管理"],
|
||||
"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 {
|
||||
BookOpen,
|
||||
BookSearch,
|
||||
CirclePlay,
|
||||
Code2,
|
||||
Coffee,
|
||||
@@ -14,6 +15,7 @@ const CATEGORY_ICONS: Readonly<Record<string, LucideIcon>> = {
|
||||
code: Code2,
|
||||
palette: Palette,
|
||||
"book-open": BookOpen,
|
||||
"book-search": BookSearch,
|
||||
zap: Zap,
|
||||
"circle-play": CirclePlay,
|
||||
coffee: Coffee,
|
||||
|
||||
@@ -57,6 +57,7 @@ describe("静态数据 service", () => {
|
||||
"haowallpaper",
|
||||
"iodraw",
|
||||
"pixiv",
|
||||
"lucide",
|
||||
])
|
||||
expect(getLinksByCategory("life").map(({ id }) => id)).toEqual(["amap"])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user