Compare commits

..
Author SHA1 Message Date
labmem b72b14a818 feat(polaris): 新增三个导航网站并更新游戏资料图标
Polaris CI / verify (push) Successful in 3m40s
Polaris CI / publish (push) Successful in 5m7s
2026-10-19 11:35:53 +08:00
labmem 306107af83 docs(goose): 添加数据库迁移使用笔记 2026-10-17 17:47:40 +08:00
8 changed files with 316 additions and 5 deletions
+175
View File
@@ -0,0 +1,175 @@
# Goose 数据库迁移笔记
Goose 用迁移文件管理数据库结构变更,并在数据库中记录已执行的迁移版本。
本文以 **PostgreSQL、Goose v3.29.0** 为例,命令使用 Bash 语法。
## 1. 基本概念
- **迁移文件**:按版本组织的数据库变更脚本。
- **Up**:应用变更,例如建表、增加字段。
- **Down**:撤销对应变更;能否恢复数据取决于具体 SQL。
- **版本记录**:Goose 默认通过 `goose_db_version` 表跟踪迁移状态。
- **工具版本与迁移版本不同**:`goose -version` 查看工具版本,`goose version` 查看数据库迁移版本。
通常将迁移文件放在 `migrations/` 目录。下文命令在该目录的上一级执行,目标数据库需事先创建。
## 2. 安装
需要先安装 Go,再安装指定版本的 Goose CLI:
```bash
go install github.com/pressly/goose/v3/cmd/goose@v3.29.0
goose -version
```
固定工具版本有助于保持不同环境的行为一致。Goose 可以作为独立命令行工具使用。
默认安装位置是 `$(go env GOPATH)/bin`;若设置了 `GOBIN`,则安装到该目录。找不到命令时,将实际安装目录加入 `PATH`:
```bash
# 未设置 GOBIN 时
export PATH="$PATH:$(go env GOPATH)/bin"
```
## 3. 配置数据库连接
在工作目录创建 `.env.local`,填写连接配置:
```dotenv
GOOSE_DRIVER=postgres
GOOSE_MIGRATION_DIR=./migrations
GOOSE_DBSTRING="postgres://example_user:URL_ENCODED_PASSWORD@127.0.0.1:5432/example_db?sslmode=disable"
```
以上用户名、密码和数据库名都是占位值,地址与端口也需按实际环境填写。
- `GOOSE_DRIVER`:数据库驱动;连接 PostgreSQL 时使用 `postgres`。
- `GOOSE_MIGRATION_DIR`:迁移文件目录。
- `GOOSE_DBSTRING`:数据库连接串。Goose 不会自动从应用配置文件或零散的应用环境变量中拼装它。
- Goose 默认读取 `.env`;本文统一使用 `-env=.env.local` 显式指定配置文件。
- `sslmode=disable` 适用于本地无 TLS 要求的场景;其他环境按实际 TLS 要求配置。
将含凭据的配置文件加入版本控制忽略规则。类 Unix 系统还可限制文件权限:
```bash
chmod 600 .env.local
```
系统中已有的同名环境变量可能优先于配置文件生效。修改文件后连接行为未改变时,可只列出相关变量名排查,避免打印连接串:
```bash
env | cut -d= -f1 | grep -i '^GOOSE'
```
## 4. 连接串中的 URL 编码
使用 URL 格式的连接串时,用户名、密码中的特殊字符需要进行百分号编码。
| 字符 | 编码 | 字符 | 编码 |
|---|---|---|---|
| `@` | `%40` | `:` | `%3A` |
| `#` | `%23` | `/` | `%2F` |
| `%` | `%25` | `?` | `%3F` |
例如,演示密码 `demo#pass` 在连接串中写成 `demo%23pass`。
- 分别编码用户名和密码,不要编码整个 URL。
- 避免对已编码的内容重复编码。
- 编码只改变连接串的写法,不改变数据库中的实际密码。
- 不要将真实密码提交给在线编码网站。
## 5. 常用命令
```bash
# 查看 Goose 工具版本
goose -version
# 离线检查迁移文件是否能被解析
goose -env=.env.local validate
# 查看迁移状态与数据库当前迁移版本
goose -env=.env.local status
goose -env=.env.local version
# 应用尚未执行的迁移
goose -env=.env.local up
# 回滚最近一个已应用的迁移;执行前检查对应 Down SQL
goose -env=.env.local down
```
`validate` 检查注解与语句拆分,不连接数据库,也不验证 SQL 在 PostgreSQL 上的语义。校验通过不代表数据库连接正常或迁移一定执行成功。
在 v3.29.0 中,`validate` 不接受位置路径参数。迁移目录通过 `GOOSE_MIGRATION_DIR` 或 `-dir` 指定:
```bash
goose -env=.env.local -dir=./migrations validate
```
## 6. 创建与执行迁移
### 创建迁移文件
```bash
goose -env=.env.local -s create create_example_items sql
```
`-s` 表示使用顺序编号,版本号按目录中已有迁移延续。例如,目录为空时,首个文件可命名为:
```text
migrations/00001_create_example_items.sql
```
### 编写 Up 与 Down
下面用一张示例表演示迁移结构:
```sql
-- +goose Up
CREATE TABLE public.example_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
-- +goose Down
DROP TABLE public.example_items;
```
该示例的 Down 会删除表及其中的数据。
### 检查并执行
```bash
goose -env=.env.local validate
goose -env=.env.local status
goose -env=.env.local up
goose -env=.env.local status
goose -env=.env.local version
```
执行前确认目标数据库已创建,且待创建的对象不存在。`status` 中的 `Pending` 表示对应迁移尚未应用。
Goose 会记录成功应用的迁移;再次执行 `up` 时,已应用版本会被跳过。
## 7. 变更与回滚原则
- **已执行的迁移文件不再修改**;后续结构变更通过新增迁移实现。
- 为需要回滚的迁移编写对应 Down,并检查它能否正确撤销结构变更。
- `down` 只回滚最近一个已应用迁移,不是清库或重建数据库的通用命令。
- 删除表或字段可能丢失数据;反向 SQL 不等于数据恢复。
- 回滚前阅读对应 SQL、评估影响并备份数据库。
- 避免手工执行与迁移文件重复的建表脚本,以免对象和版本记录不一致。
## 8. 常见问题
| 报错或现象 | 排查方向 |
|---|---|
| 找不到 `goose` 命令 | 检查安装是否成功,以及实际安装目录是否已加入 `PATH` |
| `hostname resolving error`,主机名混入非法字符 | 检查连接串分隔符,以及用户名、密码的 URL 编码 |
| `password authentication failed` | 核对用户名、密码及编码;检查系统同名环境变量是否覆盖配置 |
| `connection refused` / `i/o timeout` | 检查监听地址、端口、容器端口映射和防火墙 |
| `already exists` | 检查对象是否已被手工创建;先比较现有结构与迁移内容,再确定处理方式 |
| 修改 `.env.local` 后配置似乎未生效 | 确认使用了 `-env=.env.local`,并排查系统中已有的同名环境变量 |
遇到对象已存在的问题,不要直接删除现有数据,也不要手工插入版本记录来伪造迁移状态。
@@ -0,0 +1,30 @@
# 新增导航与游戏资料分类图标
## 范围与决定
- 用户已明确要求执行;从干净的 `main` 新建 `feat/polaris-navigation-additions` 分支,不提交代码。
- 新增 OpenRouter(AI 工具)、菜鸟教程(学习资料)、Lucide(设计资源),保持原有条目不变。
- 新条目沿用现有数据结构,填写简短描述、关键词和分类内排序,不置顶;图标沿用既有 favicon 与首字母兜底,不新增依赖或资源。
- 仅将游戏资料分类的 `icon` 改为 `book-search`,前端映射增加 Lucide 的 `BookSearch`,保留学习资料的 `BookOpen`。
- 不调整布局、样式或其他分类图标。
## 执行顺序与责任
1. 协调者创建分支并记录计划(已完成)。
2. fixer 负责 `src/data/links.json`、`src/data/categories.json`、`src/features/navigation/components/category-icon.tsx` 及必要的局部回归测试;编辑前确认编码、缩进与换行,阅读适用的 Next.js 本地文档。
3. fixer 运行数据与图标相关测试、类型检查、修改文件的 lint 和格式检查,报告证据;协调者复核最终差异和关键行为。
## 验证路径与预算
- 新增导航:断言三个 ID/URL 唯一、分类正确、字段满足现有契约,现有条目不变。
- 分类图标:断言游戏资料为 `book-search` 且渲染实际 `BookSearch`,学习资料仍为 `book-open`;类型检查确认当前 lucide-react 导出可用。
- 工程检查:优先运行现有测试套件(若成本较低则全部)、`pnpm typecheck`、修改文件的 ESLint 与 Prettier 检查、`git diff --check`。
- 不以外部站点的 favicon 网络可达性作为本次完成门槛;其失败沿用已有兜底机制。
- 无框架/API/布局变更,不重复执行完整生产构建或截图验证,除非检查暴露相关风险。
## 状态
- 实施完成,未提交代码。
- fixer 验证:全量测试 17 个文件、117 个用例通过;类型检查、修改文件 ESLint 与 Prettier 检查通过。环境使用 `corepack pnpm` 执行项目命令。
- 协调者复核:原有 51 条链接与 HEAD 完全一致,恰好新增 3 条;分类数据除游戏资料的 `icon` 外与 HEAD 完全一致;图标组件测试覆盖 `BookSearch`、`BookOpen` 与默认兜底;`git diff --check` 通过。
- 外部 favicon 可达性未测试,不影响本次数据及图标映射变更结论。
@@ -45,7 +45,7 @@
"id": "game-resources", "id": "game-resources",
"title": "游戏资料", "title": "游戏资料",
"description": "游戏攻略维基、资料速查与实用规划工具。", "description": "游戏攻略维基、资料速查与实用规划工具。",
"icon": "book-open", "icon": "book-search",
"sort": 65 "sort": 65
}, },
{ {
+48 -4
View File
@@ -30,9 +30,9 @@ describe("Polaris 静态数据", () => {
} }
}) })
it("包含 51 条合法链接且分类引用均存在", () => { it("包含 54 条合法链接且分类引用均存在", () => {
const categoryIds = new Set(categories.map(({ id }) => id)) const categoryIds = new Set(categories.map(({ id }) => id))
expect(links).toHaveLength(51) expect(links).toHaveLength(54)
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true) expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
for (const link of links) { for (const link of links) {
@@ -225,8 +225,8 @@ describe("Polaris 静态数据", () => {
}, },
] as const ] as const
it("九条新链接全部存在且总数为 51", () => { it("九条新链接全部存在且总数为 54", () => {
expect(links).toHaveLength(51) expect(links).toHaveLength(54)
expect( expect(
expectedNewLinks.every(({ id }) => expectedNewLinks.every(({ id }) =>
links.some((link) => link.id === id), links.some((link) => link.id === id),
@@ -248,4 +248,48 @@ describe("Polaris 静态数据", () => {
}, },
) )
}) })
describe("新增三条导航链接回归", () => {
const expectedNewLinks = [
{
id: "openrouter",
title: "OpenRouter",
url: "https://openrouter.ai",
categoryId: "ai",
},
{
id: "runoob",
title: "菜鸟教程",
url: "https://www.runoob.com/",
categoryId: "learning",
},
{
id: "lucide",
title: "Lucide",
url: "https://lucide.dev/",
categoryId: "design",
},
] as const
it("三条新链接字段与分类精确匹配且沿用 favicon 兜底", () => {
for (const expected of expectedNewLinks) {
const link = links.find(({ id }) => id === expected.id)
expect(link).toBeDefined()
expect(link).toMatchObject({
id: expected.id,
title: expected.title,
url: expected.url,
categoryId: expected.categoryId,
pinned: false,
})
expect(link?.icon).toBeUndefined()
}
})
it("三条新链接 ID 与 URL 唯一", () => {
expect(hasUniqueValues(expectedNewLinks.map(({ id }) => id))).toBe(true)
expect(hasUniqueValues(expectedNewLinks.map(({ url }) => url))).toBe(true)
})
})
}) })
+30
View File
@@ -534,5 +534,35 @@
"pinned": false, "pinned": false,
"keywords": ["阿里云", "控制台", "云服务", "管理"], "keywords": ["阿里云", "控制台", "云服务", "管理"],
"sort": 100 "sort": 100
},
{
"id": "openrouter",
"title": "OpenRouter",
"description": "统一大模型 API 聚合平台",
"url": "https://openrouter.ai",
"categoryId": "ai",
"pinned": false,
"keywords": ["ai", "大模型", "api", "聚合"],
"sort": 30
},
{
"id": "runoob",
"title": "菜鸟教程",
"description": "编程入门教程与在线学习网站",
"url": "https://www.runoob.com/",
"categoryId": "learning",
"pinned": false,
"keywords": ["教程", "编程", "学习", "入门"],
"sort": 20
},
{
"id": "lucide",
"title": "Lucide",
"description": "开源 SVG 图标库",
"url": "https://lucide.dev/",
"categoryId": "design",
"pinned": false,
"keywords": ["图标", "svg", "图标库", "开源"],
"sort": 50
} }
] ]
@@ -0,0 +1,29 @@
import { render } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { CategoryIcon } from "./category-icon"
describe("CategoryIcon", () => {
it("为 book-search 渲染实际的 BookSearch 图标", () => {
const { container } = render(<CategoryIcon name="book-search" />)
const svg = container.querySelector("svg")
expect(svg).not.toBeNull()
expect(svg).toHaveClass("lucide-book-search")
})
it("book-open 仍渲染 BookOpen 而非 BookSearch", () => {
const { container } = render(<CategoryIcon name="book-open" />)
const svg = container.querySelector("svg")
expect(svg).not.toBeNull()
expect(svg).toHaveClass("lucide-book-open")
expect(svg).not.toHaveClass("lucide-book-search")
})
it("未知图标回退到 Sparkles", () => {
const { container } = render(<CategoryIcon name="not-exist" />)
expect(container.querySelector("svg")).toHaveClass("lucide-sparkles")
})
})
@@ -1,5 +1,6 @@
import { import {
BookOpen, BookOpen,
BookSearch,
CirclePlay, CirclePlay,
Code2, Code2,
Coffee, Coffee,
@@ -14,6 +15,7 @@ const CATEGORY_ICONS: Readonly<Record<string, LucideIcon>> = {
code: Code2, code: Code2,
palette: Palette, palette: Palette,
"book-open": BookOpen, "book-open": BookOpen,
"book-search": BookSearch,
zap: Zap, zap: Zap,
"circle-play": CirclePlay, "circle-play": CirclePlay,
coffee: Coffee, coffee: Coffee,
@@ -57,6 +57,7 @@ describe("静态数据 service", () => {
"haowallpaper", "haowallpaper",
"iodraw", "iodraw",
"pixiv", "pixiv",
"lucide",
]) ])
expect(getLinksByCategory("life").map(({ id }) => id)).toEqual(["amap"]) expect(getLinksByCategory("life").map(({ id }) => id)).toEqual(["amap"])
}) })