feat(lyra): 接入 PostgreSQL 与 Goose 迁移
- 使用 pgx/sqlx 建立连接,从环境变量读取数据库密码 - 添加本地 PostgreSQL Compose 配置与初始迁移 - 补充 Goose 操作手册和连接串排错说明
This commit is contained in:
@@ -46,3 +46,4 @@ Thumbs.db
|
|||||||
# ---- Note 目录 ----
|
# ---- Note 目录 ----
|
||||||
note/项目/
|
note/项目/
|
||||||
docs/plans
|
docs/plans
|
||||||
|
go/projects/lyra/data
|
||||||
@@ -8,8 +8,12 @@
|
|||||||
已搭建目录骨架、程序入口 `main.go` 与 HTTP 通路:启动时加载 `etc/lyra.toml`,
|
已搭建目录骨架、程序入口 `main.go` 与 HTTP 通路:启动时加载 `etc/lyra.toml`,
|
||||||
注册 `GET /hello`(经 `httpx.OK` 输出响应)并启动 HTTP 服务;路由注册配有
|
注册 `GET /hello`(经 `httpx.OK` 输出响应)并启动 HTTP 服务;路由注册配有
|
||||||
httptest 回归测试,不监听实际端口、不连接数据库。各内部目录的职责与依赖
|
httptest 回归测试,不监听实际端口、不连接数据库。各内部目录的职责与依赖
|
||||||
约束记录在 `AGENTS.md` 中。数据库访问、迁移文件与真实业务逻辑均未实现;
|
约束记录在 `AGENTS.md` 中。数据库迁移 SQL 已就位:`migrations/00001_init.sql`
|
||||||
架构文档中标注“待新增”的文件(links 等)尚未创建。
|
(初始函数与三张表),通过手工 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/service/` | 业务规则、事务协调;可按业务分包,服务级简单接口平铺在根包 |
|
||||||
| `internal/model/` | 数据结构定义与数据访问(SQL,平铺) |
|
| `internal/model/` | 数据结构定义与数据访问(SQL,平铺) |
|
||||||
| `internal/httpx/` | 公共响应辅助 |
|
| `internal/httpx/` | 公共响应辅助 |
|
||||||
| `migrations/` | Goose SQL 迁移文件(暂无) |
|
| `migrations/` | Goose SQL 迁移文件(00001 初始迁移已就位) |
|
||||||
| `docs/` | 架构与计划文档 |
|
| `docs/` | 架构与计划文档 |
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
@@ -42,4 +46,5 @@ go test ./... # 含路由回归测试
|
|||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
已引入 Gin 与 Viper(读取 TOML 配置),go.sum 已生成;
|
已引入 Gin 与 Viper(读取 TOML 配置),go.sum 已生成;
|
||||||
PostgreSQL、Squirrel + sqlx(pgx stdlib 驱动)与 Goose 为目标选型,随后续功能引入。
|
PostgreSQL、Squirrel + sqlx(pgx stdlib 驱动)与 Goose 为目标选型,随后续功能引入;
|
||||||
|
数据库迁移已用独立 goose CLI(v3.29.0)管理,Goose Go 依赖尚未引入。
|
||||||
|
|||||||
@@ -2,30 +2,48 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"lyra/internal/config"
|
"lyra/internal/config"
|
||||||
"lyra/internal/router"
|
"lyra/internal/router"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
|
// 注册 pgx 驱动,供 sqlx 以 "pgx" 驱动名使用。
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// 加载 .env.local 文件,覆盖环境变量
|
||||||
|
if err := godotenv.Load(".env.local"); err != nil {
|
||||||
|
log.Fatal("加载环境变量失败,请检查文件是否存在")
|
||||||
|
}
|
||||||
// 加载配置(internal/config)
|
// 加载配置(internal/config)
|
||||||
cfg, err := config.Load("etc/lyra.toml")
|
cfg, err := config.Load("etc/lyra.toml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Fatal("加载配置失败,请检查配置文件和环境变量")
|
||||||
}
|
}
|
||||||
// TODO: 组装依赖——建立 PostgreSQL 连接(internal/postgres)。
|
|
||||||
gin.SetMode(cfg.App.Env)
|
gin.SetMode(cfg.App.Env)
|
||||||
gEngine := gin.Default()
|
ginEngine := gin.Default()
|
||||||
//没有反向代理,在启动前关闭代理信任:
|
//没有反向代理,在启动前关闭代理信任:
|
||||||
if err := gEngine.SetTrustedProxies(nil); err != nil {
|
if err := ginEngine.SetTrustedProxies(nil); err != nil {
|
||||||
panic(err)
|
log.Fatal("设置代理信任失败,请检查配置")
|
||||||
}
|
}
|
||||||
router.RegisterRoutes(gEngine)
|
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)
|
||||||
// TODO: 退出时关闭连接、释放资源。
|
|
||||||
|
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)
|
addr := fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port)
|
||||||
if err := gEngine.Run(addr); err != nil {
|
if err := ginEngine.Run(addr); err != nil {
|
||||||
panic(err)
|
log.Fatal("启动 HTTP 服务失败,请检查端口是否被占用")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 # 使用预先创建的外部网络
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
## 已确认技术栈
|
## 已确认技术栈
|
||||||
|
|
||||||
- 已引入:Gin v1.12.0(HTTP 路由)、Viper v1.21.0(配置读取,TOML)。`go.sum` 已由 Go 工具生成。
|
- 已引入: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 回归测试,不监听实际端口、不连接数据库。
|
- 已注册 `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()`。
|
- 已定义公共响应契约:`api.Response`(success/data/error)、`api.ErrorInfo`(code/message/debug)与错误码类型 `api.ErrorCode` 及其默认文案 `Message()`。
|
||||||
- 已实现响应输出辅助 `httpx.OK`、`httpx.Fail`。
|
- 已实现响应输出辅助 `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)
|
│ └── response.go # 公共响应输出辅助(OK/Fail)
|
||||||
├── etc/
|
├── etc/
|
||||||
│ └── lyra.toml # 运行配置(App/HTTP)
|
│ └── lyra.toml # 运行配置(App/HTTP)
|
||||||
├── migrations/ # Goose SQL 迁移文件(暂无迁移,仅 README)
|
├── migrations/ # Goose SQL 迁移文件(00001_init.sql 初始迁移 + README)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── plans/
|
│ ├── plans/
|
||||||
│ └── architecture.md # 本文
|
│ └── architecture.md # 本文
|
||||||
@@ -62,7 +63,7 @@ lyra/
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 标注“待新增”的文件只是职责分布示例,随实现按需新增,不预建空文件或空目录;`internal/` 各目录均以 `AGENTS.md` 记录职责与依赖约束。
|
- 标注“待新增”的文件只是职责分布示例,随实现按需新增,不预建空文件或空目录;`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 不照搬同样的分包。
|
- `handler/polaris/`、`service/polaris/` 仅示意业务分包的形态:实际分包与否随业务边界明确时决定,路由、api、model 不照搬同样的分包。
|
||||||
|
|
||||||
## 组织原则
|
## 组织原则
|
||||||
@@ -92,6 +93,6 @@ lyra/
|
|||||||
- 配置校验、环境变量覆盖与默认值:尚未实现(当前无任何校验、覆盖或默认值)。
|
- 配置校验、环境变量覆盖与默认值:尚未实现(当前无任何校验、覆盖或默认值)。
|
||||||
- `ErrorInfo.Debug` 的填充与按环境(dev)开关:尚未实现,目前仅有字段。
|
- `ErrorInfo.Debug` 的填充与按环境(dev)开关:尚未实现,目前仅有字段。
|
||||||
- 分页响应约定(如 data/meta 结构)与统一错误处理中间件:尚未引入,仅为历史讨论。
|
- 分页响应约定(如 data/meta 结构)与统一错误处理中间件:尚未引入,仅为历史讨论。
|
||||||
- Goose 的部署执行入口与并发控制(串行执行或数据库锁)方案。
|
- Goose 迁移的部署执行入口已确定为手工 CLI(见 [../migrations/README.md](../migrations/README.md)),并发控制以“单操作者串行执行”为约定,数据库锁未引入,服务启动不自动迁移。
|
||||||
- PostgreSQL 按项目划分 schema 的做法(组织边界,不等于权限隔离)。
|
- PostgreSQL 按项目划分 schema 的做法(组织边界,不等于权限隔离)。
|
||||||
- 日志方案(候选标准库 slog)与中间件集成方式。
|
- 日志方案(候选标准库 slog)与中间件集成方式。
|
||||||
|
|||||||
@@ -71,16 +71,12 @@ CREATE TABLE public.links (
|
|||||||
pinned_sort integer,
|
pinned_sort integer,
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
CONSTRAINT links_category_fk
|
CONSTRAINT links_category_fk FOREIGN KEY (category_id) REFERENCES public.categories (id) ON DELETE RESTRICT,
|
||||||
FOREIGN KEY (category_id) REFERENCES public.categories (id)
|
|
||||||
ON DELETE RESTRICT,
|
|
||||||
CONSTRAINT links_title_not_empty CHECK (btrim(title) <> ''),
|
CONSTRAINT links_title_not_empty CHECK (btrim(title) <> ''),
|
||||||
CONSTRAINT links_url_not_empty CHECK (btrim(url) <> ''),
|
CONSTRAINT links_url_not_empty CHECK (btrim(url) <> ''),
|
||||||
CONSTRAINT links_pinned_sort_consistent CHECK (pinned = (pinned_sort IS NOT NULL)),
|
CONSTRAINT links_pinned_sort_consistent CHECK (pinned = (pinned_sort IS NOT NULL)),
|
||||||
CONSTRAINT links_keywords_one_dimension
|
CONSTRAINT links_keywords_one_dimension CHECK (cardinality(keywords) = 0 OR array_ndims(keywords) = 1),
|
||||||
CHECK (cardinality(keywords) = 0 OR array_ndims(keywords) = 1),
|
CONSTRAINT links_keywords_no_null_elements CHECK (array_position(keywords, NULL) IS NULL)
|
||||||
CONSTRAINT links_keywords_no_null_elements
|
|
||||||
CHECK (array_position(keywords, NULL) IS NULL)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE public.links IS '链接表,归属分类';
|
COMMENT ON TABLE public.links IS '链接表,归属分类';
|
||||||
|
|||||||
@@ -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 ./...` 均通过,未连接真实数据库。
|
||||||
|
- 最终安全校正:连接与查询失败时只记录固定的阶段提示,不直接输出驱动原始
|
||||||
|
错误,避免连接串解析错误意外携带敏感配置。
|
||||||
@@ -5,3 +5,14 @@ env = "debug" #gin支持 release | test | debug 3种mode,如果需要自定义
|
|||||||
[http]
|
[http]
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
port = 7777
|
port = 7777
|
||||||
|
|
||||||
|
[database]
|
||||||
|
driver = "pgx"
|
||||||
|
host = "localhost"
|
||||||
|
port = 5432
|
||||||
|
user = "admin"
|
||||||
|
name = "lab"
|
||||||
|
|
||||||
|
[redis]
|
||||||
|
host = "localhost"
|
||||||
|
port = 6379
|
||||||
@@ -4,6 +4,8 @@ go 1.27.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.12.0
|
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
|
github.com/spf13/viper v1.21.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +23,10 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.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/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
|||||||
@@ -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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
@@ -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/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
@@ -36,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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
@@ -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/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
App AppConfig `mapstructure:"app"`
|
App AppConfig `mapstructure:"app"`
|
||||||
HTTP HTTPConfig `mapstructure:"http"`
|
HTTP HTTPConfig `mapstructure:"http"`
|
||||||
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
@@ -21,6 +23,20 @@ type HTTPConfig struct {
|
|||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Driver string `mapstructure:"driver"`
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
User string `mapstructure:"user"`
|
||||||
|
Name string `mapstructure:"name"`
|
||||||
|
Password string `mapstructure:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedisConfig struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
// 指定文件,按 .toml 扩展名识别格式
|
// 指定文件,按 .toml 扩展名识别格式
|
||||||
@@ -29,7 +45,10 @@ func Load(path string) (*Config, error) {
|
|||||||
if err := v.ReadInConfig(); err != nil {
|
if err := v.ReadInConfig(); err != nil {
|
||||||
return nil, fmt.Errorf("读取配置失败: %w", err)
|
return nil, fmt.Errorf("读取配置失败: %w", err)
|
||||||
}
|
}
|
||||||
|
v.AllowEmptyEnv(true)
|
||||||
|
if err := v.BindEnv("database.password", "PG_PASSWORD"); err != nil {
|
||||||
|
return nil, fmt.Errorf("绑定数据库密码环境变量失败: %w", err)
|
||||||
|
}
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := v.Unmarshal(&cfg); err != nil {
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
return nil, fmt.Errorf("解析配置失败: %w", err)
|
return nil, fmt.Errorf("解析配置失败: %w", err)
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
# migrations
|
# migrations
|
||||||
|
|
||||||
未来存放 [Goose](https://pressly.github.io/goose/) SQL 迁移文件的目录。
|
Lyra 的 [Goose](https://pressly.github.io/goose/) SQL 迁移目录,是数据库结构的**唯一正式执行来源**。
|
||||||
|
[docs/demo.sql](../docs/demo.sql) 仅保留作参考,不要再手工执行它,避免与迁移重复建表。
|
||||||
|
|
||||||
当前没有任何迁移文件;首个迁移的文件名与写法随实现确定。部署执行入口、
|
## 操作手册
|
||||||
并发控制(串行执行或数据库锁)与 schema 划分方案尚未确定,见
|
|
||||||
[../docs/architecture.md](../docs/architecture.md) 的"尚未确定"部分。
|
安装 goose CLI、配置 `.env.local` 连接、首次初始化、日常新增迁移、回滚警告与
|
||||||
|
故障排查等全部操作步骤,见唯一完整手册:[docs/goose.md](../docs/goose.md)。
|
||||||
|
|
||||||
|
## 现有迁移
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `00001_init.sql` | 初始结构:`set_updated_at()` 函数、`users`/`categories`/`links` 三张表、约束、注释、索引与触发器,建库内容与 `docs/demo.sql` 一致 |
|
||||||
|
|
||||||
|
## 执行模型约定
|
||||||
|
|
||||||
|
- 迁移只作用于 `public` schema,沿用现有设计,暂不做 schema 划分。
|
||||||
|
- 单个操作者串行执行,不引入数据库锁等并发控制。
|
||||||
|
- 部署执行入口是手工 CLI,服务启动时**不会**自动执行迁移。
|
||||||
|
|||||||
Reference in New Issue
Block a user