feat(lyra): 接入 PostgreSQL 与 Goose 迁移

- 使用 pgx/sqlx 建立连接,从环境变量读取数据库密码
- 添加本地 PostgreSQL Compose 配置与初始迁移
- 补充 Goose 操作手册和连接串排错说明
This commit is contained in:
2026-10-17 15:09:46 +08:00
parent 3f16384376
commit 8c0c9978e5
15 changed files with 580 additions and 33 deletions
+1
View File
@@ -46,3 +46,4 @@ Thumbs.db
# ---- Note 目录 ----
note/项目/
docs/plans
go/projects/lyra/data
+9 -4
View File
@@ -8,8 +8,12 @@
已搭建目录骨架、程序入口 `main.go` 与 HTTP 通路:启动时加载 `etc/lyra.toml`,
注册 `GET /hello`(经 `httpx.OK` 输出响应)并启动 HTTP 服务;路由注册配有
httptest 回归测试,不监听实际端口、不连接数据库。各内部目录的职责与依赖
约束记录在 `AGENTS.md` 中。数据库访问、迁移文件与真实业务逻辑均未实现;
架构文档中标注“待新增”的文件(links 等)尚未创建。
约束记录在 `AGENTS.md` 中。数据库迁移 SQL 已就位:`migrations/00001_init.sql`
(初始函数与三张表),通过手工 goose CLI 执行,操作手册见
[docs/goose.md](docs/goose.md),迁移目录说明见
[migrations/README.md](migrations/README.md);Go 应用侧的数据库连接与
Goose Go 依赖仍未实现,真实业务逻辑同样未实现;架构文档中标注“待新增”的
文件(links 等)尚未创建。
## 目录职责
@@ -24,7 +28,7 @@ httptest 回归测试,不监听实际端口、不连接数据库。各内部
| `internal/service/` | 业务规则、事务协调;可按业务分包,服务级简单接口平铺在根包 |
| `internal/model/` | 数据结构定义与数据访问(SQL,平铺) |
| `internal/httpx/` | 公共响应辅助 |
| `migrations/` | Goose SQL 迁移文件(暂无) |
| `migrations/` | Goose SQL 迁移文件(00001 初始迁移已就位) |
| `docs/` | 架构与计划文档 |
## 运行
@@ -42,4 +46,5 @@ go test ./... # 含路由回归测试
## 技术栈
已引入 Gin 与 Viper(读取 TOML 配置),go.sum 已生成;
PostgreSQL、Squirrel + sqlx(pgx stdlib 驱动)与 Goose 为目标选型,随后续功能引入。
PostgreSQL、Squirrel + sqlx(pgx stdlib 驱动)与 Goose 为目标选型,随后续功能引入;
数据库迁移已用独立 goose CLI(v3.29.0)管理,Goose Go 依赖尚未引入。
+27 -9
View File
@@ -2,30 +2,48 @@
package main
import (
"context"
"fmt"
"log"
"lyra/internal/config"
"lyra/internal/router"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"github.com/joho/godotenv"
// 注册 pgx 驱动,供 sqlx 以 "pgx" 驱动名使用。
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
// 加载 .env.local 文件,覆盖环境变量
if err := godotenv.Load(".env.local"); err != nil {
log.Fatal("加载环境变量失败,请检查文件是否存在")
}
// 加载配置(internal/config)
cfg, err := config.Load("etc/lyra.toml")
if err != nil {
panic(err)
log.Fatal("加载配置失败,请检查配置文件和环境变量")
}
// TODO: 组装依赖——建立 PostgreSQL 连接(internal/postgres)。
gin.SetMode(cfg.App.Env)
gEngine := gin.Default()
ginEngine := gin.Default()
//没有反向代理,在启动前关闭代理信任:
if err := gEngine.SetTrustedProxies(nil); err != nil {
panic(err)
if err := ginEngine.SetTrustedProxies(nil); err != nil {
log.Fatal("设置代理信任失败,请检查配置")
}
router.RegisterRoutes(gEngine)
// TODO: 退出时关闭连接、释放资源。
dataSourceName := fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable", cfg.Database.Host, cfg.Database.Port, cfg.Database.User, cfg.Database.Name, cfg.Database.Password)
ctx := context.Background()
db, err := sqlx.ConnectContext(ctx, cfg.Database.Driver, dataSourceName)
if err != nil {
log.Fatal("连接 PostgreSQL 失败,请检查连接配置、认证和网络")
}
defer db.Close()
router.RegisterRoutes(ginEngine)
addr := fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port)
if err := gEngine.Run(addr); err != nil {
panic(err)
if err := ginEngine.Run(addr); err != nil {
log.Fatal("启动 HTTP 服务失败,请检查端口是否被占用")
}
}
+36
View File
@@ -0,0 +1,36 @@
version: "3.8"
services:
postgres:
image: abcfy2/zhparser:16-alpine
container_name: lab-postgres
restart: unless-stopped
environment:
POSTGRES_DB: lab
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${PG_PASSWORD}
volumes:
- ./data/postgres:/var/lib/postgresql/data
# 测试环境不用刻意限制内存,让 Docker 自然调度即可
networks:
- net-db # 只加入数据库专用网络
# 如果外部需要直连调试,可临时 expose 端口
ports:
- "127.0.0.1:5432:5432"
redis:
image: redis:7-alpine
container_name: lab-redis
restart: unless-stopped
# 测试环境保留持久化更省心,不用关闭
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- ./data/redis:/data
networks:
- net-db
ports:
- "127.0.0.1:6379:6379"
networks:
net-db:
external: true # 使用预先创建的外部网络
+6 -5
View File
@@ -7,7 +7,7 @@
## 已确认技术栈
- 已引入:Gin v1.12.0(HTTP 路由)、Viper v1.21.0(配置读取,TOML)。`go.sum` 已由 Go 工具生成。
- 目标选型(选型已确认,尚未作为代码依赖引入):PostgreSQL、Squirrel + sqlx(pgx stdlib 驱动)、Goose(数据库迁移)。
- 目标选型(选型已确认,尚未作为代码依赖引入):PostgreSQL、Squirrel + sqlx(pgx stdlib 驱动)、Goose(数据库迁移;迁移 SQL 已就位,以独立 goose CLI v3.29.0 执行,Go 依赖未引入)。
## 当前进度
@@ -16,7 +16,8 @@
- 已注册 `GET /hello`:`internal/router/router.go` 的 `RegisterRoutes` 将其指向 `handler.Hello`,经 `httpx.OK` 输出 `api.Response`;路由注册配有 httptest 回归测试,不监听实际端口、不连接数据库。
- 已定义公共响应契约:`api.Response`(success/data/error)、`api.ErrorInfo`(code/message/debug)与错误码类型 `api.ErrorCode` 及其默认文案 `Message()`。
- 已实现响应输出辅助 `httpx.OK`、`httpx.Fail`。
- 尚未实现:真实业务逻辑(`/hello` 只是 HTTP 通路示例)、数据库访问与迁移 SQL;配置校验、环境变量覆盖与默认值;`ErrorInfo.Debug` 的填充与按环境开关。日志方案未定。
- 数据库迁移已就位:`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)。
- 尚未实现:真实业务逻辑(`/hello` 只是 HTTP 通路示例)、Go 应用侧数据库访问与 Goose Go 依赖;配置校验、环境变量覆盖与默认值;`ErrorInfo.Debug` 的填充与按环境开关。日志方案未定。
## 目录结构
@@ -52,7 +53,7 @@ lyra/
│ └── response.go # 公共响应输出辅助(OK/Fail)
├── etc/
│ └── lyra.toml # 运行配置(App/HTTP)
├── migrations/ # Goose SQL 迁移文件(暂无迁移,仅 README)
├── migrations/ # Goose SQL 迁移文件(00001_init.sql 初始迁移 + README)
├── docs/
│ ├── plans/
│ └── architecture.md # 本文
@@ -62,7 +63,7 @@ lyra/
```
- 标注“待新增”的文件只是职责分布示例,随实现按需新增,不预建空文件或空目录;`internal/` 各目录均以 `AGENTS.md` 记录职责与依赖约束。
- model 下的 `link*.go` 展示一个资源在各层的文件分布;分类、设置等资源按同样方式在 api/handler/service/model 新增对应文件。具体迁移文件名随首个迁移确定。
- model 下的 `link*.go` 展示一个资源在各层的文件分布;分类、设置等资源按同样方式在 api/handler/service/model 新增对应文件。首个迁移已落地为 `migrations/00001_init.sql`。
- `handler/polaris/`、`service/polaris/` 仅示意业务分包的形态:实际分包与否随业务边界明确时决定,路由、api、model 不照搬同样的分包。
## 组织原则
@@ -92,6 +93,6 @@ lyra/
- 配置校验、环境变量覆盖与默认值:尚未实现(当前无任何校验、覆盖或默认值)。
- `ErrorInfo.Debug` 的填充与按环境(dev)开关:尚未实现,目前仅有字段。
- 分页响应约定(如 data/meta 结构)与统一错误处理中间件:尚未引入,仅为历史讨论。
- Goose 的部署执行入口与并发控制(串行执行或数据库锁)方案。
- Goose 迁移的部署执行入口已确定为手工 CLI(见 [../migrations/README.md](../migrations/README.md)),并发控制以“单操作者串行执行”为约定,数据库锁未引入,服务启动不自动迁移。
- PostgreSQL 按项目划分 schema 的做法(组织边界,不等于权限隔离)。
- 日志方案(候选标准库 slog)与中间件集成方式。
+3 -7
View File
@@ -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 '链接表,归属分类';
+179
View File
@@ -0,0 +1,179 @@
# Goose 迁移操作手册
Lyra 数据库迁移的**唯一完整操作手册**。`migrations/` 目录是数据库结构的
唯一正式执行来源;[demo.sql](demo.sql) 仅保留作参考,不要再手工执行它。
迁移目录本身的说明见 [migrations/README.md](../migrations/README.md)。
## 目录与前提
- 所有 goose 命令都在 Lyra module 根目录(`migrations/` 的上一级)执行:
```bash
cd go/projects/lyra # 从仓库根出发
```
- 目标数据库**已创建**:迁移不负责建库,`lab` 库需先存在。
- 首次迁移要求目标对象**尚不存在**:如果之前手工执行过 demo.sql 建过
同名对象,`up` 会报 `already exists`,先按故障表评估处理。
## 安装 goose CLI(固定 v3.29.0)
goose 只作为独立命令行工具使用(Go 应用尚未引入 goose 依赖):
```bash
go install -tags='no_clickhouse no_libsql no_mssql no_mysql no_sqlite3 no_vertica no_ydb' github.com/pressly/goose/v3/cmd/goose@v3.29.0
goose -version # 应输出 v3.29.0
```
- 固定 `@v3.29.0`,保证各机器行为一致。
- `-tags=...` 是可选的 PostgreSQL 专用精简构建(`no_*` 标签已在 goose
v3.29.0 源码中核实);不追求精简可省略。
- 二进制装到 `$(go env GOPATH)/bin`(设了 `GOBIN` 则装到 `GOBIN`)。
`goose -version` 提示找不到命令时,把该目录加入 `PATH`:
```bash
export PATH="$PATH:$(go env GOPATH)/bin" # 可写入 ~/.bashrc
```
## 配置连接:.env.local
goose **不读取**应用的 `etc/lyra.toml`,也不会只凭 `PG_PASSWORD` 之类的
应用环境变量自动拼出连接串——它只认自己环境变量里的 `GOOSE_DBSTRING`。
在 module 根创建 `.env.local` 并追加以下内容(**全部是占位值**,换成实际
信息;`lab` 库需已创建):
```bash
GOOSE_DRIVER=postgres
GOOSE_MIGRATION_DIR=./migrations
GOOSE_DBSTRING="postgres://example_user:URL_ENCODED_PASSWORD@127.0.0.1:5432/lab?sslmode=disable"
```
要点:
- `GOOSE_DRIVER=postgres` 是 **goose 的驱动名**;Go 应用内用的是 pgx
(驱动名 `pgx`),两边名字不同但都连 PostgreSQL,不要混填。
- 本仓库 `.gitignore` 已忽略 `.env*`,`.env.local` 不会提交;建议再执行
`chmod 600 .env.local` 收紧文件权限。
- `127.0.0.1` 与端口必须换成**实际可达**的地址和端口(远程/容器按实际
映射填写)。
- `sslmode=disable` 仅适用于本地确认无 TLS 要求的场景;远程环境按部署的
TLS 要求调整该参数。
- 本手册所有 goose 命令统一用 `-env=.env.local` 显式加载该文件:goose
默认读的是 `.env`,`-env=.env.local` 既明确来源、又避免误用其他本地
配置。连接信息配置一次即可,无需每次交互输入密码。
- 系统环境里已有的同名变量(如曾 `export` 过 `GOOSE_DBSTRING`)可能优先
于 `.env.local` 生效:改了 `.env.local` 仍行为异常时,先查是否存在同名
环境变量——只列变量名、不打印值,避免泄露连接串:
```bash
env | cut -d= -f1 | grep -i '^GOOSE'
```
## 连接串特殊字符:URL 编码
`GOOSE_DBSTRING` 是 URL 格式:**用户名、密码**中的特殊字符必须逐个做
百分号编码,否则主机名/路径解析会错位。
- 对用户名和密码分别编码,**不要把整串 URL 一起编码**,也不要为迁就 DSN
去修改数据库里的实际密码。避免对已经编码的内容重复编码。
- 不要把真实密码交给在线网站转换;按下表手工替换即可。
| 字符 | 编码 | 字符 | 编码 |
|---|---|---|---|
| `@` | `%40` | `:` | `%3A` |
| `#` | `%23` | `/` | `%2F` |
| `%` | `%25` | `?` | `%3F` |
示例:密码 `demo#pass`(演示值)在 DSN 中写成 `demo%23pass`。
## 常用命令
```bash
# 离线检查迁移文件能否被 goose 解析(注解与语句拆分);
# 不连接数据库,也不验证 SQL 在 PostgreSQL 上的语义
goose -env=.env.local validate
# goose 工具自身版本(与数据库里的迁移版本是两回事)
goose -version
# 连接数据库:查看迁移状态 / 当前版本
goose -env=.env.local status
goose -env=.env.local version
# 执行迁移(连接数据库)
goose -env=.env.local up
```
注意:v3.29.0 的 `validate` **不接受位置路径参数**,迁移目录只由
`GOOSE_MIGRATION_DIR`(`.env.local` 已设 `./migrations`)或 `-dir` 标志
决定。`validate` 通过只证明文件能解析,不证明连接正常或 SQL 语义正确。
## 首次初始化
前提:`lab` 库已创建,且 00001 中的对象(函数、三张表、索引、触发器)
都不存在——**不要**先手工执行 demo.sql 再 `up`。
```bash
goose -env=.env.local validate # 离线确认文件可解析
goose -env=.env.local status # 显示 00001 Pending:连接成功,该迁移尚未应用
goose -env=.env.local up # 执行 00001,并在 goose_db_version 中记录版本:
# set_updated_at() 函数、users/categories/links 三张表、
# 约束、注释、索引与触发器
goose -env=.env.local status # 00001 显示应用时间
goose -env=.env.local version # 显示 version 1
```
- `up` 可重复执行:已应用版本按 `goose_db_version` 自动跳过。
- **已执行过的迁移文件不再修改**;要变更结构就新增迁移文件(见下节)。
## 日常新增迁移
```bash
goose -env=.env.local -s create add_example_column sql
```
`-s` 表示顺序编号:在现有 00001 基础上生成
`migrations/00002_add_example_column.sql`(实际编号按目录中现有最大版本
延续)。例如给 users 表加一列,编辑生成的文件:
```sql
-- +goose Up
ALTER TABLE public.users
ADD COLUMN avatar_url text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE public.users
DROP COLUMN avatar_url;
```
然后按顺序执行:
```bash
goose -env=.env.local validate
goose -env=.env.local status
goose -env=.env.local up
goose -env=.env.local status
```
Up/Down 成对编写,保证迁移可回滚。
## 回滚警告
```bash
goose -env=.env.local down # ⚠ 危险操作,见下
```
- `down` 只回滚**最近一个**已应用迁移的 Down 部分,**不是**清库或重建的
通用命令。
- **回滚 00001 会删除 `links`/`categories`/`users` 三张表及表中全部数据。**
- 执行任何 `down` 之前:阅读对应迁移 SQL、评估数据损失、备份数据库。
不建议随手“试一下” `down`,也不要把它列入日常维护流程。
## 故障速查
| 报错特征 | 常见原因与处理 |
|---|---|
| `hostname resolving error`(主机名里混入非法字符) | DBSTRING 格式或 URL 编码不对:检查用户名/密码特殊字符是否已按上表编码、`://` 与 `@` `:` 分隔符是否完整 |
| `password authentication failed` | 用户名或密码不对:核对 `.env.local`(编码后)的值;再排查系统环境同名变量是否优先生效(只查变量名,不打印值) |
| `connection refused` / `i/o timeout` | 主机端口不可达:确认 PostgreSQL 实际监听地址、端口、容器端口映射与防火墙 |
| `already exists`(对象已存在) | 目标对象曾被手工创建(如执行过 demo.sql):先评估现有对象与迁移的差异再决定处理方式;**不要直接删数据**,也不要手工往 `goose_db_version` 插版本记录伪造状态 |
@@ -0,0 +1,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 ./...` 均通过,未连接真实数据库。
- 最终安全校正:连接与查询失败时只记录固定的阶段提示,不直接输出驱动原始
错误,避免连接串解析错误意外携带敏感配置。
+11
View File
@@ -5,3 +5,14 @@ env = "debug" #gin支持 release | test | debug 3种mode,如果需要自定义
[http]
host = "0.0.0.0"
port = 7777
[database]
driver = "pgx"
host = "localhost"
port = 5432
user = "admin"
name = "lab"
[redis]
host = "localhost"
port = 6379
+7
View File
@@ -4,6 +4,8 @@ go 1.27.1
require (
github.com/gin-gonic/gin v1.12.0
github.com/jackc/pgx/v5 v5.11.0
github.com/jmoiron/sqlx v1.4.0
github.com/spf13/viper v1.21.0
)
@@ -21,6 +23,10 @@ require (
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/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -43,6 +49,7 @@ require (
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.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
+23
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
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=
@@ -27,6 +29,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/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=
@@ -36,6 +40,18 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -46,8 +62,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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=
@@ -80,6 +100,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
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.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=
@@ -104,6 +125,8 @@ 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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+20 -1
View File
@@ -9,6 +9,8 @@ import (
type Config struct {
App AppConfig `mapstructure:"app"`
HTTP HTTPConfig `mapstructure:"http"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
}
type AppConfig struct {
@@ -21,6 +23,20 @@ type HTTPConfig struct {
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 RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
}
func Load(path string) (*Config, error) {
v := viper.New()
// 指定文件,按 .toml 扩展名识别格式
@@ -29,7 +45,10 @@ func Load(path string) (*Config, error) {
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)
+134
View File
@@ -0,0 +1,134 @@
-- Lyra 初始迁移:与 docs/demo.sql 的建库内容保持一致(函数、三张表、约束、注释、索引、触发器)。
-- +goose Up
-- updated_at 维护函数
-- +goose StatementBegin
CREATE FUNCTION public.set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
-- +goose StatementEnd
-- 用户表
CREATE TABLE public.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account text NOT NULL UNIQUE,
password_hash text NOT NULL,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_account_not_empty CHECK (btrim(account) <> ''),
CONSTRAINT users_display_name_not_empty CHECK (btrim(display_name) <> '')
);
COMMENT ON TABLE public.users IS '后台管理用户表';
COMMENT ON COLUMN public.users.id IS '用户 ID';
COMMENT ON COLUMN public.users.account IS '登录账号,当前大小写敏感';
COMMENT ON COLUMN public.users.password_hash IS '密码哈希';
COMMENT ON COLUMN public.users.display_name IS '展示昵称';
COMMENT ON COLUMN public.users.created_at IS '创建时间';
COMMENT ON COLUMN public.users.updated_at IS '更新时间,触发器自动刷新';
-- 分类表
CREATE TABLE public.categories (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text NOT NULL UNIQUE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
icon text NOT NULL,
sort integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT categories_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
CONSTRAINT categories_title_not_empty CHECK (btrim(title) <> '')
);
COMMENT ON TABLE public.categories IS '链接分类表';
COMMENT ON COLUMN public.categories.id IS '分类 ID,内部关联用';
COMMENT ON COLUMN public.categories.slug IS '页面地址(路由)标识';
COMMENT ON COLUMN public.categories.title IS '分类标题';
COMMENT ON COLUMN public.categories.description IS '分类描述';
COMMENT ON COLUMN public.categories.icon IS '分类图标标识';
COMMENT ON COLUMN public.categories.sort IS '分类排序,越小越靠前';
COMMENT ON COLUMN public.categories.created_at IS '创建时间';
COMMENT ON COLUMN public.categories.updated_at IS '更新时间,触发器自动刷新';
-- 链接表
CREATE TABLE public.links (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category_id bigint NOT NULL,
title text NOT NULL,
description text NOT NULL DEFAULT '',
url text NOT NULL,
icon text,
keywords text[] NOT NULL DEFAULT ARRAY[]::text[],
sort integer NOT NULL DEFAULT 0,
pinned boolean NOT NULL DEFAULT false,
pinned_sort integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT links_category_fk FOREIGN KEY (category_id) REFERENCES public.categories (id) ON DELETE RESTRICT,
CONSTRAINT links_title_not_empty CHECK (btrim(title) <> ''),
CONSTRAINT links_url_not_empty CHECK (btrim(url) <> ''),
CONSTRAINT links_pinned_sort_consistent CHECK (pinned = (pinned_sort IS NOT NULL)),
CONSTRAINT links_keywords_one_dimension CHECK (cardinality(keywords) = 0 OR array_ndims(keywords) = 1),
CONSTRAINT links_keywords_no_null_elements CHECK (array_position(keywords, NULL) IS NULL)
);
COMMENT ON TABLE public.links IS '链接表,归属分类';
COMMENT ON COLUMN public.links.id IS '链接 ID';
COMMENT ON COLUMN public.links.category_id IS '所属分类 ID,删分类前需先处理链接';
COMMENT ON COLUMN public.links.title IS '链接标题';
COMMENT ON COLUMN public.links.description IS '链接描述';
COMMENT ON COLUMN public.links.url IS '链接地址,应用层校验,去尾斜杠后全局唯一';
COMMENT ON COLUMN public.links.icon IS '链接图标,可空,前端回退';
COMMENT ON COLUMN public.links.keywords IS '搜索关键词数组,供本地搜索匹配';
COMMENT ON COLUMN public.links.sort IS '分类内排序,越小越靠前';
COMMENT ON COLUMN public.links.pinned IS '首页快捷入口(手动固定),非点击排行';
COMMENT ON COLUMN public.links.pinned_sort IS '首页快捷入口顺序,越小越靠前';
COMMENT ON COLUMN public.links.created_at IS '创建时间';
COMMENT ON COLUMN public.links.updated_at IS '更新时间,触发器自动刷新';
-- 索引
CREATE INDEX categories_order_idx
ON public.categories (sort, id);
CREATE INDEX links_category_order_idx
ON public.links (category_id, sort, id);
CREATE INDEX links_pinned_order_idx
ON public.links (pinned_sort, id)
WHERE pinned;
CREATE UNIQUE INDEX links_normalized_url_unique
ON public.links (rtrim(url, '/'));
-- 触发器
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON public.users
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER categories_set_updated_at
BEFORE UPDATE ON public.categories
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER links_set_updated_at
BEFORE UPDATE ON public.links
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- +goose Down
-- 按依赖逆序删除:链接表 → 分类表 → 用户表 → 函数。
-- 删表会一并移除表上的触发器与索引,故无需单独 DROP;
-- 不用 CASCADE / IF EXISTS,出现意外依赖时让删除直接报错。
DROP TABLE public.links;
DROP TABLE public.categories;
DROP TABLE public.users;
DROP FUNCTION public.set_updated_at();
+18 -4
View File
@@ -1,7 +1,21 @@
# migrations
未来存放 [Goose](https://pressly.github.io/goose/) SQL 迁移文件的目录。
Lyra 的 [Goose](https://pressly.github.io/goose/) SQL 迁移目录,是数据库结构的**唯一正式执行来源**。
[docs/demo.sql](../docs/demo.sql) 仅保留作参考,不要再手工执行它,避免与迁移重复建表。
当前没有任何迁移文件;首个迁移的文件名与写法随实现确定。部署执行入口、
并发控制(串行执行或数据库锁)与 schema 划分方案尚未确定,见
[../docs/architecture.md](../docs/architecture.md) 的"尚未确定"部分。
## 操作手册
安装 goose CLI、配置 `.env.local` 连接、首次初始化、日常新增迁移、回滚警告与
故障排查等全部操作步骤,见唯一完整手册:[docs/goose.md](../docs/goose.md)。
## 现有迁移
| 文件 | 内容 |
|---|---|
| `00001_init.sql` | 初始结构:`set_updated_at()` 函数、`users`/`categories`/`links` 三张表、约束、注释、索引与触发器,建库内容与 `docs/demo.sql` 一致 |
## 执行模型约定
- 迁移只作用于 `public` schema,沿用现有设计,暂不做 schema 划分。
- 单个操作者串行执行,不引入数据库锁等并发控制。
- 部署执行入口是手工 CLI,服务启动时**不会**自动执行迁移。