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

BREAKING CHANGE: 将 GET /hello 替换为 GET /test,响应由问候字符串改为分类列表
2026-10-17 17:06:20 +08:00
labmem 8c0c9978e5 feat(lyra): 接入 PostgreSQL 与 Goose 迁移
- 使用 pgx/sqlx 建立连接,从环境变量读取数据库密码
- 添加本地 PostgreSQL Compose 配置与初始迁移
- 补充 Goose 操作手册和连接串排错说明
2026-10-17 15:09:46 +08:00
labmem 3f16384376 feat(lyra): 启动 HTTP 服务并添加示例接口
- 注册 GET /hello,使用统一响应结构并添加路由回归测试
- 使用 lyra.toml 配置 Gin 模式与监听地址,处理启动错误
- 明确 handler/service 业务分包约定并同步文档
2026-10-17 10:32:44 +08:00
labmem 8b320817ed feat(lyra): 搭建基础骨架与配置响应组件
- 使用 Viper 加载 TOML 配置
- 定义统一响应、业务错误码及响应辅助函数
- 添加分层职责约束并同步架构文档
2026-10-16 23:36:34 +08:00
labmem e4fc4af912 feat(lyra): 添加 PostgreSQL 初始化示例
- 定义用户、分类和链接表,包含密码哈希字段
- 添加数据约束、索引及更新时间触发器
2026-10-16 17:40:01 +08:00
labmem d63b006135 feat: 添加 chi 练习与 Gitea CI 实践
- 新增 chi 路由示例并更新依赖
- 用简单 CI 实践笔记替换 Polaris 部署笔记
- 添加本地 Gitea 与 runner 配置示例,注册令牌从环境变量读取
2026-10-16 15:02:25 +08:00
labmem 3280c8a7bc docs(lyra): 记录技术选型与分层架构
- 记录 Gin、PostgreSQL、Squirrel、sqlx 和 Goose 选型
- 明确 handler、service、model 调用链及 API 职责边界
- 整理目标目录结构、待定事项与后续验证要求
2026-10-16 14:52:32 +08:00
labmem d45d713a50 chore(lyra): 初始化模块
- 创建独立 Go module
2026-10-15 23:29:03 +08:00
labmem d375463de5 refactor(go)!: 分离练习区与独立项目区
- 将现有练习及 module 文件迁移至 go/practice,保持代码和依赖不变
- 新增 go/projects 项目区说明,更新目录约定与开发命令

BREAKING CHANGE: Go 练习命令需改在 go/practice 目录下执行
2026-10-15 22:26:57 +08:00
labmem 86f6eb39b2 docs(polaris): 同步设计与部署文档现状
对齐深色玻璃样式、响应式布局、数据规模与配置生效范围。
明确仅由 polaris-v* 标签触发发布,并保留历史规格说明。
2026-10-14 16:38:07 +08:00
labmem d53dbac88e ci(polaris)!: 改为仅在版本标签触发工作流
将运行提示改为中文,移除旧的 runner 冒烟测试工作流。

BREAKING CHANGE: main 分支提交不再触发 Polaris CI。
2026-10-13 14:18:42 +08:00
labmem fec2a97936 docs(polaris): 记录容器化部署架构与实践
梳理 Gitea CI、容器网络与镜像存储边界。
记录 Registry 推送、手动部署及排障过程。
2026-10-13 00:14:30 +08:00
labmem 144b8abe49 ci(polaris): 增加镜像发布工作流
Polaris CI / verify (push) Successful in 2m10s
Polaris CI / publish (push) Successful in 1m56s
校验通过后构建、冒烟测试并推送镜像。
提供按摘要手动部署与回滚的 Compose 模板和说明。
2026-10-12 23:00:34 +08:00
labmem 2c674b7f2d feat(polaris)!: 改为 standalone 容器运行
Polaris CI / verify (push) Successful in 1m47s
将 Next.js 构建切换为 standalone,新增多阶段 Docker 镜像和非 root 运行。
CI 改为启动服务检查页面与静态资源,并移除静态导出校验。

BREAKING CHANGE: 不再生成 out/ 静态产物,部署需运行 Node 服务容器。
2026-10-12 17:42:26 +08:00
labmem 09e5735162 ci(polaris): 添加ci测试
Polaris CI / verify (push) Successful in 1m42s
2026-10-12 15:28:36 +08:00
labmem abe4dec0b1 ci(polaris): 增加gitea工作流测试
Polaris runner smoke test / smoke (push) Successful in 1m15s
2026-10-12 14:51:38 +08:00
labmem 6e839019f5 feat(polaris): 细分游戏资料并完善站点导航
新增游戏资料分类及九个网站链接,清理三条旧链接。
调整侧边栏提示交互,并为阿里云控制台使用本地图标。
同步更新分类、链接和交互测试。
2026-10-12 00:08:34 +08:00
labmem 95e2799721 feat(polaris): 优化深色玻璃主题与分类页布局
让背景透过分类页内容区域,并移除标题背景框。
优化移动端样式,更新背景图和品牌标志,保留旧标志备份。
2026-10-10 22:00:54 +08:00
97 changed files with 4267 additions and 476 deletions
+173
View File
@@ -0,0 +1,173 @@
name: Polaris CI
on:
push:
tags:
- 'polaris-v*'
jobs:
verify:
runs-on: polaris-ci
container: node:22.23.2-bookworm
timeout-minutes: 30
defaults:
run:
working-directory: ts/web/nextjs/polaris
steps:
- name: 检出仓库
uses: https://gitea.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: 安装依赖
run: corepack pnpm install --frozen-lockfile
- name: 代码检查
run: corepack pnpm lint
- name: 类型检查
run: corepack pnpm typecheck
- name: 运行测试
run: corepack pnpm test
- name: 构建独立服务器
run: corepack pnpm build
- name: 准备独立部署文件
run: |
test -f .next/standalone/server.js
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
- name: 独立服务器冒烟测试
shell: bash
run: |
set -euo pipefail
base_url="http://127.0.0.1:3100"
PORT=3100 HOSTNAME=127.0.0.1 node .next/standalone/server.js &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
ready=0
for _ in $(seq 1 30); do
if curl -fsS -o /dev/null "$base_url/"; then
ready=1
break
fi
kill -0 "$server_pid" 2>/dev/null || { echo "服务器进程提前退出" >&2; exit 1; }
sleep 1
done
test "$ready" -eq 1 || { echo "服务器在 30 秒内未就绪" >&2; exit 1; }
curl -fsS -o /dev/null "$base_url/categories/dev/"
curl -fsS -o /dev/null "$base_url/brand/logo.svg"
static_asset=$(curl -fsS "$base_url/" | grep -o '/_next/static/[^"]*' | sed -n '1p')
test -n "$static_asset" || { echo "首页未引用 /_next/static 静态资源" >&2; exit 1; }
curl -fsS -o /dev/null "$base_url$static_asset"
publish:
needs: verify
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/polaris-v')
runs-on: polaris-ci
container: node:22.23.2-bookworm
timeout-minutes: 30
steps:
- name: 检出仓库
uses: https://gitea.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
# Daemon comes from the runner (gitea/runner:3-dind-rootless) via the
# job container's /var/run/docker.sock. Only the CLI is installed here;
# DOCKER_HOST stays unset and no host socket is mounted.
- name: 安装 Docker 命令行工具
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends docker.io
docker version
- name: 确定镜像引用
shell: bash
run: |
set -euo pipefail
repository="${GITHUB_REPOSITORY:?未设置 GITHUB_REPOSITORY}"
owner="${repository%%/*}"
owner="${owner,,}"
if ! [[ "$owner" =~ ^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$ ]]; then
echo "::error::所有者名称 '$owner' 不是有效的镜像仓库路径组成部分" >&2
exit 1
fi
image="git.labmem.icu/${owner}/polaris:${GITHUB_SHA}"
echo "镜像引用:$image"
echo "IMAGE=$image" >> "$GITHUB_ENV"
- name: 构建镜像
run: docker build -t "$IMAGE" ts/web/nextjs/polaris
- name: 镜像冒烟测试
shell: bash
run: |
set -euo pipefail
# Use the unique container ID instead of a fixed name so
# concurrent runs on a shared runner daemon cannot collide.
container="$(docker run -d "$IMAGE")"
cleanup() { docker rm -f "$container" >/dev/null 2>&1 || true; }
trap cleanup EXIT
probe() {
docker exec "$container" node -e "fetch('http://127.0.0.1:3000$1').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" >/dev/null 2>&1
}
ready=0
for _ in $(seq 1 30); do
if probe /; then
ready=1
break
fi
if [[ "$(docker inspect -f '{{.State.Running}}' "$container")" != "true" ]]; then
echo "容器提前退出,日志:" >&2
docker logs "$container" >&2 || true
exit 1
fi
sleep 1
done
test "$ready" -eq 1 || { echo "服务器在 30 秒内未就绪" >&2; docker logs "$container" >&2 || true; exit 1; }
probe /categories/dev/ || { echo "冒烟测试失败:/categories/dev/" >&2; exit 1; }
probe /brand/logo.svg || { echo "冒烟测试失败:/brand/logo.svg" >&2; exit 1; }
echo "镜像 $IMAGE 冒烟测试通过"
- name: 登录并推送镜像
shell: bash
env:
POLARIS_REGISTRY_USER: ${{ secrets.POLARIS_REGISTRY_USER }}
POLARIS_REGISTRY_TOKEN: ${{ secrets.POLARIS_REGISTRY_TOKEN }}
run: |
set -euo pipefail
if [[ -z "${POLARIS_REGISTRY_USER}" || -z "${POLARIS_REGISTRY_TOKEN}" ]]; then
echo "::error::缺少 secrets.POLARIS_REGISTRY_USER 或 secrets.POLARIS_REGISTRY_TOKEN,无法登录 git.labmem.icu;请在仓库 Settings -> Actions -> Secrets 配置后重试" >&2
exit 1
fi
# Ephemeral docker config: credentials live only inside this step
# and are deleted when the step (and with it the job) exits.
docker_config_dir="$(mktemp -d)"
push_log_file="$(mktemp)"
trap 'rm -rf "$docker_config_dir" "$push_log_file"' EXIT
printf '%s' "$POLARIS_REGISTRY_TOKEN" | docker --config "$docker_config_dir" login git.labmem.icu --username "$POLARIS_REGISTRY_USER" --password-stdin >/dev/null
echo "正在推送 $IMAGE"
# tee keeps push output visible in the log; pipefail (already set
# via `set -euo pipefail`) fails the step when push itself fails.
docker --config "$docker_config_dir" push "$IMAGE" 2>&1 | tee "$push_log_file"
digest="$(sed -n 's/.*digest: \(sha256:[0-9a-f]\{64\}\).*/\1/p' "$push_log_file" | tail -n 1)"
if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::push 日志中未解析到合法 digest(期望 sha256:<64 位十六进制>),不输出部署引用;请在 registry 界面确认该镜像的实际 digest 后再手动部署" >&2
exit 1
fi
echo "已发布镜像:$IMAGE"
echo "镜像摘要:$digest"
echo "手动部署引用:docker pull ${IMAGE%%:*}@$digest"
+1
View File
@@ -46,3 +46,4 @@ Thumbs.db
# ---- Note 目录 ----
note/项目/
docs/plans
go/projects/lyra/data
+17 -11
View File
@@ -6,26 +6,32 @@
| 目录 | 说明 |
|---|---|
| `go/` | Go module `lab`(Go 1.26.2),go.mod 在该目录内 |
| `go/` | Go 代码:`practice/` 是共用 module(`module lab`)的练习区,`projects/` 各项目为独立 module |
| `note/` | 个人笔记(非代码),`note/项目/` 被 gitignore |
| `dart/` | 预留,尚未建立 |
## Go 约定
- **Module 在 `go/` 目录下,不在仓库根**——所有 go 命令需在 `go/` 内执行
- 每个叶子目录 = 一个独立小程序(`package main` + `main.go`)
- 多个小程序可并存,不同目录、各自独立
- 主题目录(basics/concurrency/stdlib/patterns/algorithms/web/testing/project)可随时增删
- 测试文件用 `*_test.go`,运行单个测试:`cd go && go test ./testing/ -run TestSliceAppend`
- Go 代码集中在 `go/`,目录本身不是 module,按用途分两区:
- `go/practice/`——共用 module 的练习区,`go.mod` 在该目录内(见 [go/practice/README.md](go/practice/README.md))
- `go/projects/<项目名>/`——每个项目一个独立 module,自带 `go.mod`(见 [go/projects/README.md](go/projects/README.md))
- **go 命令在对应 module 的根目录执行**:练习代码在 `go/practice/` 内执行;projects 每个项目在自己的目录内执行
- 练习区中每个叶子目录通常是一个独立小程序(`package main` + `main.go`),多个小程序并存互不冲突;库包(如算法实现)与只有测试文件的包也是合法形态
- 主题目录可随时增删;写新程序时挑最贴的主题,在里面建一个新子目录
- 测试文件用 `*_test.go`,运行单个测试:`cd go/practice && go test ./testing/slice -run TestSliceAppend`
## 开发命令
```
# Go——所有命令在 go/ 目录下执行
cd go
go run ./<主题>/<程序名>/ # 运行单个程序
go test ./... # 运行全部测试
go test ./testing/ -v # 运行 testing 包测试
# Go 练习——在 go/practice/ 目录下执行
cd go/practice
go run ./basics/runtime/ # 运行单个程序
go test ./... # 运行全部测试(部分练习阻塞/依赖环境,不保证全过)
go test ./testing/... -v # 运行 testing 包及其子包测试
go mod tidy # 整理依赖
# Go projects——在 go/projects/<项目名>/ 各自目录下执行
go run . # 运行项目(按实际入口调整)
go mod tidy # 整理依赖
```
+1 -1
View File
@@ -4,7 +4,7 @@
## 当前语言
- `go/` — Go(共用一个 module,约定见 [go/README.md](go/README.md))
- `go/` — Go(分 `practice/` 练习区与 `projects/` 项目区,约定见 [go/README.md](go/README.md))
- `dart/` — Dart
- `ts/` — TypeScript / JavaScript(每个实验独立 npm 项目,约定见 [ts/README.md](ts/README.md))
+10 -20
View File
@@ -1,24 +1,14 @@
# Go 练习区
# Go
本目录是 Go 的学习 / 练手代码,共用一个 module(`go.mod` 在本目录)。
本目录按用途分两个区,`go/` 本身不是 module:
## 约定
- **每个叶子目录 = 一个独立小程序**:`package main` + `main.go`。
- 多个小程序并存互不冲突(不同目录、各自的 `main`)。
- 某个程序依赖太特殊时,可在它自己的目录单独 `go mod init`,脱离本 module。
## 主题目录
| 目录 | 放什么 |
| 目录 | 定位 |
|---|---|
| `basics/` | 语法基础、内置类型(slice / map / chan 行为等) |
| `concurrency/` | 并发:goroutine / channel / select / context |
| `stdlib/` | 标准库练习(net/http、encoding/json、io…) |
| `patterns/` | 设计模式 |
| `algorithms/` | 数据结构与算法 |
| `web/` | net/http、Gin 等框架 |
| `testing/` | 练习 Go 测试机制(`*_test.go`、`go test`、table-driven、testify) |
| `project/` | 完整小项目(多文件) |
| [`practice/`](practice/README.md) | 练习区:共用一个 module(`go.mod` 在 `practice/` 内),语法 / 算法 / 测试等日常练手代码 |
| [`projects/`](projects/README.md) | 项目区:每个子目录是独立 module 的完整小应用,自带 `go.mod`、自行管理依赖 |
> 主题目录可随时增删。写新程序时挑最贴的主题,在里面建一个新子目录放 `main.go` 即可。
## 边界
- 写小段代码验证语言行为、练语法和标准库 → 放 `practice/` 对应主题目录。
- 程序长成完整应用,或依赖需要独立于练习区管理 → 在 `projects/` 下新建目录,单独 `go mod init`。
- 两个区互不包含:`practice/` 的 module 不覆盖 `projects/`,每个 projects 项目也是独立 module。
+35
View File
@@ -0,0 +1,35 @@
# practice — 练习区
共用一个 module 的练手代码:`go.mod` 在本目录(`module lab`),所有日常练习都放这里。两区定位见 [../README.md](../README.md)。
## 约定
- 每个小程序 = 一个子目录:`package main` + `main.go`,多个小程序并存互不冲突(不同目录、各自的 `main`)。
- 库包与测试包同样是合法形态:算法实现可以只提供库(如 `algorithms/linkedlist/`),`testing/` 下多为只有 `*_test.go` 的测试包。
- 测试文件用 `*_test.go`,与被测代码同目录或独立成子包均可。
- 程序长成完整应用、依赖需要独立管理时,移到 [`../projects/`](../projects/README.md) 单独 `go mod init`。
## 当前主题
| 目录 | 放什么 |
|---|---|
| `basics/` | 语法基础、内置类型行为(slice/map、runtime、pprof、goroutine 泄漏等) |
| `algorithms/` | 数据结构与算法(含可复用的库包,如 `linkedlist/`) |
| `testing/` | 练习 Go 测试机制,按点分子包:table-driven、regexp、net、crypto、json、error 等 |
主题目录可随时增删:想练并发、标准库、设计模式、Web 时,直接新建 `concurrency/`、`stdlib/`、`patterns/`、`web/` 等目录即可。
## 常用命令
以下示例从仓库根目录开始执行:
```bash
cd go/practice
go run ./basics/runtime/ # 运行单个程序
go test ./... # 运行全部测试(不保证全过,见下方说明)
go test ./testing/... -v # testing 包及其子包的全部测试
go test ./testing/slice -run TestSliceAppend -v # 运行单个测试
go mod tidy # 整理依赖
```
> 注意:练习代码中含阻塞(如 channel 永久等待)或依赖环境(如反向 DNS)的测试示例,`go test ./...` 不保证全部通过;验证单个练习可用 `go test ./testing/slice -run TestSliceAppend`。
+1
View File
@@ -5,6 +5,7 @@ go 1.27.0
require (
github.com/gin-contrib/sessions v1.1.2
github.com/gin-gonic/gin v1.12.0
github.com/go-chi/chi/v5 v5.5.0
golang.org/x/crypto v0.56.0
)
+2
View File
@@ -17,6 +17,8 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
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-chi/chi/v5 v5.5.0 h1:xdKQ1dJmVJsmgahrXSKhUx8NyVPzpwdlyrrQFpMPJgg=
github.com/go-chi/chi/v5 v5.5.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM=
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=
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
})
http.ListenAndServe(":30000", r)
}
+27
View File
@@ -0,0 +1,27 @@
# projects — 独立 module 项目区
每个子目录是一个**完全独立的 Go module**,用于完整的小应用:自带 `go.mod`、自行管理依赖,与 [`../practice/`](../practice/README.md) 的共用 module 互不影响。是否该放这里见 [../README.md](../README.md)。
## 新建项目
以下命令从仓库根目录开始执行:
```bash
mkdir go/projects/myapp && cd go/projects/myapp
go mod init example.com/myapp
```
`example.com/myapp` 仅为示例,需替换:本地练手可任意取名;打算对应真实仓库时按实际路径选择(如 `github.com/<用户名>/lab/go/projects/myapp`)。
之后按需创建 `main.go`(或 `cmd/<名>/main.go` 等布局)即可,不需要额外脚手架。
## 运行与维护
Go 命令一律在**该项目自己的目录**(module 根)下执行;以下示例从仓库根目录开始:
```bash
cd go/projects/myapp
go run . # 运行(按项目实际入口调整)
go test ./... # 运行测试
go mod tidy # 整理依赖
```
+66
View File
@@ -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 依赖尚未引入。
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"lyra/internal/config"
"lyra/internal/handler"
"lyra/internal/model"
"lyra/internal/router"
"github.com/jmoiron/sqlx"
)
type Dependencies struct {
Config *config.Config
CategoryModel model.CategoryModel
}
// buildHandlers 集中组装各路由的处理器,入口启动时调用一次。
func buildHandlers(dpd *Dependencies) router.Handlers {
return router.Handlers{
TestHandler: handler.NewTestHandler(dpd.CategoryModel).Hello(),
}
}
func buildDependencies(cfg *config.Config, db *sqlx.DB) *Dependencies {
return &Dependencies{
Config: cfg,
CategoryModel: model.NewCategoryModel(db),
}
}
+51
View File
@@ -0,0 +1,51 @@
// Command lyra 是 Lyra 的程序入口,负责启动、依赖组装与退出清理。
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 {
log.Fatal("加载配置失败,请检查配置文件和环境变量")
}
gin.SetMode(cfg.App.Env)
ginEngine := gin.Default()
//没有反向代理,在启动前关闭代理信任:
if err := ginEngine.SetTrustedProxies(nil); err != nil {
log.Fatal("设置代理信任失败,请检查配置")
}
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()
deps := buildDependencies(cfg, db)
handlers := buildHandlers(deps)
router.RegisterRoutes(ginEngine, handlers)
addr := fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port)
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 # 使用预先创建的外部网络
+105
View File
@@ -0,0 +1,105 @@
# Lyra 目标结构
日期:2026-10-17
本文记录已确认的目标目录结构与职责边界,并与当前真实实现同步。目录树是**目标结构**,并非全部已存在:标注“待新增”的文件尚未创建;标注“占位”的目录(`internal/postgres`、`internal/service`)仅含 `AGENTS.md`(记录职责与依赖约束),暂不构成 Go 包。当前已有 Go 代码的是 `cmd/lyra` 与 `internal/{api,config,handler,httpx,model,router}`。仓库内另有本地 `.env.local`(已被 gitignore、不入库)等文件,不属于目标结构,不在此展开。选型与讨论过程见 [plans/2026-10-16-lyra-architecture.md](plans/2026-10-16-lyra-architecture.md);handler/service 业务分包的调整记录见 [plans/2026-10-17-business-package-layout.md](plans/2026-10-17-business-package-layout.md)。
## 已确认技术栈
- 已引入并使用:Gin v1.12.0(HTTP 路由)、Viper v1.21.0(配置读取,TOML)、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 # 启动流程:加载 .env.local 与配置、连接数据库、组装依赖
│ └── dependency.go # Dependencies 与 handler 组装
├── internal/
│ ├── api/ # HTTP 请求/响应 DTO、错误码与响应契约(平铺,按资源分文件)
│ │ ├── response.go # Response/ErrorInfo 结构
│ │ ├── code.go # ErrorCode 与错误码常量、默认文案
│ │ ├── category.go # CategoryResponse 分类响应 DTO
│ │ ├── links.go # links 资源 DTO 示例(待新增)
│ │ └── settings.go # 待新增
│ ├── config/
│ │ └── config.go # 配置定义与加载
│ ├── postgres/ # 数据库连接的目标位置(占位:仅 AGENTS.md,连接暂在 cmd/lyra/main.go)
│ ├── router/
│ │ ├── router.go # 路由注册、项目路径前缀
│ │ └── router_test.go # 路由 httptest 回归测试
│ ├── handler/
│ │ ├── test.go # GET /test 处理:调用 model、显式 DTO 转换、响应输出
│ │ ├── test_test.go # handler 替身测试
│ │ ├── links.go # 参数绑定、DTO 转换、响应输出(待新增)
│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包)
│ ├── service/ # 业务规则、事务协调(占位:仅 AGENTS.md)
│ │ ├── links.go # 业务规则、事务协调(待新增)
│ │ ├── links_test.go # 测试与源文件同目录(待新增)
│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包)
│ ├── model/
│ │ ├── category.go # Category 结构、CategoryModel 接口与 Squirrel+sqlx 实现
│ │ ├── link.go # Link 数据结构定义(待新增)
│ │ ├── link_model.go # 数据访问对象:CRUD 与 SQL(待新增)
│ │ └── link_model_test.go # 查询测试(待新增)
│ └── httpx/
│ └── response.go # 公共响应输出辅助(OK/Fail)
├── etc/
│ └── lyra.toml # 运行配置(App/HTTP/Database/Redis)
├── migrations/ # Goose SQL 迁移文件(00001_init.sql 初始迁移 + README)
├── docs/
│ ├── plans/
│ └── architecture.md # 本文
├── README.md
├── go.mod
└── go.sum # 已生成
```
- 标注“待新增”的文件只是职责分布示例,随实现按需新增,不预建空文件或空目录;`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 不照搬同样的分包。
## 组织原则
- handler、service 可以按明确业务分包(如 `polaris/`),仅在业务边界清晰时新增;服务级简单接口(如 `hello`)保留在各自根包内平铺,不强制分包。
- router、api、model 等其他层暂时平铺,不按业务项目建代码子目录;项目通过 `/api/polaris` 等路由前缀区分,包内文件按资源划分。
- 公共基础能力全局唯一:配置在 internal/config,连接在 internal/postgres,HTTP 辅助在 internal/httpx,不随业务项目复制或按项目再拆。
- 不保留 platform/modules 分类层;简单接口不强制走完整三层,也不按每张表机械复制层级。
- 不预建空业务包,分包随实际业务代码一起落地。
## 层间边界
- 真实业务的主调用链为 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,避免传输契约与实现耦合或循环引用。
- 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。
## 尚未确定 / 待实现
以下均为计划中的推荐或待确认项,尚未实现,也未定为已决:
- 配置校验与默认值:尚未实现。环境变量覆盖当前仅 `database.password` 绑定 `PG_PASSWORD`,其余配置项不支持。
- 连接与查询超时:尚未设置,也未确定超时策略。
- `ErrorInfo.Debug` 的填充与按环境(dev)开关:尚未实现,目前仅有字段。
- 分页响应约定(如 data/meta 结构)与统一错误处理中间件:尚未引入,仅为历史讨论。
- Goose 迁移的部署执行入口已确定为手工 CLI(见 [../migrations/README.md](../migrations/README.md)),并发控制以“单操作者串行执行”为约定,数据库锁未引入,服务启动不自动迁移。
- PostgreSQL 按项目划分 schema 的做法(组织边界,不等于权限隔离)。
- 日志方案(候选标准库 slog)与中间件集成方式。
+126
View File
@@ -0,0 +1,126 @@
-- Polaris 导航站 PostgreSQL 新库初始化示例(非幂等,仅执行一次)。
BEGIN;
-- updated_at 维护函数
CREATE FUNCTION public.set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
-- 用户表
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();
COMMIT;
+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,123 @@
# Lyra 技术选型与架构讨论记录
日期:2026-10-16
状态:HTTP 路由、数据访问、数据库迁移工具与目标目录结构已确认(结构见 [architecture.md](../architecture.md));本文档方案已获文档层面批准,代码实施尚未批准。
## 已确认
- Lyra 是个人 Go 后端,先承接 Polaris,未来承接其他项目接口。
- Polaris 采用一人管理、匿名公开浏览的模式。
- 已有服务器和 PostgreSQL;具体部署与连接配置尚未核实。
- 数据访问选择 Squirrel + sqlx,PostgreSQL 驱动使用 pgx stdlib。
- HTTP 路由选择 Gin;用户无明显接口风格偏好,结合文档与开发便利性作出选择。
- 数据库迁移工具选择 Goose。
- 本次确认仅涉及选型,不代表批准搭建代码或接入真实数据库。
## 数据访问约定
- 固定查询直接写 SQL,动态查询使用 Squirrel,统一由 sqlx 执行。
- Squirrel 使用 Dollar 占位符,普通值使用参数绑定,动态标识符采用服务端白名单。
- 使用带 Context 的执行方法;需要原子性的业务操作使用事务。
- 显式声明查询字段,处理可空值和 JOIN 列名冲突。
- 不同时引入 sqlc 或 golang-migrate。
- 已讨论的取舍:无代码生成步骤,但查询与结果映射主要在运行时校验;上游维护低频。
## 当前实现状态
- Lyra 仅有 go.mod 与 docs/ 下的文档,尚无接口、数据库或业务代码。
- Polaris 的 services 仍同步读取本地 JSON,未接入 Lyra。
- 未进行数据库连接或数据库结构修改。
## 架构建议(尚未定案)
- 一个 Go 服务;根据用户不希望按 Polaris 建立独立项目目录的偏好,调整为按技术职责组织代码,项目在 HTTP 路由上使用不同前缀。
- 各项目使用独立 HTTP 前缀,共享配置、日志、连接池等基础设施。
- PostgreSQL 可按项目划分 schema;这是组织边界,不等于权限隔离。
- 不提前建设微服务、统一用户中心或通用 CRUD 框架。
- 日志候选为标准库 slog,具体中间件与日志集成方式另行确认。
## 目录结构与边界(已确认)
2026-10-16 用户确认以下目标组织方式;同日确认将数据访问职责并入 model,不再设独立 internal/store 目录。完整结构记录见 [architecture.md](../architecture.md)。确认的是结构与职责划分,不代表立即创建所有文件或占位目录,代码实施仍需另行批准。
```text
lyra/
├── cmd/lyra/main.go
├── internal/
│ ├── api/
│ │ ├── links.go
│ │ ├── categories.go
│ │ └── settings.go
│ ├── config/
│ │ └── config.go
│ ├── postgres/
│ │ └── postgres.go
│ ├── router/
│ │ └── router.go
│ ├── handler/
│ │ └── links.go
│ ├── service/
│ │ ├── links.go
│ │ └── links_test.go
│ ├── model/
│ │ ├── link.go
│ │ ├── link_model.go
│ │ └── link_model_test.go
│ └── httpx/
│ └── response.go
├── migrations/
│ └── 00001_polaris_create_tables.sql
├── docs/plans/
├── README.md
├── go.mod
└── go.sum
```
- cmd/lyra 负责启动、依赖组装、调用路由注册和退出清理,不放业务规则。
- 用户提出统一管理接口请求与响应;推荐 internal/api,只放 HTTP 请求与响应 DTO 及其字段标签,按资源组织文件,不放 handler、业务逻辑或数据库查询。
- internal/router 注册路由与项目路径前缀;handler 处理参数绑定、DTO 转换与响应输出;service 管理业务规则;model 承载数据结构定义与数据访问(Squirrel/SQL 构造、sqlx 执行与结果映射),驱动为 pgx stdlib。测试与被测文件同目录。
- 树中 handler/service/model 仅列出链接资源示例;model/link.go 定义 Link 数据结构,model/link_model.go 定义数据访问与 SQL,model/link_model_test.go 测试查询,均为示例,实际文件随实现新增,不创建空占位目录或文件。
- handler 引用 API DTO 并转换为业务输入或响应;service/model 不依赖 API DTO 包,API DTO 包不引用 handler/service/model,避免传输契约和实现相互耦合或循环引用。
- 请求、响应与数据库模型职责分开,即使初期字段相近,也不默认直接把数据库模型暴露为响应;接受必要的显式转换代码。
- 主调用链为 handler→service→model→PostgreSQL;service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。
- 上述文件是职责划分建议,不要求简单接口机械地走完整三层,也不按每张表或每种类型机械复制一套层级与文件。
- HTTP 层可以依赖 Gin;service 和 model 使用标准 context.Context,不接收 gin.Context。
- 移除 platform 和 modules 分类层;配置、连接和 HTTP 辅助直接位于 internal/config、internal/postgres、internal/httpx,减少无必要的路径深度。httpx 只在需要统一响应输出时实现,不提前抽象用户、设置、通用 CRUD 等业务。
- go.sum 在引入依赖后由 Go 工具生成;测试文件随实际功能编写,不创建空占位文件。
- 新项目可以在路由上增加独立前缀并复用上述技术层目录,不强制增加项目目录。不同领域的同名类型需要明确命名;文件名本身不提供 Go 包级符号隔离。
- 路由前缀只区分 HTTP 入口,不自动隔离代码依赖、数据库或权限;业务所有权与鉴权仍需显式设计,不能因路径不同就假定隔离成立。
- PostgreSQL 按项目划分 schema 仍为建议,尚未确认;schema 不自动提供权限隔离。
### 外部示例参考
- 参考 https://github.com/vespeng/gin-pathway ,读取 main 分支 commit 67582d1。
- 借鉴其薄启动入口、显式依赖组装与统一错误处理思路;曾考虑其按业务分组,后按用户偏好改为技术分层,不直接复制具体代码或全部依赖。
- 该示例没有独立 API DTO 目录,部分数据库模型直接作为响应,本方案不沿用这一点。
- 不沿用其启动时同步表结构的机制;Lyra 仍使用已确认的 Goose 管理迁移,具体执行方式待确认。
- 不预建空 pkg、scripts、tests 等目录,不引入当前无需求的 Redis、ORM 或全局数据库单例。
- internal/api 与根 api 都是可行布局,小写 api 是命名习惯;推荐 internal/api 是因为当前 DTO 仅供 Lyra 内部使用,而非 Go 语言强制要求。
- 另参考 https://github.com/llmons/go-dianping ,读取 main 分支 commit cc7fbcf9:借鉴其独立 API 响应定义、技术分层与构造函数注入依赖;不照搬 ORM 生成层及缓存设施。
- go-dianping 的部分请求与响应复用数据库模型,Lyra 的独立请求/响应 DTO 方案不沿用这一做法。
## 推荐迁移执行方式(待用户确认)
- 起步采用 Goose CLI,在独立部署步骤中显式执行 SQL 迁移;Web 服务启动时不自动迁移。
- 迁移成功后再启动或更新服务;迁移失败则停止后续发布,检查实际状态。
- 同一个 Lyra 数据库初期使用一个 migrations 目录和统一版本序列,文件名标注所属模块;不让不同模块共用版本表却各自维护重复版本号。
- 部署流程保证同一数据库的迁移串行执行;如以后存在多个迁移入口或并发发布,再选择带数据库锁的迁移执行入口,不假定事务本身能防止并发迁移。
- 优先 SQL 迁移,非事务 DDL 单独设计;生产回退需要评估数据影响,不自动执行 Down。
- 表结构迁移与 Polaris JSON 初次导入分开设计,不能把 Goose 等同于业务数据导入工具。
## 后续讨论顺序
1. 确认 Goose 的执行入口、串行执行或数据库锁方案(技术分层与路由前缀组织已确认,见 [architecture.md](../architecture.md))。
2. 确认首阶段范围:建议先做 JSON 导入、只读 API 和 Polaris 接入;管理写入另行确定认证方式。
3. 编写实施与验证方案,获得明确批准后再实施。
## 后续实施方案必须覆盖的验证
- 使用隔离的 PostgreSQL 测试环境验证迁移、核心查询与事务,不默认操作现有真实数据。
- 核对导入前后的分类、链接、排序和置顶语义。
- 接入 Polaris 时验证 service 异步化、动态分类路由、缓存与后端故障处理。
- 管理写接口上线前验证鉴权,不能以隐藏入口代替权限控制。
@@ -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 方法。
+18
View File
@@ -0,0 +1,18 @@
[app]
name = "lyra"
env = "debug" #gin支持 release | test | debug 3种mode,如果需要自定义,需要映射到这3种mode中去,gin的默认模式是debug模式
[http]
host = "0.0.0.0"
port = 7777
[database]
driver = "pgx"
host = "localhost"
port = 5432
user = "admin"
name = "lab"
[redis]
host = "localhost"
port = 6379
+59
View File
@@ -0,0 +1,59 @@
module lyra
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
)
require (
github.com/Masterminds/squirrel v1.5.4 // indirect
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/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/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/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.yaml.in/yaml/v3 v3.0.4 // 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.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+149
View File
@@ -0,0 +1,149 @@
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/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/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/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=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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=
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/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.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+5
View File
@@ -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"`
}
+21
View File
@@ -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 "服务暂时不可用"
}
}
+13
View File
@@ -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"`
}
@@ -0,0 +1,4 @@
# config 包
- 负责配置定义与加载。
- 具体配置项、来源与加载方式随实际需求确定,由程序入口调用配置加载逻辑。
@@ -0,0 +1,58 @@
package config
import (
"fmt"
"github.com/spf13/viper"
)
type Config struct {
App AppConfig `mapstructure:"app"`
HTTP HTTPConfig `mapstructure:"http"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
}
type AppConfig struct {
Name string `mapstructure:"name"`
Env string `mapstructure:"env"`
}
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 RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
}
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,6 @@
# handler 包
- 负责参数绑定、API DTO 与内部模型的相互转换、响应输出。
- 引用 internal/api 的 DTO,使用 Gin 完成绑定与响应。
- 调用 service 时传递标准 context.Context,不下传 gin.Context。
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
+43
View File
@@ -0,0 +1,43 @@
package handler
import (
"lyra/internal/api"
"lyra/internal/httpx"
"lyra/internal/model"
"net/http"
"github.com/gin-gonic/gin"
)
type TestHandler struct {
categoryModel model.CategoryModel
}
func NewTestHandler(categoryModel model.CategoryModel) *TestHandler {
return &TestHandler{categoryModel: categoryModel}
}
func (h *TestHandler) Hello() gin.HandlerFunc {
return func(c *gin.Context) {
categories, err := h.categoryModel.FindAll(c.Request.Context())
if err != nil {
httpx.Fail(c, http.StatusInternalServerError, api.CodeInternalError, "获取分类列表失败")
return
}
httpx.OK(c, 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,128 @@
package handler_test
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"lyra/internal/handler"
"lyra/internal/model"
"github.com/gin-gonic/gin"
)
type fakeCategoryModel struct {
categories []*model.Category
err error
calls int
gotCtx context.Context
}
func (f *fakeCategoryModel) FindAll(ctx context.Context) ([]*model.Category, error) {
f.calls++
f.gotCtx = ctx
return f.categories, f.err
}
func serveHello(t *testing.T, fake *fakeCategoryModel, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/test", handler.NewTestHandler(fake).Hello())
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
return recorder
}
func newHelloRequest() *http.Request {
return httptest.NewRequest(http.MethodGet, "/test", nil)
}
func TestHelloMapsCategoriesToResponse(t *testing.T) {
fake := &fakeCategoryModel{categories: []*model.Category{
{ID: 1, Slug: "go-basics", Title: "Go 基础", Sort: 1},
{ID: 2, Slug: "sql-practice", Title: "SQL 练习", Sort: 2},
}}
recorder := serveHello(t, fake, newHelloRequest())
if fake.calls != 1 {
t.Fatalf("模型调用次数 = %d, want 1", fake.calls)
}
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
want := `{"success":true,"data":[{"id":1,"slug":"go-basics","title":"Go 基础","sort":1},{"id":2,"slug":"sql-practice","title":"SQL 练习","sort":2}]}`
if got := recorder.Body.String(); got != want {
t.Fatalf("body = %q, want %q", got, want)
}
}
func TestHelloReturnsEmptyArrayForNoCategories(t *testing.T) {
tests := []struct {
name string
categories []*model.Category
}{
{name: "空切片", categories: []*model.Category{}},
{name: "nil结果", categories: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &fakeCategoryModel{categories: tt.categories}
recorder := serveHello(t, fake, newHelloRequest())
if fake.calls != 1 {
t.Fatalf("模型调用次数 = %d, want 1", fake.calls)
}
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
want := `{"success":true,"data":[]}`
if got := recorder.Body.String(); got != want {
t.Fatalf("body = %q, want %q", got, want)
}
})
}
}
func TestHelloReturnsInternalErrorOnModelFailure(t *testing.T) {
modelErr := errors.New("数据库连接被拒绝: 10.0.0.8:5432")
fake := &fakeCategoryModel{err: modelErr}
recorder := serveHello(t, fake, newHelloRequest())
if fake.calls != 1 {
t.Fatalf("模型调用次数 = %d, want 1", fake.calls)
}
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
}
want := `{"success":false,"error":{"code":10002,"message":"获取分类列表失败"}}`
if got := recorder.Body.String(); got != want {
t.Fatalf("body = %q, want %q", got, want)
}
if strings.Contains(recorder.Body.String(), modelErr.Error()) {
t.Fatalf("响应泄漏了内部错误信息: %q", recorder.Body.String())
}
}
type contextKey struct{}
func TestHelloPassesRequestContextToModel(t *testing.T) {
fake := &fakeCategoryModel{categories: []*model.Category{}}
reqCtx := context.WithValue(context.Background(), contextKey{}, "req-42")
req := newHelloRequest().WithContext(reqCtx)
serveHello(t, fake, req)
if fake.gotCtx != reqCtx {
t.Fatalf("模型收到非请求 context: %v", fake.gotCtx)
}
if got, _ := fake.gotCtx.Value(contextKey{}).(string); got != "req-42" {
t.Fatalf("context 值 = %q, want %q", got, "req-42")
}
}
@@ -0,0 +1,4 @@
# httpx 包
- 提供公共 HTTP 响应辅助。
- 出现实际复用需求时再补充具体辅助函数,不预先设计无使用场景的抽象。
@@ -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,6 @@
# model 包
- 承载数据结构定义与数据访问:使用 Squirrel/SQL 构造查询、sqlx 执行与映射结果,驱动为 pgx stdlib。
- SQL 与查询逻辑显式写在这一层,不使用 ORM 自动持久化。
- 支持在 service 传入的同一事务内执行操作。
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
@@ -0,0 +1,46 @@
package model
import (
"context"
"fmt"
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"`
}
type CategoryModel interface {
FindAll(ctx context.Context) ([]*Category, error)
}
type defaultCategoryModel struct {
db *sqlx.DB
}
func NewCategoryModel(db *sqlx.DB) CategoryModel {
return &defaultCategoryModel{db: db}
}
func (m *defaultCategoryModel) FindAll(ctx context.Context) ([]*Category, error) {
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)
}
return rows, err
}
@@ -0,0 +1,4 @@
# postgres 包
- 负责建立与管理 PostgreSQL 连接,使用 pgx stdlib 驱动。
- 连接参数来自 internal/config,连接供 model 层通过 sqlx 执行查询使用。
@@ -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,6 @@
# service 包
- 承载业务规则与事务协调,通过 model 完成数据访问。
- 需要原子性时,在同一事务内协调多次写入,不把各自独立提交的多次写入当成原子业务。
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
+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();
+21
View File
@@ -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,143 @@
# Gitea 简单 CI 实践
> 用 Gitea Actions 给仓库搭一条最简单的持续集成:推送代码后自动做语法检查、跑单元测试。
## 1. 启用 Gitea Actions
- Gitea 1.21 及以上版本的实例默认启用 Actions(`app.ini` 中 `[actions] ENABLED` 默认为 true);
- 但仓库级默认禁用,需在仓库 **设置 → Actions** 中打开。
## 2. 准备并注册 act_runner
job 由 act_runner 执行,可以二进制安装或者容器安装,官方文档:<https://docs.gitea.com/runner/>
准备前提:
- runner 所在机器能通过网络访问 Gitea 实例;
- 本例用 Docker 执行器(label 里的 `docker://` 前缀),runner 所在机器需有可用的 Docker。
本地 [Compose 示例](local-ci/compose.yaml) 用 `gitea/runner` 容器跑 Runner:
首次启动时 entrypoint 读取 `GITEA_INSTANCE_URL`、`GITEA_RUNNER_REGISTRATION_TOKEN`、
`GITEA_RUNNER_LABELS` 自动完成注册,注册结果写入挂载的 `/data` 下的 `.runner`
文件并持久化,重启容器不必手动注册。只有不用 Compose、直接安装二进制运行时,
才需要手动执行注册与启动命令(见官方文档)。
注册令牌等同凭据:妥善保管,只在环境变量、secrets 或 runner 自己的配置文件里
出现,不要写进命令行历史、文档或仓库文件。
label 的格式是 `名称:执行器://参数`:以 Compose 中的
`demo-ci:docker://node:22-bookworm` 为例,名称为 `demo-ci`,
执行器为 Docker,参数为 job 容器镜像 `node:22-bookworm`。
工作流的 `runs-on` 按**名称部分**匹配。
### Runner 的 Docker 运行方式
`docker://` 表示 job 在容器中运行,但不决定 Runner 使用哪一个 Docker daemon:
| 术语 | 含义 | 取舍 |
|---|---|---|
| DinD(Docker-in-Docker) | Runner 容器里另起 Docker daemon,由它创建 job 容器 | 与宿主 Docker 分开;job 默认也与外层 Compose 网络分开,需要配置访问 Gitea 的路径 |
| DooD(Docker-outside-of-Docker) | Runner 通过宿主机 Docker socket 使用宿主 daemon,不在容器里另起 daemon | 配置和缓存复用较简单;能接触 socket 的 job 可能控制宿主 Docker,不宜在共享宿主上运行不可信代码 |
| Rootless | Docker daemon 以非 root 身份运行,不是 DinD 的反义词 | 可以与 DinD 组合;降低部分权限风险,但仍需留意网络、存储限制及宿主资源访问 |
本地 [Compose 示例](local-ci/compose.yaml) 使用需要特权容器的 `3-dind`,
并非 Rootless。三种术语并不处于同一分类:DinD / DooD 说明**使用哪个 daemon**,
Rootless 说明**daemon 以什么权限运行**;它也不等于完全隔离。
## 3. 最小 Node demo
两个零依赖文件,放在仓库根目录。
`demo.js`(CommonJS,一个加法函数):
```js
function add(a, b) {
return a + b;
}
module.exports = { add };
```
`demo.test.js`(用 Node 内置的 `node:test` 与 `node:assert/strict`):
```js
const test = require('node:test');
const assert = require('node:assert/strict');
const { add } = require('./demo');
test('整数相加', () => {
assert.equal(add(1, 2), 3);
});
test('负数相加', () => {
assert.equal(add(-1, -2), -3);
});
```
先在本地验证能跑:`node --test demo.test.js`。
## 4. 编写工作流
在仓库根目录新建 `.gitea/workflows/ci.yml`:
```yaml
name: Demo CI
on:
push:
branches: [main] # 默认分支不是 main 的仓库按需修改
jobs:
test:
runs-on: demo-ci
steps:
- name: 检出仓库
uses: https://gitea.com/actions/checkout@v4
- name: 语法检查
run: node --check demo.js && node --check demo.test.js
- name: 运行测试
run: node --test demo.test.js
```
要点:
- `runs-on: demo-ci` 必须与 runner 注册 label 的名称部分一致;
- Gitea 中 `uses` 不带完整 URL 时默认从 GitHub 拉取,这里显式写 gitea.com
的绝对地址;runner 仍需能访问该 action 源;
- job 会跑在 label 指定的 `node:22-bookworm` 容器里:Node 内置测试运行器
开箱即用,不需要 `npm install`,也没有任何第三方依赖。
## 5. 推送并查看结果
```bash
git add demo.js demo.test.js .gitea/workflows/ci.yml
git commit -m "add demo CI"
git push
```
打开仓库 **Actions** 页,能看到这次 push 触发的 run;
点进 job 可展开每个 step 的日志。
常见排查:
| 现象 | 排查方向 |
|---|---|
| run 一直等待、没有 job 被领取 | 仓库 Actions 是否启用;runner 是否在线(runner 页看状态);`runs-on` 与 runner label 名称是否一致。不同版本对不匹配的处理有差异,结合 runner 日志与实际执行环境核对 |
| checkout 步骤失败或超时 | runner 到 Gitea 实例、到 action 源(gitea.com)的网络连通性 |
| 语法检查或测试失败 | 直接看该 step 日志里的报错/断言输出,在本地用同样命令复现 |
本地用 Compose + Docker-in-Docker 时,还要区分 runner 容器与 job 容器的网络:
runner 能解析 `gitea`,不代表 job 也能。若 checkout 的 `git fetch` 报
`Could not resolve host: gitea`,先检查 job 容器能否访问 Gitea,而不只是检查 runner。
本地测试可在 Runner 配置的 `container:` 下设 `network: host`,通过 Compose
挂载该配置、设置 `CONFIG_FILE` 并重建 runner,见 [本地 Compose 示例](local-ci/compose.yaml)。
这里的 `host` 指 runner 容器所在的网络命名空间,不是宿主机网络;job 与 runner
共用网络会降低隔离性,只适合可信的本地测试,不应直接当作生产环境通用方案。
## 参考
- Gitea Actions 快速上手:<https://docs.gitea.com/usage/actions/quickstart/>
- act_runner 注册:<https://docs.gitea.com/runner/registration/>
- act_runner 标签:<https://docs.gitea.com/runner/labels/>
@@ -0,0 +1,88 @@
# 本地单机 Gitea + runner(DinD)测试环境,仅用于本机可信代码测试。
# 配套笔记:上级目录《Gitea 简单 CI 实践》——让其中的 demo workflow
# (runs-on: demo-ci、checkout@v4、Node 22)不依赖云服务器即可本机跑通。
#
# ── 使用步骤(命令均从仓库根目录执行;若已 cd 进本目录,可去掉 -f 直接 `docker compose up -d`)──
#
# 1. 生成并导出注册令牌(只放在当前 shell 里,切勿写进仓库或 .env 文件):
# export GITEA_RUNNER_REGISTRATION_TOKEN="$(openssl rand -hex 32)"
# Gitea >= 1.23:在 Gitea 首次初始化(完成安装向导)之前设置该变量,
# 它会被固定为 runner 注册令牌;实例已初始化后再设置不会改变令牌,
# 此时以 管理后台/仓库 → Actions → Runners 页面显示的令牌为准。
#
# 2. 启动(首次运行会拉取两个镜像):
# docker compose -f note/资源/DevOps/Gitea/local-ci/compose.yaml up -d
#
# 3. 打开 http://localhost:3000 完成首次安装(数据库保持默认 SQLite 即可)。
# 若 runner 早于安装完成启动,注册会失败并按 restart 策略自动重试,
# 安装完成后无需手动干预,稍等即可看到 runner 在线。
#
# 4. 注册/登录 → 新建仓库 → 仓库 设置 → Actions 启用;
# 按《Gitea 简单 CI 实践》加入 demo.js、demo.test.js 与
# .gitea/workflows/ci.yml。本机 git 克隆/推送走 HTTP 协议:
# git clone http://localhost:3000/<owner>/<repo>.git
#
# 5. 仓库 Actions 页查看 run;需要排查 runner 时:
# docker compose -f note/资源/DevOps/Gitea/local-ci/compose.yaml logs runner
#
# 6. 账号、仓库、runner 注册与镜像缓存在三个命名卷里,保留卷即保留数据;
# 彻底重来:docker compose -f note/资源/DevOps/Gitea/local-ci/compose.yaml down -v
#
# ── 安全提醒 ──
# runner 容器 privileged: true(DinD 必需),权限等同宿主 root;
# 本文件只用于本机可信仓库的本地测试,勿用于不可信仓库或对外提供服务。
services:
gitea:
image: docker.gitea.com/gitea:latest
restart: unless-stopped
environment:
# 单机演示固定 SQLite,数据随 gitea-data 卷持久化
GITEA__database__DB_TYPE: sqlite3
# 页面/克隆地址显示为 localhost,方便本机浏览器与 git 客户端
GITEA__server__ROOT_URL: http://localhost:3000/
# 与 runner 用同一个固定注册令牌(含义见文件头第 1 步)
GITEA_RUNNER_REGISTRATION_TOKEN: "${GITEA_RUNNER_REGISTRATION_TOKEN:?set GITEA_RUNNER_REGISTRATION_TOKEN}"
ports:
# 只绑定回环地址,不暴露到局域网;git 走 HTTP,无需映射 SSH 端口
- "127.0.0.1:3000:3000"
volumes:
- gitea-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
runner:
image: docker.io/gitea/runner:3-dind
restart: unless-stopped
# DinD:job 容器跑在 runner 内部的嵌套 Docker daemon 里,需要 privileged
privileged: true
depends_on:
gitea:
condition: service_healthy
environment:
CONFIG_FILE: /config.yaml
# 注册与克隆都发生在 runner 容器自身,它在 compose 网络内可直连 gitea 服务
GITEA_INSTANCE_URL: http://gitea:3000
GITEA_RUNNER_NAME: local-demo
# 与 demo workflow 的 runs-on: demo-ci 一致;node:22-bookworm 提供 Node 22
GITEA_RUNNER_LABELS: demo-ci:docker://node:22-bookworm
GITEA_RUNNER_REGISTRATION_TOKEN: "${GITEA_RUNNER_REGISTRATION_TOKEN:?set GITEA_RUNNER_REGISTRATION_TOKEN}"
volumes:
- ./runner-config.yaml:/config.yaml:ro
# runner 状态(含 .runner 注册文件)
- runner-data:/data
# 嵌套 dockerd 的镜像与构建缓存
- runner-docker:/var/lib/docker
# DinD 默认给 job 建独立网络;checkout 的 git fetch 会访问 http://gitea:3000,
# job 若无法解析 gitea 就会失败,即使 runner 容器能解析也没用。
# runner-config.yaml 设置 container.network: host,使 job 共用 runner 容器的
# 网络命名空间(不是宿主机网络);隔离性随之降低,仅限本地可信测试。
volumes:
gitea-data:
runner-data:
runner-docker:
@@ -0,0 +1,302 @@
# Every option with its default value, all commented out. Read this file, do not copy it.
# `./gitea-runner config init` writes a config file to copy the lines you change into.
# Logging for the runner process itself (messages printed to stderr), plus the copy of
# each task's log kept under log.job. Neither controls how workflow step output is streamed
# to the Gitea UI; tune that with runner.log_report_* below.
log:
# logrus severity: trace, debug, info, warn, error, fatal, panic.
# trace and debug turn on caller/file:line in log lines. Default if omitted: info.
#level: info
# Write a copy of each task's log to dir as <start time>-task-<id>.log, so a job's output
# survives a failure to send it to Gitea. A path turns them on, empty turns them off.
# retention is how long a log is kept (0s keeps all), max_size caps one log (0 is no limit).
#job:
# dir: ""
# retention: 168h
# max_size: 1GB
runner:
# Where to store the registration result.
#file: .runner
# Execute how many tasks concurrently at the same time.
# With `container.network` empty, each concurrent docker job takes a subnet from the
# daemon's address pool, so a high capacity can exhaust it. See `default-address-pools`
# in the docker daemon config.
#capacity: 1
# Extra environment variables to run jobs.
#envs:
# A_TEST_ENV_NAME_1: a_test_env_value_1
# A_TEST_ENV_NAME_2: a_test_env_value_2
# Extra environment variables to run jobs from a file.
# It will be ignored if it's empty or the file doesn't exist.
#env_file: .env
# The timeout for a job to be finished.
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
# So the job could be stopped by the Gitea instance if its timeout is shorter than this.
#timeout: 3h
# The timeout for the runner to wait for running jobs to finish when shutting down.
# Any running jobs that haven't finished after this timeout will be cancelled.
#shutdown_timeout: 0s
# Whether skip verifying the TLS certificate of the Gitea instance.
#insecure: false
# The timeout for fetching the job from the Gitea instance.
# Values above the 60s RPC timeout are capped to it.
#fetch_timeout: 5s
# The interval for fetching the job from the Gitea instance.
#fetch_interval: 2s
# The maximum interval for fetching the job from the Gitea instance.
# The runner uses exponential backoff when idle, increasing the interval up to this maximum.
# Set to 0 or same as fetch_interval to disable backoff.
#fetch_interval_max: 5s
# While idle, remove stale bind-workdir task directories and orphaned host-mode
# scratch directories (left behind when a host cleanup delete stalls) older than
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
# (or any non-positive value) disables stale-directory cleanup entirely, along with
# the docker network and volume cleanup below.
#workdir_cleanup_age: 24h
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use
# docker it removes the per-job networks and volumes of jobs this runner did not live to
# tear down, which would otherwise hold a subnet of the daemon address pool and the job's
# workspace contents until the host is rebuilt.
#idle_cleanup_interval: 10m
# The base interval for periodic log flush to the Gitea instance.
# Logs may be sent earlier if the buffer reaches log_report_batch_size
# or if log_report_max_latency expires after the first buffered row.
#log_report_interval: 5s
# The maximum time a log row can wait before being sent.
# This ensures even a single log line appears on the frontend within this duration.
# Must be less than log_report_interval to have any effect.
#log_report_max_latency: 3s
# Flush logs immediately when the buffer reaches this many rows.
# This ensures bursty output (e.g., npm install) is delivered promptly.
#log_report_batch_size: 100
# The interval for reporting task state (step status, timing) to the Gitea instance.
# State is also reported on step transitions (start/stop) and after each log flush.
#state_report_interval: 5s
# Per-attempt deadline for flushing the final logs and task state when a job
# finishes, on a detached context so a server cancel can't block the acknowledgement.
#report_close_timeout: 10s
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
# and github_mirror is not empty. In this case,
# it replaces https://github.com with the value here, which is useful for some special network environments.
#github_mirror: ''
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
# Set to false to clone the full history.
#action_shallow_clone: true
# When true (the default), inject the ACT=true environment variable into jobs.
# Set to false so workflows gated on `if: ${{ !env.ACT }}` behave like they do on GitHub.
#set_act_env: true
# When true (the default), apply compatibility patches to the actions a job runs, so actions
# written for GitHub work against this instance. Set to false to run them exactly as published,
# at the price of the stock artifact actions refusing and the cache client keeping to v1.
#patch_actions: true
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
# If it's empty when registering, it will ask for inputting labels.
# If it's empty when execute `daemon`, will use labels in `.runner` file.
#labels:
# - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
# - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
# Default false matches GitHub actions/runner. Enable only for jobs that need an interactive
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
# when a TTY is present.
#allocate_pty: false
# Image for a job whose runs-on matches none of the labels above. A runner without a
# docker daemon runs such a job on the host instead.
#default_image: "docker.gitea.com/runner-images:ubuntu-latest"
# What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools:
# none: nothing. A docker job sees what its image ships there, a host job an empty dir, and
# either way what it installs is gone when the job ends.
# shared: one tool cache every job reuses. Two jobs writing the same tool version at once
# corrupt it, so use it only with capacity 1.
#tool_cache_mode: none
# Optional executable on the host, run once after each task's built-in cleanup
# (post-steps, container teardown, bind-workdir removal). Additive only.
#
# IMPORTANT: While this script runs the runner stops task heartbeats and stays
# offline from Gitea's perspective until the script exits. A script that never
# returns blocks new work until post_task_script_timeout kills it (default 5m).
# Keep scripts short; set post_task_script_timeout to a safe upper bound.
#
# Output -> runner process log (not the job log). Non-zero exit -> warning only.
# Windows: use .exe, .bat, or .cmd. PowerShell (.ps1) is not supported yet as
# the configured path; wrap PowerShell commands in a .cmd file instead.
# Full guide: docs/post-task-script.md
#post_task_script: ''
# Hard limit on post_task_script runtime. Default if omitted: 5m.
#post_task_script_timeout: 5m
# Scripts run inside the job environment before the job's first step and after its last
# one, the equivalent of GitHub's ACTIONS_RUNNER_HOOK_JOB_STARTED and
# ACTIONS_RUNNER_HOOK_JOB_COMPLETED, which are read when these are unset. The paths are
# resolved inside the job environment. Either one failing fails the job.
# Full guide: docs/job-hooks.md
#hooks:
# job_started: ''
# job_completed: ''
cache:
# Enable caching (used by actions/cache and similar actions). Off means no built-in server is
# started and no job is registered with an external_server either.
#enabled: true
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
# Ignored when external_server is set.
#dir: ""
# Outbound IP or hostname that job containers use to reach this runner's cache server.
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
# If the runner itself runs in Docker, automatic detection can choose an
# address on the runner container's network that job containers cannot reach
# when the runner creates a separate per-job network. In that case, set this
# to a hostname/IP reachable from job containers, and set port to a fixed
# published port or put the job containers on a shared Docker network.
# Ignored when external_server is set.
#host: ""
# Port for the built-in cache server. 0 picks a random free port.
# Ignored when external_server is set.
#port: 0
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
# Set on every runner that should share a cache pool. A trailing slash is optional.
# Jobs reach the server at this URL too, so set it to the reverse proxy when one fronts the server.
# Example: "http://cache-host:8088/"
# Requires external_secret (below) to match the value on the cache-server.
#external_server: ""
# Shared secret between this runner and the external cache-server.
# Required when external_server is set. Must be identical on every runner and the cache-server.
# Generate with: openssl rand -hex 32
#external_secret: ""
# Path to a file containing the shared secret, as an alternative to external_secret.
# Use this to keep the secret out of this file.
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
# Setting both external_secret and external_secret_file is an error.
#external_secret_file: ""
# When true, reuse a cached action instead of fetching from the remote on every job.
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
# until its cache entry expires or is manually removed.
#offline_mode: false
# Serve the actions cache service v2 API. The actions that use it fall back to v1 on any host
# they do not take for GitHub, so reaching it means editing that check out of their own bundle,
# put back after the copy into the job. That edit is made either way, this only governs the API
# advertised. A bundle that does not match is left alone. With v2, artifact uploads go via the
# cache server unless container jobs cannot reach its Docker network.
#v2: true
# How the cache server discards entries, ignored when external_server is set since that
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three
# limits off. Sizes accept 10GB, 512mb, 1TiB or a plain byte count, binary either way.
# Whatever these allow, the cache still sheds entries to keep free space on its volume
# above health_check.min_free_disk_space_mb when health checks are enabled.
# Remove entries nothing has read or written within this window. Only last access counts.
#retention: 168h
# Cap one repository, removing its least recently accessed entries until it fits. An entry
# larger than the limit is dropped rather than emptying the repository to make room.
#repo_size_limit: 10GB
# Cap the whole cache the same way. Off by default, since the free space floor bounds it.
#size_limit: 0
# Minimum time between two eviction sweeps. This one has no "off".
#sweep_interval: 1h
container:
# Specifies the network to which the container will connect.
# Could be host, bridge or the name of a custom network.
# If it's empty, runner will create a network automatically.
# For dockerized runners using the built-in cache server, a custom shared
# network can be required so job containers can reach cache.host/cache.port.
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
network: host
# network_create_options only apply when `network` is left empty and the runner
# auto-creates a per-job network that does not already exist. They have no effect
# when a custom `network` name is set, because that network is used as-is and never
# created by the runner. Omit the entire block to use Docker's defaults. An auto-created
# network is labelled com.gitea.runner.uuid=<this runner's uuid>, which is how the idle
# cleanup tells its own leftovers apart from those of other runners on the same daemon.
#network_create_options:
# enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
# enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
#privileged: false
# Any other options to be used when the container is started, for example:
# options: --add-host=my.gitea.url:host-gateway
# A volume declared here replaces the one the runner would mount on the same container path, so the
# tool cache can be kept on the host. Its source must also be allowed by valid_volumes below:
# options: --volume /host/toolcache:/opt/hostedtoolcache
# With privileged disabled, options that could escape the container (--security-opt, --device,
# --cap-add, --pid, ...) are ignored in a workflow's container.options, but keep working here.
#options:
# The parent directory of a job's working directory.
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
# If the path starts with '/', the '/' will be trimmed.
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
# If it's empty, /workspace will be used.
# Purely numeric subdirectories under this path are reserved for task workspaces and may be removed by idle cleanup.
#workdir_parent:
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
# valid_volumes:
# - data
# - /src/*.json
# If you want to allow any volume, please use the following configuration:
# valid_volumes:
# - '**'
#valid_volumes: []
# Overrides the docker client host with the specified one.
# If it's empty, runner will find an available docker host automatically.
# If it's "-", runner will find an available docker host automatically, but won't mount it into the job and action containers.
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
#docker_host: ""
# Pull docker image(s) even if already present.
# Defaults to false when the key is omitted.
# Two exceptions: an image pinned by digest (image@sha256:...) cannot change, so it is never
# re-pulled, and a pull that fails while a copy is already on the host does not fail the job,
# which runs on that copy with a warning in its log.
#force_pull: false
# Rebuild docker image(s) even if already present
#force_rebuild: false
# Always require a reachable docker daemon, even if not required by runner
#require_docker: false
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
#docker_timeout: 0s
# Mount the workspace from a host directory instead of a Docker volume, so jobs
# can bind-mount it by its own path into sibling containers (".:/app" in docker
# compose). Not needed when jobs get the Docker proxy or workflows use the
# GITEA_DOCKER_WORKSPACE variable.
# The workspace parent directory must be mounted into the runner container.
#bind_workdir: false
# How long a job waits for a service container that declares a healthcheck to become
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
#service_ready_timeout: 5m
host:
# The parent directory of a job's working directory.
# If it's empty, $HOME/.cache/act/ will be used.
#workdir_parent:
# Optional local task-admission checks. Disabled by default. When enabled, low
# disk space or a failing script pauses new task fetching; existing jobs continue.
# No health checks run while any job is active; the last result is reused until idle.
health_check:
#enabled: false
# Minimum free space required on the filesystem holding runner workspaces.
# Defaults to 1024 MiB when omitted or set to zero.
#min_free_disk_space_mb: 1024
# Optional additional executable. A non-zero exit, timeout, or startup failure
# marks the runner unavailable.
#script: ''
# How long a script result is cached and its maximum execution time.
#interval: 30s
#timeout: 10s
metrics:
# Enable the Prometheus metrics endpoint.
# When enabled, metrics are served at /metrics, liveness at /healthz, and
# task-admission readiness at /readyz.
#enabled: false
# The address for the metrics HTTP server to listen on.
# Defaults to localhost only. Set to ":9101" to allow external access,
# but ensure the port is firewall-protected as there is no authentication.
#addr: "127.0.0.1:9101"
# Consecutive polling failures may last this long before /readyz returns 503.
#readiness_grace: 30s
+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`,并排查系统中已有的同名环境变量 |
遇到对象已存在的问题,不要直接删除现有数据,也不要手工插入版本记录来伪造迁移状态。
+42
View File
@@ -0,0 +1,42 @@
# VCS
.git
.gitignore
.gitattributes
# Dependencies & build artifacts (rebuilt inside the image)
node_modules
.next
out
coverage
.vercel
.turbo
# Env files & secrets — never ship credentials into the build context
.env
.env.*
!.env.example
*.pem
*.key
*.p12
credentials*.json
secrets*
# Logs & OS noise
*.log
npm-debug.log*
pnpm-debug.log*
yarn-error.log*
.DS_Store
Thumbs.db
# Docker files themselves
Dockerfile
.dockerignore
# IDE & editor
.idea
.vscode
# Non-build project content
docs
.github
+39
View File
@@ -0,0 +1,39 @@
# ---------- deps: install workspace dependencies (keeps patchedDependencies) ----------
FROM node:22.23.2-bookworm AS deps
WORKDIR /app
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
# ---------- builder: build the standalone Next.js output ----------
FROM node:22.23.2-bookworm AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 \
COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches ./patches
COPY . .
RUN pnpm run build
# ---------- runner: minimal runtime with standalone server ----------
FROM node:22.23.2-bookworm AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+16
View File
@@ -0,0 +1,16 @@
# Polaris 部署环境变量示例 —— 复制为同目录 .env 后填入真实值:
# cp .env.example .env
#
# 安全约定:
# - 本文件只放镜像引用;任何 registry token、密码都不要写进这里或本示例文件
# - CI 推送用 secrets POLARIS_REGISTRY_USER / POLARIS_REGISTRY_TOKEN(写权限),
# 只配置在 Gitea Actions secrets 中;宿主机登录另用只读 token(见 README.md 安全提醒)
# 固定到具体 digest 的镜像引用(内容寻址,可精确回退)。
# 镜像由 CI 校验通过后推送:git.labmem.icu/<repo-owner>/polaris:<full-commit-sha>
# <repo-owner> 为 Gitea 用户/组织名;<64-hex-digest> 为该次推送对应的 sha256 摘要,
# 获取与校验方法见 README.md「如何确认真实 digest」。
# 建议保留来源与上一版注释行,便于追溯与回退:
# # 来源:CI run <run-id> / commit <full-commit-sha>
# POLARIS_IMAGE=git.labmem.icu/<repo-owner>/polaris@sha256:<old-64-hex-digest>
POLARIS_IMAGE=git.labmem.icu/<repo-owner>/polaris@sha256:<64-hex-digest>
+200
View File
@@ -0,0 +1,200 @@
# Polaris 部署(第一阶段:CI 出镜像,宿主机手动部署 digest)
本目录提供在服务器上以**独立 Compose 项目**运行 Polaris 的最小配置。
第一阶段流程:仅当推送 `polaris-v*` 标签时才运行 CI(Gitea Actions)整条验证+发布
工作流——`verify` job 通过校验(lint / typecheck / test / build / standalone 冒烟)后,
`publish` job 构建镜像 `git.labmem.icu/<repo-owner>/polaris:<full-commit-sha>`(完整
commit SHA 作 tag)并**自动推送**到 Gitea registry,从 push 日志解析输出 digest;
分支/路径推送不触发 CI。宿主机(服务器)首次部署时用**只读**凭据 `docker login`,
在 `.env` 中以 digest 固定该镜像,再 `pull + up` 单服务运行。
## 文件说明
| 文件 | 用途 |
|---|---|
| `compose.yaml` | 独立项目 `polaris` 的服务定义(单服务,仅外部网络 `net-public`) |
| `.env.example` | 环境变量示例,复制为同目录 `.env` 并填入真实镜像 digest |
设计约束:
- 镜像来源:CI 仅在 `polaris-v*` 标签触发的发布中,以完整 commit SHA 为 tag 推送;
部署侧约定在 `.env` 写 `...@sha256:<digest>` 固定镜像,杜绝 latest 漂移;
`compose.yaml` 的 `image: ${POLARIS_IMAGE:?...}` 只校验变量非空,不校验 digest 格式
- 只加入外部网络 `net-public`(与 nginx 同网);**不连** `net-db`
- 不映射宿主机端口(无 `ports:`),仅通过 nginx 反代访问
- 无 DB/Redis、无 secrets、无源码/数据卷挂载
- 应用在镜像内已是 `node` 非 root 用户,监听 3000(见仓库 `Dockerfile`,本目录不改镜像与代码)
- `HEALTHCHECK` 用运行时 Node 原生 `fetch` 探测 `/`,不要求 curl 等额外工具
- 顶层 `name: polaris` 固定项目名,避免项目路径/命名歧义
`<repo-owner>` 为 Gitea 用户/组织名(`compose.yaml` 报错提示文本中的 `<owner>` 同义)。
## 前置条件(须预先就绪)
一次性、由 Gitea 仓库/实例管理员完成:
1. **registry 已启用且支持推送**(否则 CI push 会失败)。
2. **Gitea Actions secrets 已配置**:`POLARIS_REGISTRY_USER`、`POLARIS_REGISTRY_TOKEN`,
对应具备**镜像推送(写)权限**的账号/token。secrets 必须预先在仓库(或组织)设置中
填好——CI 不会也不能替你创建。
3. **宿主机另备一个只读(拉取)token**:与 CI 写权限**分离**,只在服务器 `docker login` 使用。
以上三项均为目标要求,**现状尚未验证**;不满足时先补齐再走首次发布。
## 待现场验证(不要假设,逐项落实)
- workflow 的 build/push 与 digest 输出逻辑已定稿:推送步骤从 push 日志解析
`digest: sha256:<64 位十六进制>`,回显「镜像摘要」与手动部署引用,解析失败即 job 失败;
**待现场验证**的是真实 Gitea registry / runner 下 push 输出能否匹配该解析
- registry / runner 实际行为:登录、推送、按 digest 拉取
- 生产 Nginx 行为(见「Nginx 反代」)
- 服务器上 `net-public` 网络名、既有 Compose 项目与目录布局
(以 `docker network ls`、`docker compose ls` 实际输出为准)
- Nginx 配置未提供:不要在本目录猜测域名,也不要改动服务器 Nginx 文件
## 首次发布步骤
### 1) 推送标签触发 CI,记录镜像与 digest
```bash
# 推送 polaris-v* 标签触发整条工作流(分支/路径推送不触发 CI),例如:
git tag polaris-v0.1.0 && git push origin polaris-v0.1.0
# 在 Gitea 网页打开 Actions 运行页,等待 verify 与 publish job 全绿
```
- 确认全部 job 通过后,从该次运行日志的**推送步骤**记录:
- 镜像:`git.labmem.icu/<repo-owner>/polaris:<full-commit-sha>`
- digest:推送步骤解析成功时直接回显「镜像摘要:sha256:<digest>」与
`docker pull ...@sha256:<digest>` 手动部署引用;实际输出以日志为准,
若未给出,用下文「如何确认真实 digest」的方法在部署前核对
- 记下对应 commit sha 与 run 编号,便于追溯。
### 2) 拷贝示例到服务器,并确认现状与项目路径
```bash
# 仓库机器上(主机/路径按实际调整):
rsync -av ts/web/nextjs/polaris/deploy/ admin@<server>:/home/admin/docker/lab/polaris/
```
```bash
# 服务器上:
docker compose version # 需 Compose v2(docker compose 子命令)
docker network ls # 确认 net-public 存在(名字以实际为准)
docker compose ls # 查看既有项目,避免目录/文件名冲突
ls -la /home/admin/docker/lab
```
**Compose 项目路径注意事项:**
- 推荐把文件放进 `/home/admin/docker/lab/polaris/` 子目录,与既有栈完全隔离;
若 `/home/admin/docker/lab` 下已存在 `compose.yaml` / `docker-compose.yaml`,
**切勿覆盖**,必须使用子目录。
- `.env` 必须与 `compose.yaml` 同目录(Compose 从项目目录自动加载它)。
- `compose.yaml` 已用顶层 `name: polaris` 固定项目名;仍建议 `cd` 到该目录再执行命令,
保证 `.env` 加载与 down/up 指向同一项目。
### 3) 宿主机登录 registry(只读 token)
```bash
docker login git.labmem.icu -u <registry用户名>
# 交互输入只读 token;不要把 token 写进命令行参数/历史
```
或非交互(输入不回显、不进历史):
```bash
read -rs REGISTRY_TOKEN # 粘贴只读 token 后回车
printf '%s' "$REGISTRY_TOKEN" | docker login git.labmem.icu -u <registry用户名> --password-stdin
unset REGISTRY_TOKEN
```
凭据会存于当前用户 `~/.docker/config.json`(base64 明文),见「安全提醒」。
### 4) 准备 .env 并按 digest 部署(单服务)
```bash
cd /home/admin/docker/lab/polaris
cp .env.example .env
# 编辑 .env:POLARIS_IMAGE=git.labmem.icu/<repo-owner>/polaris@sha256:<digest>
# 注释行保留来源 tag/commit 与上一版 digest,便于追溯与回退
docker compose pull polaris
docker compose up -d polaris
docker compose ps
docker inspect -f '{{.State.Health.Status}}' polaris # 等待变为 healthy
```
### 5) 健康与反代验证
```bash
# 容器内自测(镜像内无 curl,用 node):
docker exec polaris node -e "fetch('http://127.0.0.1:3000/').then(r=>{console.log(r.status);process.exit(r.ok?0:1)})"
# 若 nginx 容器内带 wget/curl,可从 net-public 内验证服务名连通性:
docker exec <nginx容器名> wget -qO- http://polaris:3000/ >/dev/null && echo ok
```
## Nginx 反代(配置在服务器,本仓库不改)
- 上游地址:`http://polaris:3000` —— `polaris` 是其在 `net-public` 内的
服务/DNS 名(compose 已设 `container_name: polaris` 保证名字稳定)。
- 域名、server 块、TLS 以服务器实际配置为准;本目录不猜测、不代改 Nginx 文件。
- **重建后注意 DNS/IP**:Nginx 对静态 `proxy_pass http://polaris:3000;` 只在载入配置时
解析一次;polaris 容器重建后其在 `net-public` 内的 IP 可能变化,
需要重启/reload Nginx(在其所属 Compose 项目内操作),或在其配置中改用
`resolver 127.0.0.11 valid=10s;` 的变量式动态上游——是否改造由服务器 Nginx 配置决定。
- 验证:从外部按实际域名请求一次,确认经反代返回 Polaris 页面。
- 生产 Nginx 的上述行为**未经实测,待现场验证**。
## 升级与回退
升级(**无零停机承诺**):为要发布的新 commit 打新的 `polaris-v*` 标签触发 CI,
运行**全绿**并取得新 digest 后,把新 digest 写入 `.env` 的 `POLARIS_IMAGE`,
旧值保留为注释行,然后:
```bash
docker compose pull polaris && docker compose up -d polaris
```
`up -d` 是停旧建新,存在秒级中断;健康检查只表示容器自检通过,
不会自动把坏版本隔离在流量之外。
回退:
- **有旧版本**:把 `.env` 中 `POLARIS_IMAGE` 换回上一个 digest(注释行),再次 `pull + up -d`。
- **首发失败且无旧版本可回退**:在 polaris 项目目录执行 `docker compose down`
(只移除本项目的容器与网络引用,不影响 nginx / db 等既有项目)。
## 如何确认真实 digest
按可靠程度排序,最终以「按 digest 拉取成功」为准:
- **CI 运行日志**:workflow 已实现 digest 输出——推送步骤解析 push 日志后回显
「镜像摘要」与 `docker pull` 手动部署引用;真实 registry / runner 下输出是否符合预期
**待现场验证**,以实际日志为准。
- **部署前交叉核对**(任一已登录该 registry、装有 buildx 的机器):
`docker buildx imagetools inspect git.labmem.icu/<repo-owner>/polaris:<full-commit-sha>`
查看该 tag 当前指向的 manifest(列表)摘要。
- **服务器最终校验**:`docker pull git.labmem.icu/<repo-owner>/polaris@sha256:<digest>`
输出 `Digest: sha256:...` —— 按 digest 拉取本身就是内容寻址校验,
两者不一致不可能拉取成功。
- 已拉取过 tag 的机器可查本地记录的远端摘要:
`docker image inspect --format '{{index .RepoDigests 0}}' git.labmem.icu/<repo-owner>/polaris:<full-commit-sha>`。
- 注意:`.env` 固定的是 manifest(列表)digest;多架构镜像按 digest 拉取时由运行时
选择本平台对应的 manifest。workflow 用单条 `docker build` 构建单平台镜像
(平台取决于 runner 架构),是否覆盖服务器架构**待现场验证**。
- commit sha tag 正常不应被重复推送;若发现同名 tag 的 digest 变化,
先排查 CI 再部署,不要直接沿用旧记录。
## 安全提醒(registry token 读写分离)
- **CI 写**:`POLARIS_REGISTRY_USER` / `POLARIS_REGISTRY_TOKEN` 只存在于
Gitea Actions secrets,具备推送(写)权限;绝不写进仓库、README、命令或日志。
- **宿主读**:服务器 `docker login` 只用只读(拉取)token;尽量范围最小、时效最短。
token 实际权限粒度以 Gitea registry 实现为准(**待现场验证**)。
- 登录凭据以 base64 明文存于该用户 `~/.docker/config.json`,注意宿主机账号权限;
拉取完成后可 `docker logout git.labmem.icu` 清除。
- 本目录所有命令均为占位示例,不含任何真实 secret;`.env` 只放镜像引用,不放凭据。
- 若在仓库中发现疑似真实凭据:先停止操作,只报告路径、行号与类型(不复述值),
随后脱敏/移除并轮换该凭据。
+39
View File
@@ -0,0 +1,39 @@
# Polaris 独立 Compose 项目(第一阶段:宿主机手动发布)。
#
# 设计约束(详见 README.md):
# - 镜像由 .env 的 POLARIS_IMAGE 以 sha256 digest 固定,缺失时 compose 直接报错
# - 只加入外部网络 net-public(与 nginx 同网);不连 net-db
# - 不映射宿主机端口(无 ports:),仅经 nginx 反代访问
# - 无 DB/Redis、无 secrets、无源码/数据卷挂载
# - 应用在镜像内已是 node 非 root,监听 3000(见仓库 Dockerfile,本目录不改其行为)
# - HEALTHCHECK 用运行时 Node 原生 fetch 探测 /,不要求 curl 等额外工具
# - 顶层 name 固定项目名为 polaris,规避项目路径/命名歧义
name: polaris
services:
polaris:
# 必填:digest 固定的镜像引用;未设置时 compose 加载即失败并给出提示
image: ${POLARIS_IMAGE:?set POLARIS_IMAGE in .env, e.g. git.labmem.icu/<owner>/polaris@sha256:<digest> — see deploy/README.md}
container_name: polaris # net-public 内的 DNS 名,nginx 上游即 http://polaris:3000
restart: unless-stopped
# expose 仅作声明用途,不是端口映射;同网络容器本就可直接互访
expose:
- "3000"
healthcheck:
# 运行时镜像只有 node,不假设 curl:Node 22 自带全局 fetch
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:3000/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- net-public
networks:
net-public:
external: true # 由服务器既有栈提供;名字以 docker network ls 实际输出为准,勿在本文件创建
@@ -5,6 +5,17 @@
> 状态:用户已批准,可进入实施
>
> 实现分支:`feat/polaris-homepage`
>
> 历史状态(2026-10-14 补记):本规格是首页阶段的实施依据,正文(含范围、视觉与验收要求)保留 2026-08-26 时点的决策原貌,不作逐行改写;后续开发已推进多项内容,部分条目不再代表现状,见下方「当前实现索引」。
## 当前实现索引(2026-10-14 补记,非原规格内容)
- **视觉**:深色玻璃主题为当前默认外观(无主题切换),设计 tokens 见 `src/app/globals.css`;第 8.1 节的「CSS 低饱和青蓝渐变背景」已由可替换的背景素材层 + 深色遮罩取代,样式由现有 CSS 管理,素材类型/路径/画面不作设计规范。
- **移动端**:第 2.2 节「本次不实现」中的「移动端与平板布局」已完成——≤767px 时侧边栏变为顶部横栏,其余断点适配见两份设计文档的第 0 章;「深色模式」以深色玻璃默认主题的形式落地(无切换开关)。
- **数据**:第 7.1 节的「7 个分类 / 20 条链接 / 8 条 pinned」为当时规模;当前为 8 个分类、51 条链接、6 条 pinned。
- **分类页**:第 2.2 节「正式分类页」与第 3 节「分类导航临时禁用」已被后续工作取代——分类页已在 `/categories/[categoryId]` 实现并启用导航。
- **设置**:`settings.theme`、`settings.background` 仍仅有数据模型与校验,不驱动样式;`settings.search` 与 `settings.sidebar` 已接入。
- 其余章节(目录结构、交互、测试与验收)保留原貌,作为首页阶段的历史验收依据。
## 1. 背景与目标
@@ -4,6 +4,17 @@
> [Polaris 项目说明文档](./Polaris%20项目说明文档.md) —— 项目定位、技术选型、数据结构、开发阶段
> [Polaris 首页设计文档](./Polaris%20首页设计文档.md) —— 首页布局与搜索交互
## 0. 当前实现状态(2026-10-14 校对)
本章为现状说明。第 1–23 章保留立项时的历史设计意图;其中浅色玻璃样式、白色大面板容器和数据示例均为历史方案,视觉细节以本章与 `src/app/globals.css` 为准。
- **路由**:`/categories/[categoryId]` 已实现,8 个分类全部静态预生成,未知分类返回 404。
- **布局**:左侧边栏与首页一致(桌面固定悬浮;≤767px 变为顶部横栏,折叠按钮隐藏、导航横向滚动)。主内容区**不再是一整块白色玻璃面板**——面板容器透明,使背景素材保持清晰,玻璃只压在承载文字的元素上:标题与描述带轻边框和文字投影,「全部工具」列表放在一层薄纱视口(rgba(5, 14, 29, 0.18)、圆角 20px)内,工具卡片为深色玻璃。
- **滚动**:桌面端页面高度固定为 `100svh`,工具列表在内部视口滚动(`overscroll-behavior: contain`、可聚焦);视口过矮(`max-height` 断点)或移动端回退为整页滚动。
- **网格现状**:工具网格基础为两列;≥1280px 升为三列,≤999px 降为单列,即 1000–1279px 保持两列。手机顶部横栏与单列已完成;平板/中等窗口的两列已实现,与历史计划(768px–1279px 两列)的差异仅剩 768px–999px 区间当前同为单列。
- **视觉**:深色玻璃主题,tokens 与首页共用(见[首页设计文档](./Polaris%20首页设计文档.md)第 0 章);背景素材可整体替换,背景层显示与遮罩样式由现有 CSS 管理,素材类型/路径/画面不作为设计规范。
- **数据现状**:全站 8 个分类共 51 条链接;本文以「开发工具」为例的数据(15 条)为立项期示意,当前该分类实际 12 条。
## 1. 页面定位
Polaris 的分类页用于展示某一类导航资源,例如:
@@ -72,6 +83,8 @@ Figma
其他分类页面复用相同结构,只需要替换分类名称、描述和链接数据。
> 2026-10-14 说明:本节与第 12 节的链接清单、数量均为立项期示意数据,不代表当前 `src/data/` 内容。
---
## 3. 与首页的关系
@@ -198,6 +211,8 @@ background: rgba(99, 102, 241, 0.1);
color: #2563eb;
```
> 2026-10-14 实现状态:上方浅蓝紫数值为历史方案。当前选中态为 `--primary-soft`(rgba(147, 190, 255, 0.15))底色 + `--primary-hover` 文字色 + 一圈淡色内描边,圆角 16px 不变。
图标和文字同时高亮。
选中项圆角:
@@ -279,6 +294,8 @@ border-radius: 24px;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.1);
```
> 2026-10-14 实现状态:上方白色玻璃面板为历史方案。当前面板容器已透明化(见第 0 章):`GlassPanel` 结构保留,但边框、背景、阴影与 backdrop-filter 均被覆盖为透明或无,背景素材直接可见。
容器与页面边距:
```css
@@ -482,7 +499,7 @@ grid-template-columns: repeat(2, minmax(0, 1fr));
}
```
> 移动端单列布局为后续阶段工作,第一版仅适配桌面端与桌面窗口缩窄场景。
> 历史说明:立项时移动端单列为后续阶段工作。当前状态(2026-10-14):两列为基础布局,≥1280px 升为三列,≤999px(含手机及 768px–999px 窗口)降为单列;历史计划的「768px–1279px 两列」已基本实现,仅 768px–999px 区间为单列。
---
@@ -573,6 +590,8 @@ border-radius: 18px;
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.08);
```
> 2026-10-14 实现状态:上方浅色样式为历史方案。当前工具卡为深色玻璃 rgba(12, 24, 42, 0.65) + blur(10px),高 82px、圆角 18px,hover 提亮为 rgba(25, 43, 65, 0.84) 并轻微上浮。
Hover 状态:
```css
@@ -678,6 +697,8 @@ JavaScript 包管理器
## 12. 开发工具页数据示例
> 2026-10-14 说明:本节数据为立项期示例(15 条),不代表当前 `src/data/`;当前全站 8 类 51 条链接,开发工具分类 12 条。字段结构与现行 `NavLink` 类型一致,仍可作为格式参考。
### 12.1 分类数据
```json
@@ -955,11 +976,15 @@ Hover 时只做轻微反馈:
768px - 1279px:两列
```
当前实现(2026-10-14):两列为基础布局,≥1280px 三列、≤999px 单列;历史计划的「768px–1279px 两列」已基本实现,仅其中 768px–999px 区间当前为单列(见第 0 章)。
---
### 14.2 平板与移动端(后续阶段)
### 14.2 平板与移动端
第一版仅适配桌面端。平板与移动端适配为后续阶段工作,届时参考以下方向:
> 2026-10-14 实现状态:移动端适配已完成——≤767px 时侧边栏变为**常驻顶部横栏**(Logo + 横向滚动导航,非抽屉),工具卡片单列,短屏与移动端回退整页滚动;平板方向的「边栏宽度略小(220px)」与「工具卡片两列」已实现(两列覆盖 1000px–1279px,768px–999px 区间为单列),下列历史方向中的「抽屉式边栏」未采用。
立项时(仅适配桌面端)的参考方向:
```text
平板端:边栏宽度略小(200px - 220px),工具卡片两列
@@ -1201,6 +1226,8 @@ type CategoryPageData = {
侧边栏移动端适配
```
> 2026-10-14 实现状态:「平板两列」(1000px–1279px 区间,768px–999px 除外)、「移动端一列」与「侧边栏移动端适配」已完成(见第 0 章)。
---
### 第三阶段
@@ -4,7 +4,9 @@
> [Polaris 首页设计文档](./Polaris%20首页设计文档.md) —— 首页布局、搜索交互、视觉规范
> [Polaris 分类页设计文档](./Polaris%20分类页设计文档.md) —— 分类页布局、卡片、状态设计
>
> 配套资产:[占位 Logo](../../public/brand/logo.svg)(后续替换)、[Polaris 页面设计图](../assets/Polaris页面设计图.png)
> 配套资产:[站点 Logo](../../public/brand/logo.svg)、[Polaris 页面设计图](../assets/Polaris页面设计图.png)
> 本文以当前代码实现为准(2026-10-14 对齐)。早期决策中已被后续实现取代的条目在第 4 节单独标注为「历史」,不再代表现状。
## 1. 项目名称
@@ -20,15 +22,15 @@ Polaris 原意为“北极星”,具有方向、导航、定位、指引的含
Polaris 不是后台管理系统,也不是信息密集型 Dashboard。
> 一个具有个人审美、可自定义背景、可快速搜索和访问常用链接的浏览器起始页。
> 一个具有个人审美、可快速搜索和访问常用链接的浏览器起始页。
产品形态是**动态可配置的个人导航站**:第一版可以静态部署,数据写死在本地配置中,但页面具有动态交互(实时时钟、搜索、侧边栏折叠、快捷键等),后续将本地数据源替换为 Go 后端 API,不需要推翻前端整体结构。
产品形态是**个人导航站**:数据内置于本地 JSON 配置、随构建打包,页面具有动态交互(实时时钟、搜索、侧边栏折叠、快捷键等)。页面统一经 service 层取数,不依赖具体数据源形态;后续数据源迁移另行规划。
---
## 3. 项目目标
第一阶段目标:
第一阶段目标(已全部实现):
1. 提供一个美观的个人导航首页;
2. 支持快速访问常用链接;
@@ -36,40 +38,49 @@ Polaris 不是后台管理系统,也不是信息密集型 Dashboard。
4. 支持搜索框(Google / Bing / GitHub / 本地);
5. 支持当前时间与日期展示;
6. 支持左侧边栏折叠(状态持久化);
7. 支持背景自定义的设计预留;
8. 数据第一版写死在本地 JSON 数据配置中;
9. 代码结构预留未来接入 Go 后端接口的能力。
7. 数据内置于本地 JSON 配置,结构由 Vitest 校验测试兜底;
8. 页面组件统一经 service 层取数。
后续阶段目标:
背景自定义目前停留在数据模型与校验预留(`settings.background`),不驱动样式,见 6.3。
后续阶段目标(未实现):
1. 从 Go 后端 API 获取链接、分类、设置等数据;
2. 使用数据库持久化配置;
3. 支持后台或隐藏入口管理链接;
4. 支持用户自定义背景;
5. 支持主题色、深色模式、导入导出、移动端适配等个性化能力。
4. 让 `settings.theme` / `settings.background` 真正驱动界面,支持自定义背景与主题切换;
5. 支持导入导出等个性化能力。
---
## 4. 已确认的关键决策
以下决策已于 2026-08-21 确认,作为实施基线:
以下决策于 2026-08-21 确认,是当时的实施基线;标注「历史」的条目已被后续实现取代,以当前实现为准:
| # | 决策项 | 结论 |
| --- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| 1 | 技术栈 | 以本文档为准:Next.js + TypeScript + Tailwind CSS + shadcn/ui + lucide-react(早期 README 中 Astro 设想作废) |
| 2 | 渲染策略 | 静态导出(`output: 'export'`),时钟/搜索/侧边栏折叠为 client 岛屿 |
| 3 | 包管理器 | pnpm |
| 4 | 项目位置 | `ts/web/nextjs/polaris/` |
| 5 | Logo | 现有 `logo.svg` 仅作占位,后续替换,不投入设计精力 |
| 6 | 数据载体 | 本地 JSON 数据文件(`links.json` / `categories.json` / `settings.json`),结构以 Vitest 校验测试兜底 |
| 7 | 第一版数据范围 | 「开发工具」15 条 + 首页常用链接 8 条(跨分类条目归入真实分类,见首页设计文档 8.1);设计资源、学习资料、生活服务三个分类展示空状态 |
| 8 | 默认背景 | CSS 低饱和青蓝渐变,不依赖图片资产;`settings.background.type` 预留 `image` |
| 9 | 深色模式 | 第一版不做;CSS 变量按双主题友好组织,后续以 `[data-theme="dark"]` 覆盖 |
| 10 | 移动端 | 第一版不适配,仅桌面端 |
| 11 | 快捷键 | Ctrl+K(macOS ⌘K)聚焦搜索框,仅首页生效;提示按平台检测显示 |
| 12 | 图标方案 | 四级 fallback:自定义图标 → Simple Icons / 本地图标库 → favicon 自动抓取 → 文字占位(见 6.4) |
| 13 | 后端数据库 | PostgreSQL(后续阶段);表结构不锁死 PG 特有语法(JSON 存通用 TEXT) |
| 14 | 搜索行为 | 按模式严格分离:选中哪个引擎就只用哪个引擎搜索(回车直跳);仅 Local 模式显示本地结果下拉 |
| # | 决策项 | 结论 | 状态 |
| --- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---- |
| 1 | 技术栈 | Next.js + TypeScript + Tailwind CSS + shadcn/ui + lucide-react(早期 README 中 Astro 设想作废) | 现行 |
| 2 | 渲染策略 | 静态导出(`output: 'export'`),时钟/搜索/侧边栏折叠为 client 岛屿 | 历史 |
| 3 | 包管理器 | pnpm | 现行 |
| 4 | 项目位置 | `ts/web/nextjs/polaris/` | 现行 |
| 5 | Logo | `logo.svg` 不投入设计精力 | 现行 |
| 6 | 数据载体 | 本地 JSON 数据文件(`links.json` / `categories.json` / `settings.json`),结构以 Vitest 校验测试兜底 | 现行 |
| 7 | 第一版数据范围 | 「开发工具」15 条 + 首页常用链接 8 条;设计资源、学习资料、生活服务三个分类展示空状态 | 历史 |
| 8 | 默认背景 | CSS 低饱和青蓝渐变,不依赖图片资产;`settings.background.type` 预留 `image` | 历史 |
| 9 | 深色模式 | 第一版不做;CSS 变量按双主题友好组织,后续以 `[data-theme="dark"]` 覆盖 | 历史 |
| 10 | 移动端 | 第一版不适配,仅桌面端 | 历史 |
| 11 | 快捷键 | Ctrl+K(macOS ⌘K)聚焦搜索框,仅首页生效;提示按平台检测显示 | 现行 |
| 12 | 图标方案 | 四级 fallback:自定义图标 → Simple Icons / 本地图标库 → favicon 自动抓取 → 文字占位(见 6.4) | 现行 |
| 13 | 后端数据库 | PostgreSQL(后续阶段);表结构不锁死 PG 特有语法(JSON 存通用 TEXT) | 现行(未来项) |
| 14 | 搜索行为 | 按模式严格分离:选中哪个引擎就只用哪个引擎搜索(回车直跳);仅 Local 模式显示本地结果下拉 | 现行 |
历史决策的现行实现:
- **#2 渲染策略**:2026-10-12 部署阶段由静态导出改为 standalone 服务端输出,见 5.2。
- **#7 数据范围**:当前为 8 个分类共 51 条链接,全部分类均有内容,首页常用链接 6 条,见 9。
- **#8 默认背景**:当前视觉由 CSS 定义的深色玻璃表面与背景遮罩构成;背景素材为可替换内容,不作为设计规范,见 5.5。`settings.background` 未驱动样式。
- **#9 深色模式**:当前为固定深色玻璃(`color-scheme: dark`),无双主题切换,见 5.5。
- **#10 移动端**:移动端已适配,侧边栏在窄屏转为顶部横栏,见 5.5。
---
@@ -88,62 +99,61 @@ simple-icons
说明:
1. **Next.js**:适合从静态页面起步,后续接入动态接口;
1. **Next.js**:以本地数据起步,后续接入动态接口;
2. **TypeScript**:适合维护链接、分类、配置等结构化数据;
3. **Tailwind CSS**:适合快速实现玻璃感、响应式、主题变量;
4. **shadcn/ui**:用于基础组件,例如 Button、Input、Dialog、Command;
3. **Tailwind CSS**:配合 CSS 变量实现玻璃表面、响应式与视觉 token;
4. **shadcn/ui**:用于基础组件(Button、DropdownMenu、Tooltip);
5. **lucide-react**:用于侧边栏和操作图标;
6. **simple-icons**:品牌图标库(npm 包,按 slug 引用)。
测试:Vitest + Testing Library,重点覆盖纯函数(问候语分段、本地搜索匹配排序、icon 解析、搜索 URL 模板)与核心交互组件。
测试:Vitest + Testing Library,覆盖静态数据结构校验、service 层行为、纯函数(时间格式化与问候语分段、本地搜索匹配排序、icon 候选解析、搜索 URL 模板、偏好读写)与核心交互组件。
### 5.2 渲染策略
采用静态导出:
采用 standalone 输出:
```ts
// next.config.ts
const nextConfig = { output: "export" }
const nextConfig: NextConfig = {
output: "standalone",
trailingSlash: true,
images: { unoptimized: true },
}
```
- 首页与全部分类页在构建时从本地数据生成(`generateStaticParams`);
- 首页与全部分类页为 server 组件,构建时经 service 层从本地数据生成;分类页通过 `generateStaticParams` 预生成,`dynamicParams = false`(未知分类统一 404);
- 时钟、搜索、侧边栏折叠等交互以 client 组件实现;
- 产物为纯静态文件,可部署到任意静态服务器;
- 后续接 Go 后端时,改造 service 层为 fetch,数据页面改为客户端获取。
- 构建产物为 `.next/standalone` 下的最小 Node 服务器(`server.js`),`public` 与 `.next/static` 需按 Next 文档在部署时手动复制。
### 5.3 数据访问层
### 5.3 数据访问层(已实现)
**页面组件不直接依赖数据文件,一律通过 service 层获取数据。**
第一版:
当前 service 层已实现,页面与数据文件解耦:
```ts
import links from "@/data/links.json"
// src/services/links.ts
export function getLinks(): NavLink[]
export function getLinksByCategory(categoryId: string): NavLink[] // 按 sort 排序
export function getPinnedLinks(limit = 8): NavLink[] // 按 pinnedSort 排序
export function getLinks(): NavLink[] {
return links
}
// src/services/categories.ts
export function getCategories(): Category[]
export function getCategoryById(id: string): Category | null
// src/services/settings.ts
export function getSettings(): AppSettings // 解析时校验,非法数据直接抛错
```
后续接 Go 后端时,只需要修改 service 层:
所有函数返回数据副本,避免调用方改动模块级数据。`settings.json` 在模块加载时经 `parseSettings` 逐字段校验,类型不符直接抛 `TypeError`,问题在构建/测试阶段即暴露。
```ts
export async function getLinks() {
const res = await fetch("/api/links")
if (!res.ok) {
throw new Error("Failed to fetch links")
}
return res.json()
}
```
数据源切换的迁移方案另行规划;当前已确保页面组件只依赖 service 接口,不直接依赖数据文件。
这样页面组件不用大规模重构。
JSON 缺少编译期类型检查,用 **Vitest 数据校验测试**兜底:`src/data/data.test.ts` 校验 `data/*.json` 满足 `Category[]` / `NavLink[]` / `AppSettings` 结构(必填字段齐全、类型正确、categoryId 存在于分类中、URL 唯一且不含会话参数等),`src/services/services.test.ts` 校验 service 行为。
JSON 缺少编译期类型检查,用 **Vitest 数据校验测试**兜底:校验 `data/*.json` 满足 `Category[]` / `NavLink[]` / `AppSettings` 结构(必填字段齐全、类型正确、categoryId 存在于分类中),字段拼错或漏填在测试阶段即报错。
### 5.4 后端技术栈(未来阶段)
### 5.4 后端技术栈(后续阶段)
后端由 Go 实现,数据库 PostgreSQL。
计划由 Go 实现,数据库选用 PostgreSQL(决策 #13,未实施)。
候选组合:
@@ -152,6 +162,16 @@ Go + Chi + sqlc —— 结构清晰、SQL 可控(推荐)
Go + Gin + GORM —— 开发更快、上手成本低
```
### 5.5 视觉与布局现状
界面为**固定深色玻璃**风格,不提供主题切换。视觉描述以 `src/app/globals.css` 中的设计 token 与表面为准:
- **全局基调**:`:root` 声明 `color-scheme: dark`,底色为深蓝夜色(`--background: #101a2a`),文字为亮色系(`--text-primary` / `--text-secondary` / `--text-muted`),强调色为浅蓝(`--primary` 系列);
- **背景与遮罩**:背景素材为可替换内容,用户可不定期更换,其具体文件、类型与画面不作为设计规范;背景层的显示方式由 CSS 定义(居中、覆盖、固定附着),其上由 `body::before` 叠加全屏深色渐变遮罩,保证文字对比度;
- **玻璃表面 token**:侧边栏、搜索框、卡片分别使用 `--surface-sidebar` / `--surface-search` / `--surface-card`(及 `--surface-card-hover`)半透明渐变,配 `--border-glass` 描边、`--shadow-*` 阴影与 `backdrop-filter: blur()` 毛玻璃效果;圆角与栏宽由 `--radius-*` / `--sidebar-*` token 控制;
- **桌面布局**:左侧固定玻璃侧边栏(展开 240px / 折叠 72px,`localStorage` 持久化),内容区随侧边栏宽度偏移;首页为纵向排列、水平居中的时钟、搜索框、常用链接;分类页仅文字元素挂玻璃效果,保持背景清晰;分类页网格默认 2 列,≥1280px 时 3 列;
- **移动端适配(≤767px)**:侧边栏转为固定顶部横栏(品牌行 + 横向滚动的分类导航),折叠按钮隐藏,首页与分类页顶部留白相应加大;≤420px 时常用链接降为单列、快捷键提示隐藏;分类页网格在 ≤999px 时降为 1 列;低视口高度时分类页从固定高度回退为整页滚动;全局支持 `prefers-reduced-motion`。
---
## 6. 数据结构
@@ -178,6 +198,8 @@ icon: 图标名称(侧边栏,lucide 图标名)
sort: 排序值
```
当前数据共 8 个分类:AI 工具、开发工具、设计资源、学习资料、效率工具、娱乐媒体、游戏资料、生活服务。
### 6.2 链接数据
```ts
@@ -210,6 +232,8 @@ keywords: 本地搜索关键词
sort: 分类内排序值
```
当前数据共 51 条链接,各分类均有内容。首页常用链接 6 条(按 `pinnedSort`):GitHub、ChatGPT、Gmail、Notion、YouTube、Bilibili;`getPinnedLinks` 默认上限 8 条,取 `min(pinned 数量, 8)`。
### 6.3 设置数据
```ts
@@ -232,6 +256,8 @@ sort: 分类内排序值
}
```
> **注意**:`theme` 与 `background` 目前仅作为数据模型与校验存在(`services/settings.ts` 解析校验,`theme` 当前仅接受 `"light-glass"`),页面未消费这两个字段,**不驱动样式**。界面视觉为固定深色玻璃,见 5.5。页面实际消费的字段是 `siteName`、`search.*` 与 `sidebar.defaultCollapsed`。
### 6.4 统一图标方案
链接卡片与搜索结果的图标按以下顺序解析,逐级 fallback:
@@ -243,11 +269,11 @@ sort: 分类内排序值
4. 文字占位 以上全部失败 → 标题首字符 + 圆角玻璃底兜底
```
侧边栏分类导航图标使用 lucide-react(如 `home` / `code` / `palette`),不参与此 fallback 链。
侧边栏分类导航图标使用 lucide-react(如 `home` / `code` / `sparkles`),不参与此 fallback 链。
---
## 7. 未来 Go 后端 API 设计
## 7. 未来 Go 后端 API 设计(未实现)
后续接入 Go 后端时,推荐 API:
@@ -282,7 +308,7 @@ PUT /api/settings/background
---
## 8. 数据库表设计草案
## 8. 数据库表设计草案(未来)
> 草案使用跨数据库通用语法;JSON 以 TEXT 存储。PostgreSQL 下可将 `TEXT`(存 JSON)字段替换为 `JSONB` 以获得索引与查询能力。
@@ -337,41 +363,45 @@ CREATE TABLE settings (
## 9. 开发阶段
### 第一阶段:前端静态版(当前阶段)
第一至第三阶段已实现(以当前代码为准),第四、五阶段为未来规划。
### 第一阶段:前端基础版(已完成)
目标:完成首页与分类页基础体验。
```text
页面背景(CSS 渐变)与背景遮罩
背景层与遮罩(样式由 CSS 定义,素材可替换)
左侧边栏 + 折叠(localStorage 持久化)+ 导航选中态
时间 / 日期 / 问候语
搜索框(Google / Bing / GitHub / 本地搜索)+ Ctrl+K 快捷键
常用链接(pinned,8 个)
分类页路由 + 卡片网格 + 空状态
常用链接(pinned,6 个)
分类页路由 + 卡片网格 + 空状态组件
桌面端响应式
```
数据范围:开发工具 15 个链接 + 首页 8 个常用链接(跨分类条目归入真实分类:ChatGPT → AI 工具,Gmail / Notion → 效率工具,YouTube / Bilibili → 娱乐媒体);设计资源、学习资料、生活服务三个分类展示空状态。
数据范围:8 个分类共 51 条链接,全部分类均有内容;空状态组件保留为数据缺失时的兜底展示。首页常用链接 6 条:GitHub、ChatGPT、Gmail、Notion、YouTube、Bilibili。
### 第二阶段:数据补全与体验完善
### 第二阶段:数据补全与视觉定型(已完成)
```text
补全其余 6 个分类的链接数据
深色玻璃主题
移动端 / 平板适配
补全全部分类的链接数据 → 已完成(8 个分类均有内容)
深色玻璃主题 → 已完成(固定深色玻璃,见 5.5)
移动端 / 平板适配 → 移动端已完成(顶部横栏,见 5.5);
平板沿用断点降级,未做专项设计
```
### 第三阶段:数据访问层抽象
### 第三阶段:数据访问层抽象(已完成)
```text
封装 links / categories / settings service
统一 TypeScript 类型
统一错误处理与 loading 状态
封装 links / categories / settings service → 已实现(src/services/)
统一 TypeScript 类型 → 已实现(src/types/)
统一错误处理与 loading 状态 → 数据解析校验失败即抛错;
loading 状态针对远程数据,当前本地数据不涉及
```
此阶段完成后,页面组件不直接关心数据来自本地配置还是 API。
此阶段完成后,页面组件不直接关心数据来自本地配置还是 API——当前已达成:页面只依赖 service 接口。
### 第四阶段:Go 后端 API
### 第四阶段:Go 后端 API(未来)
```text
Go API 服务
@@ -381,7 +411,7 @@ links / categories / settings 查询接口
Docker 部署预留
```
### 第五阶段:管理能力
### 第五阶段:管理能力(未来)
```text
新增 / 编辑 / 删除链接
@@ -396,33 +426,49 @@ Docker 部署预留
## 10. 项目目录结构
实际目录按 feature-first 组织:
```text
polaris/
public/
backgrounds/ # 背景素材(可随时替换,不作为设计基线)
brand/ # 站点 Logo
icons/ # 自定义 SVG 图标(icon: "local:xxx" 引用)
logo.svg
src/
app/
layout.tsx # 背景层 + 字体
page.tsx # 首页
categories/
[categoryId]/
page.tsx # 分类页(generateStaticParams 生成)
globals.css # Tailwind + CSS 变量(浅色值,深色预留覆盖位)
layout.tsx # 根布局、元信息、viewport(深色)
page.tsx # 首页(server 组件,经 service 取数)
categories/[categoryId]/page.tsx # 分类页(generateStaticParams,未知分类 404)
globals.css # Tailwind + 深色玻璃设计 token 与组件样式(见 5.5)
features/ # 按功能域组织
home/ # 首页
components/ # home-content / clock-greeting / search-box /
# search-provider-menu / local-search-results / quick-links
hooks/ # use-clock
lib/ # date-time / search-providers / provider-preference /
# local-search(纯函数 + 测试)
category/ # 分类页
components/ # category-content / category-header /
# category-section-header / tool-grid / tool-card /
# category-empty-state
navigation/ # 外壳与侧边栏
components/ # app-shell / sidebar / category-icon
lib/ # sidebar-preference
components/
layout/ # AppShell / Sidebar / SidebarNavItem
home/ # ClockGreeting / SearchBox / SearchResults / QuickLinks
category/ # CategoryHeader / ToolGrid / ToolCard
common/ # SiteIcon(四级 fallback)/ GlassPanel
shared/ # site-icon(四级 fallback)/ icon-candidates /
# simple-icon-map / glass-panel
ui/ # button / dropdown-menu / tooltip(shadcn/ui)
data/ # links.json / categories.json / settings.json
services/ # links.ts / categories.ts / settings.ts / search.ts
# + data.test.ts(结构校验)
services/ # links.ts / categories.ts / settings.ts + services.test.ts
types/ # navigation.ts / settings.ts / search.ts
lib/ # cn.ts / greeting.ts / local-search.ts / search-providers.ts / icon.ts(纯函数)
lib/ # utils.ts(cn)
next.config.ts # output: 'export'
next.config.ts # output: 'standalone'
vitest.config.ts
```
@@ -430,4 +476,4 @@ polaris/
## 11. 最终目标
> 打开 Polaris 后,用户可以立刻搜索、立刻访问常用网站,也可以通过侧边栏进入分类页面。页面具有个人氛围,但保持干净、高效、克制。第一版数据本地配置,后续平滑切换到 Go 后端。
> 打开 Polaris 后,用户可以立刻搜索、立刻访问常用网站,也可以通过侧边栏进入分类页面。页面具有个人氛围,但保持干净、高效、克制。当前数据内置于本地 JSON 配置,页面统一经 service 层取数。
@@ -6,6 +6,43 @@
>
> 配套设计图:[Polaris 页面设计图](../assets/Polaris页面设计图.png)(示意图,视觉细节以本文档文字规范为准)
## 0. 当前实现状态(2026-10-14 校对)
本章为现状说明。第 1–13 章保留立项时的历史设计意图,其中「浅色玻璃」「CSS 渐变背景」「仅适配桌面端」等表述均为历史方案,不代表当前实现;样式细节以本章与 `src/app/globals.css` 为准。
**视觉主题**:当前为深色玻璃主题(`color-scheme: dark`),没有浅色/深色切换。稳定的设计 tokens:
```css
--background: #101a2a;
--text-primary: #f4f7fc;
--text-secondary: #d7e2f0;
--text-muted: #bfd0e4;
--primary: #a9c9ff;
--primary-hover: #d3e3ff;
--primary-soft: rgba(147, 190, 255, 0.15);
--border-glass: rgba(222, 234, 255, 0.17);
--radius-card: 20px;
--radius-panel: 24px;
```
**玻璃表面**:侧边栏、搜索框、常用链接卡片均为深色半透明渐变(`--surface-sidebar` / `--surface-search` / `--surface-card` / `--surface-card-hover`),配合 `backdrop-filter: blur(16px - 20px) saturate(108% - 134%)` 与带内描边高光的软阴影,形成悬浮玻璃面板感。
**背景**:背景素材可整体替换(用户会不定期更换),背景层的显示与遮罩样式由现有 CSS 管理——`body` 背景层(fixed + cover)之上叠加一层固定的深色渐变遮罩保证前景可读;素材的类型、路径与画面内容**不属于设计规范**。`settings.theme` 与 `settings.background` 目前仅有数据模型与校验逻辑,不驱动任何样式。
**响应式(已实现)**:
```text
桌面默认:侧栏展开 240px / 折叠 72px,常用链接 4 列
≤1199px:侧栏展开宽度 220px,常用链接 2 列
≤767px:侧边栏变为顶部横栏(Logo + 横向滚动导航,折叠按钮隐藏),
首页与分类页改为顶部横栏下方的常规滚动布局
≤420px:常用链接 1 列,搜索快捷键提示隐藏
```
另已支持 `prefers-reduced-motion` 降低动效。
**数据现状**:8 个分类(含后增的「游戏资料」)、51 条链接、6 条 pinned 常用链接。
## 1. 页面定位
Polaris 首页是一个**浏览器起始页**,不是后台管理系统,也不是信息密集型 Dashboard。
@@ -40,6 +77,8 @@ Polaris 首页是一个**浏览器起始页**,不是后台管理系统,也
> 轻量玻璃感 + 极简浏览器起始页 + 柔和个人空间感
> 2026-10-14 实现状态:该方向已落地,但配色从浅色玻璃改为**深色玻璃**(深蓝底、冷白文字,见第 0 章);关键词中「玻璃感、低饱和、可换背景、安静」依然成立。
设计关键词:
```text
@@ -136,6 +175,8 @@ AI 工具
生活服务
```
> 2026-10-14 实现状态:当前侧边栏为「首页 + 8 个分类」,新增了「游戏资料」分类,上表为立项时的 7 分类历史列表;折叠时导航项只显示图标并提供 Tooltip,移动端顶部横栏下不显示折叠按钮。
「首页」指向 `/`,各分类指向 `/categories/{categoryId}`。当前路由对应的导航项处于选中态,选中样式见[分类页设计文档](./Polaris%20分类页设计文档.md) 6.2 节。
暂时不展示:
@@ -204,6 +245,8 @@ box-shadow: 0 24px 80px rgba(15, 23, 42, 0.1);
border-radius: 24px;
```
> 2026-10-14 实现状态:上方浅色样式为历史方案。当前侧边栏为深色渐变玻璃(`--surface-sidebar`,blur(20px) saturate(108%),`--shadow-panel` 阴影),悬浮边距仍为 20px;宽度见第 0 章响应式。
边栏与页面边缘保持一定距离,形成悬浮玻璃面板感。
推荐边距:
@@ -245,6 +288,8 @@ font-weight: 700;
color: #0f172a;
```
> 2026-10-14 实现状态:当前问候语字号为 clamp(44px, 4.2vw, 56px)、冷白色(rgba(248, 250, 252, 0.98))并带文字投影,日期为 rgba(241, 245, 249, 0.86);上方 #0f172a / #64748b 为历史浅色方案。
日期样式:
```css
@@ -311,7 +356,7 @@ height: 68px;
高度:64px - 72px
```
> 移动端尺寸第一版不定义,移动端整体适配为后续阶段工作。
> 历史说明:立项时移动端尺寸未定义。当前移动端已适配:搜索框宽度随容器收缩,≤420px 时隐藏快捷键提示(见第 0 章)。
### 6.4 搜索框视觉样式
@@ -325,6 +370,8 @@ border-radius: 24px;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12);
```
> 2026-10-14 实现状态:上方浅色样式为历史方案。当前搜索框为深色玻璃(`--surface-search` 渐变 + blur(20px) saturate(108%)),聚焦时出现 rgba(147, 197, 253, 0.17) 的 focus 环;尺寸仍为最大 760px × 68px。
placeholder 统一为:
```text
@@ -480,6 +527,8 @@ border-radius: 20px;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12);
```
> 2026-10-14 实现状态:上方浅色样式为历史方案。当前结果面板为更实的深色玻璃 rgba(19, 31, 49, 0.95),最大高度 min(520px, 100vh − 220px),选中项使用 `--primary-soft` 底色。
列表项内容:
```text
@@ -525,6 +574,8 @@ Bilibili
实际项目中根据个人习惯调整。
> 2026-10-14 实现状态:当前 pinned 链接为 6 条,按 `pinnedSort` 排序展示:GitHub、ChatGPT、Gmail、Notion、YouTube、Bilibili;服务层默认上限仍为 8 条(`getPinnedLinks(limit = 8)`)。下文「第一版展示 8 个常用链接」及 8 条 pinned 的数据构成为立项时的历史方案。
数据来源:`pinned: true` 的链接,按 `pinnedSort` 排序取前 8 个;超出 8 个时按 4 列网格换行展示。`sort` 仅用于分类内排序,避免首页顺序与分类页顺序相互约束。
第一版数据构成(8 条 pinned 分布于各分类,归入真实分类):
@@ -576,6 +627,8 @@ border-radius: 20px;
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.1);
```
> 2026-10-14 实现状态:上方浅色样式为历史方案。当前卡片为深色玻璃(`--surface-card` + blur(16px) saturate(134%)),高 76px、圆角 20px,hover 上浮与过渡不变。
推荐尺寸:
```text
@@ -622,6 +675,8 @@ Polaris 支持背景自定义的设计预留。
**第一版默认背景为 CSS 低饱和青蓝渐变**(模拟晨雾 / 天空氛围),不依赖图片资产。
> **2026-10-14 实现状态(重要)**:背景已不再是 CSS 渐变,改为可替换的背景素材层 + 深色遮罩(由 `body` 背景层加载,见第 0 章);背景层显示与遮罩样式由现有 CSS 管理,素材的类型、路径、画面与题材由用户自行选择、不定期更换,**不写入设计规范**。下方「图片背景建议」仅作当时的设计联想,不构成约束。`settings.background.type` 目前仅有数据模型与校验,未接入渲染;「CSS 低饱和青蓝渐变」为历史方案。
后续支持:
```text
@@ -650,6 +705,8 @@ Polaris 支持背景自定义的设计预留。
由于用户未来可能更换任意背景,因此需要增加背景遮罩层,保证内容可读。
> 2026-10-14 实现状态:遮罩已实现,但形态与下方两侧历史值不同——当前为固定深色渐变(顶部 rgba(5, 12, 25, 0.43) → 中部 0.37 → 底部 0.47,无 backdrop blur);浅色遮罩值为历史方案,「深色主题(后续阶段预留)」未按该形式落地。
浅色主题:
```css
@@ -668,9 +725,9 @@ backdrop-filter: blur(8px);
## 10. 配色系统
默认采用浅色玻璃主题。
> 2026-10-14 实现状态:当前默认为**深色玻璃主题**(无主题切换),tokens 直接定义在 `globals.css` 的 `:root`,未使用 `[data-theme]` 覆盖;`settings.theme` 未接入样式。现行值见第 0 章;下方「推荐 CSS 变量」为立项时的浅色方案,仅作历史参考。
推荐 CSS 变量:
历史方案(浅色玻璃)推荐 CSS 变量:
```css
:root {
@@ -694,7 +751,7 @@ backdrop-filter: blur(8px);
}
```
变量按双主题友好方式组织:第一版只实现浅色值;深色玻璃主题为后续阶段,届时以 `[data-theme="dark"]` 覆盖同名变量,组件无需改动。
> 历史说明:上段为立项时的双主题规划。实际实现未走 `[data-theme]` 覆盖路线,深色值自当前版本起直接作为默认主题(见第 0 章与 `globals.css`)。
---
@@ -716,6 +773,8 @@ font-family:
sans-serif;
```
> 2026-10-14 实现状态:当前字体栈以 Avenir Next / Avenir 开头,未引入 Inter;中文回退与上方一致。
字体原则:
```text
@@ -775,6 +834,8 @@ font-family:
第一版仅实现浅色主题、仅适配桌面端
```
> 历史说明:末行「仅浅色主题、仅适配桌面端」为第一版约束,已被后续实现取代——当前为深色玻璃默认主题,移动端适配已完成。
最终目标:
> 打开 Polaris 后,用户可以立刻搜索、立刻访问常用网站,也可以通过侧边栏进入分类页面。页面具有个人氛围,但保持干净、高效、克制。
@@ -0,0 +1,17 @@
# Polaris 文档与当前实现对齐(2026-10-14)
状态:用户已确认调整后的方案;设计文档和部署说明已更新(2026-10-14)。未修改产品代码、配置或 CI 工作流。
## 范围
1. 更新 `docs/design/Polaris 项目说明文档.md`:将当前部署方式统一为 standalone;依据现有 CSS 描述深色玻璃视觉、移动端布局、8 类 51 条链接及 6 条常用链接;更正阶段清单与目录结构;明确 `settings.theme`、`settings.background` 仅有数据模型与校验,未驱动样式。背景素材属于可更换内容,不写死图片路径、画面类型或具体背景外观。
2. 更新 `docs/design/Polaris 首页设计文档.md`、`Polaris 分类页设计文档.md`:在保留历史设计意图的同时,按现有代码描述深色玻璃风格和移动端顶部横栏;区分尚未实现的双主题、背景配置生效与已实现的分类网格三档断点(≥1280px 三列、1000–1279px 两列、≤999px 单列),不规定具体背景素材及其内容。
3. 对日期命名的 `docs/design/2026-08-26-Polaris-首页实现规格.md` 增加历史规格状态说明和现状索引,不逐行重写历史验收要求;历史 `docs/superpowers/plans/` 保持原貌。
4. 修订 `deploy/README.md` 的触发方式和镜像摘要说明:仅推送 `polaris-v*` 标签才运行 CI;验证通过后发布带完整 commit SHA 的镜像;workflow 已实现摘要输出,但 registry、runner 与服务器行为仍待现场验证。明确 Compose 目前只要求镜像变量非空,digest 固定是部署操作约定而非自动校验。
## 边界与验证
- 不修改应用代码、配置数据、工作流或标签触发条件;不处理未来 Go API。
- 不以当前背景文件或壁纸内容作为设计基线;用户可不定期替换背景素材,视觉描述以现有 CSS 中稳定的色彩、表面与交互样式为准。
- 不碰仓库 `note/` 中已有的删除/新增改动,亦不处理仓库外或已删除的历史笔记。
- 编辑前检查目标文件缩进和换行形式;完成后检查文档差异、搜索剩余过时表述并核对 Git 工作树。纯文档更新不要求构建或测试。
@@ -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 可达性未测试,不影响本次数据及图标映射变更结论。
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
output: "export",
output: "standalone",
trailingSlash: true,
images: {
unoptimized: true,
-1
View File
@@ -7,7 +7,6 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"verify:export": "node scripts/verify-static-export.mjs",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
+4 -5
View File
@@ -1,4 +1,4 @@
# Next.js Windows 静态导出补丁
# Next.js Windows 静态导出历史补丁
## 原因与范围
@@ -17,13 +17,12 @@
```bash
corepack pnpm install --frozen-lockfile
corepack pnpm build
corepack pnpm verify:export
```
`verify:export` 只读取产物:检查首页、全部分类页和 404,并将每个源 RSC segment 与浏览器所需扁平路径下的导出文件逐字节比较。它不会复制、移动或重命名 `out/` 文件。部署前还需在真实静态服务器上验证预取和客户端导航。
当前项目使用 standalone 服务端输出,不再生成 `out/` 或执行静态导出检查。补丁暂时保留以维持锁文件中的安装约定;服务端产物和 HTTP 路由由 Gitea CI 验证。
## 后续升级
补丁仅绑定 `next@16.3.3`,不会自动迁移到其他版本。升级 Next.js 时必须重新检查上游导出实现、运行构建与 `verify:export`,再进行静态浏览器验收;不能仅以 `next build` 成功判断问题已修复。
补丁仅绑定 `next@16.3.3`,不会自动迁移到其他版本。升级 Next.js 时先评估能否移除这个不再服务当前部署方式的补丁;如果恢复静态导出,再单独验收 Windows 路径与浏览器导航。
上游修复并验收通过后,使用 pnpm 的补丁移除命令清理对应登记和补丁文件,更新锁文件,保留产物回归检查。
决定移除时,同步清理 pnpm 的补丁登记和补丁文件,更新锁文件,并保留服务端 HTTP 回归检查。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 9.2 MiB

+5 -110
View File
@@ -1,111 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<!-- 背景透明 -->
<defs>
<!-- 渐变:高亮发光青蓝 到 科技蓝 -->
<linearGradient id="magicCyan" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00F2FE" />
<stop offset="100%" stop-color="#3B82F6" />
</linearGradient>
<!-- 渐变:深邃蓝 (指针反向切面与暗部) -->
<linearGradient id="magicDark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1E293B" />
<stop offset="100%" stop-color="#0F172A" />
</linearGradient>
<!-- 霓虹发光 Glow 滤镜 -->
<filter id="magicGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- ==================== 丰富高精度的极客魔法阵 (Geek Magic Array) ==================== -->
<!-- 1. 最外层:外边界细环与四角法术引线节点 -->
<circle cx="256" cy="256" r="242" fill="none" stroke="#00F2FE" stroke-width="1.5" stroke-opacity="0.5" />
<circle cx="256" cy="256" r="236" fill="none" stroke="#3B82F6" stroke-width="2.5" stroke-opacity="0.8" />
<!-- 外圈四极极客指示顶点 -->
<path d="M 256 8 L 266 26 L 246 26 Z" fill="#00F2FE" filter="url(#magicGlow)" />
<path d="M 256 504 L 266 486 L 246 486 Z" fill="#2563EB" opacity="0.6" />
<path d="M 8 256 L 26 246 L 26 266 Z" fill="#2563EB" opacity="0.6" />
<path d="M 504 256 L 486 246 L 486 266 Z" fill="#2563EB" opacity="0.6" />
<!-- 2. 精密仪表与符文刻度带 (外环与内环间) -->
<circle cx="256" cy="256" r="226" fill="none" stroke="#00F2FE" stroke-width="6" stroke-dasharray="2 6" stroke-opacity="0.75" />
<circle cx="256" cy="256" r="216" fill="none" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.4" />
<!-- 精算刻度环:6个长粗虚线的中心点完全对齐正六边形与角点的 60° 几何角 -->
<circle cx="256" cy="256" r="210" fill="none" stroke="#00F2FE" stroke-width="4.5"
stroke-dasharray="25 8 6 15.977872" stroke-dashoffset="-12.5" stroke-opacity="0.85" filter="url(#magicGlow)" />
<!-- 3. 魔法阵三重嵌套多边形结界 (正三角形 + 正六边形 + 12角星线拓扑) -->
<g fill="none" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.5">
<!-- 正六边形 A (顶角精确落于 r=210 粗虚线长段) -->
<polygon points="256,46 437.86,151 437.86,361 256,466 74.14,361 74.14,151" stroke="#00F2FE" stroke-width="1.8" stroke-opacity="0.7" />
<!-- 正六边形 B (旋转30度) -->
<polygon points="361,74.14 466,256 361,437.86 151,437.86 46,256 151,74.14" />
<!-- 正三角形 C (向上) -->
<polygon points="256,46 437.86,361 74.14,361" stroke="#00F2FE" stroke-width="1" stroke-opacity="0.4" />
<!-- 正三角形 D (向下) -->
<polygon points="256,466 74.14,151 437.86,151" stroke="#00F2FE" stroke-width="1" stroke-opacity="0.4" />
</g>
<!-- 4. 六方位魔法法阵节点圈 (在多边形顶点上的极客元素) -->
<g fill="#0F172A" stroke="#00F2FE" stroke-width="2" opacity="0.95">
<circle cx="256" cy="46" r="10" />
<circle cx="437.86" cy="151" r="10" />
<circle cx="437.86" cy="361" r="10" />
<circle cx="256" cy="466" r="10" />
<circle cx="74.14" cy="361" r="10" />
<circle cx="74.14" cy="151" r="10" />
</g>
<!-- 节点内发光核 -->
<g fill="#00F2FE" filter="url(#magicGlow)">
<circle cx="256" cy="46" r="4" />
<circle cx="437.86" cy="151" r="4" />
<circle cx="437.86" cy="361" r="4" />
<circle cx="256" cy="466" r="4" />
<circle cx="74.14" cy="361" r="4" />
<circle cx="74.14" cy="151" r="4" />
</g>
<!-- 5. 中阶魔法阵环与细分刻度 -->
<circle cx="256" cy="256" r="186" fill="none" stroke="#00F2FE" stroke-width="2" stroke-opacity="0.8" filter="url(#magicGlow)" />
<circle cx="256" cy="256" r="176" fill="none" stroke="#2563EB" stroke-width="3" stroke-dasharray="12 6 4 6" stroke-opacity="0.6" />
<circle cx="256" cy="256" r="166" fill="none" stroke="#00F2FE" stroke-width="1" stroke-dasharray="2 4" stroke-opacity="0.5" />
<!-- 6. 魔法阵放射状流向线 (16等分星射线) -->
<g stroke="#00F2FE" stroke-width="1" stroke-opacity="0.2">
<line x1="256" y1="26" x2="256" y2="486" />
<line x1="26" y1="256" x2="486" y2="256" />
<line x1="93" y1="93" x2="419" y2="419" />
<line x1="419" y1="93" x2="93" y2="419" />
</g>
<!-- ==================== 中心:硬核极客机甲指针 (中间区域干净无符号) ==================== -->
<g transform="rotate(28, 256, 256)">
<!-- 北向主指针(立体几何亮蓝切面) -->
<path d="M 256 70 L 306 226 L 256 256 Z" fill="url(#magicCyan)" filter="url(#magicGlow)" />
<!-- 北向副切面(深蓝阴影) -->
<path d="M 256 70 L 206 226 L 256 256 Z" fill="#1D4ED8" />
<!-- 指针侧翼机甲组件 -->
<path d="M 312 236 L 332 266 L 282 256 Z" fill="#00F2FE" opacity="0.7" />
<path d="M 200 236 L 180 266 L 230 256 Z" fill="#2563EB" opacity="0.7" />
<!-- 南向反向指针(深色金属质感) -->
<path d="M 256 442 L 220 286 L 256 256 Z" fill="url(#magicDark)" />
<path d="M 256 442 L 292 286 L 256 256 Z" fill="#334155" />
</g>
<!-- 中心核心:反应堆式同心轴 -->
<circle cx="256" cy="256" r="32" fill="#0F172A" stroke="#00F2FE" stroke-width="3" />
<circle cx="256" cy="256" r="18" fill="none" stroke="#3B82F6" stroke-width="2" stroke-dasharray="6 3" />
<circle cx="256" cy="256" r="8" fill="#00F2FE" filter="url(#magicGlow)" />
<circle cx="256" cy="256" r="3" fill="#FFFFFF" />
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-labelledby="title desc">
<title id="title">北极星标志</title>
<desc id="desc">透明背景的蓝色八角北极星标志,纵向星芒略长。</desc>
<path fill="#205B91" d="M256 16 279 207 348 164 305 233 472 256 305 279 348 348 279 305 256 496 233 305 164 348 207 279 40 256 207 233 164 164 233 207Z"/>
<path fill="#438CC4" d="M256 16 256 256 279 207Z M472 256 256 256 305 279Z M256 496 256 256 233 305Z M40 256 256 256 207 233Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 559 B

@@ -0,0 +1,111 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<!-- 背景透明 -->
<defs>
<!-- 渐变:高亮发光青蓝 到 科技蓝 -->
<linearGradient id="magicCyan" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00F2FE" />
<stop offset="100%" stop-color="#3B82F6" />
</linearGradient>
<!-- 渐变:深邃蓝 (指针反向切面与暗部) -->
<linearGradient id="magicDark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1E293B" />
<stop offset="100%" stop-color="#0F172A" />
</linearGradient>
<!-- 霓虹发光 Glow 滤镜 -->
<filter id="magicGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- ==================== 丰富高精度的极客魔法阵 (Geek Magic Array) ==================== -->
<!-- 1. 最外层:外边界细环与四角法术引线节点 -->
<circle cx="256" cy="256" r="242" fill="none" stroke="#00F2FE" stroke-width="1.5" stroke-opacity="0.5" />
<circle cx="256" cy="256" r="236" fill="none" stroke="#3B82F6" stroke-width="2.5" stroke-opacity="0.8" />
<!-- 外圈四极极客指示顶点 -->
<path d="M 256 8 L 266 26 L 246 26 Z" fill="#00F2FE" filter="url(#magicGlow)" />
<path d="M 256 504 L 266 486 L 246 486 Z" fill="#2563EB" opacity="0.6" />
<path d="M 8 256 L 26 246 L 26 266 Z" fill="#2563EB" opacity="0.6" />
<path d="M 504 256 L 486 246 L 486 266 Z" fill="#2563EB" opacity="0.6" />
<!-- 2. 精密仪表与符文刻度带 (外环与内环间) -->
<circle cx="256" cy="256" r="226" fill="none" stroke="#00F2FE" stroke-width="6" stroke-dasharray="2 6" stroke-opacity="0.75" />
<circle cx="256" cy="256" r="216" fill="none" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.4" />
<!-- 精算刻度环:6个长粗虚线的中心点完全对齐正六边形与角点的 60° 几何角 -->
<circle cx="256" cy="256" r="210" fill="none" stroke="#00F2FE" stroke-width="4.5"
stroke-dasharray="25 8 6 15.977872" stroke-dashoffset="-12.5" stroke-opacity="0.85" filter="url(#magicGlow)" />
<!-- 3. 魔法阵三重嵌套多边形结界 (正三角形 + 正六边形 + 12角星线拓扑) -->
<g fill="none" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.5">
<!-- 正六边形 A (顶角精确落于 r=210 粗虚线长段) -->
<polygon points="256,46 437.86,151 437.86,361 256,466 74.14,361 74.14,151" stroke="#00F2FE" stroke-width="1.8" stroke-opacity="0.7" />
<!-- 正六边形 B (旋转30度) -->
<polygon points="361,74.14 466,256 361,437.86 151,437.86 46,256 151,74.14" />
<!-- 正三角形 C (向上) -->
<polygon points="256,46 437.86,361 74.14,361" stroke="#00F2FE" stroke-width="1" stroke-opacity="0.4" />
<!-- 正三角形 D (向下) -->
<polygon points="256,466 74.14,151 437.86,151" stroke="#00F2FE" stroke-width="1" stroke-opacity="0.4" />
</g>
<!-- 4. 六方位魔法法阵节点圈 (在多边形顶点上的极客元素) -->
<g fill="#0F172A" stroke="#00F2FE" stroke-width="2" opacity="0.95">
<circle cx="256" cy="46" r="10" />
<circle cx="437.86" cy="151" r="10" />
<circle cx="437.86" cy="361" r="10" />
<circle cx="256" cy="466" r="10" />
<circle cx="74.14" cy="361" r="10" />
<circle cx="74.14" cy="151" r="10" />
</g>
<!-- 节点内发光核 -->
<g fill="#00F2FE" filter="url(#magicGlow)">
<circle cx="256" cy="46" r="4" />
<circle cx="437.86" cy="151" r="4" />
<circle cx="437.86" cy="361" r="4" />
<circle cx="256" cy="466" r="4" />
<circle cx="74.14" cy="361" r="4" />
<circle cx="74.14" cy="151" r="4" />
</g>
<!-- 5. 中阶魔法阵环与细分刻度 -->
<circle cx="256" cy="256" r="186" fill="none" stroke="#00F2FE" stroke-width="2" stroke-opacity="0.8" filter="url(#magicGlow)" />
<circle cx="256" cy="256" r="176" fill="none" stroke="#2563EB" stroke-width="3" stroke-dasharray="12 6 4 6" stroke-opacity="0.6" />
<circle cx="256" cy="256" r="166" fill="none" stroke="#00F2FE" stroke-width="1" stroke-dasharray="2 4" stroke-opacity="0.5" />
<!-- 6. 魔法阵放射状流向线 (16等分星射线) -->
<g stroke="#00F2FE" stroke-width="1" stroke-opacity="0.2">
<line x1="256" y1="26" x2="256" y2="486" />
<line x1="26" y1="256" x2="486" y2="256" />
<line x1="93" y1="93" x2="419" y2="419" />
<line x1="419" y1="93" x2="93" y2="419" />
</g>
<!-- ==================== 中心:硬核极客机甲指针 (中间区域干净无符号) ==================== -->
<g transform="rotate(28, 256, 256)">
<!-- 北向主指针(立体几何亮蓝切面) -->
<path d="M 256 70 L 306 226 L 256 256 Z" fill="url(#magicCyan)" filter="url(#magicGlow)" />
<!-- 北向副切面(深蓝阴影) -->
<path d="M 256 70 L 206 226 L 256 256 Z" fill="#1D4ED8" />
<!-- 指针侧翼机甲组件 -->
<path d="M 312 236 L 332 266 L 282 256 Z" fill="#00F2FE" opacity="0.7" />
<path d="M 200 236 L 180 266 L 230 256 Z" fill="#2563EB" opacity="0.7" />
<!-- 南向反向指针(深色金属质感) -->
<path d="M 256 442 L 220 286 L 256 256 Z" fill="url(#magicDark)" />
<path d="M 256 442 L 292 286 L 256 256 Z" fill="#334155" />
</g>
<!-- 中心核心:反应堆式同心轴 -->
<circle cx="256" cy="256" r="32" fill="#0F172A" stroke="#00F2FE" stroke-width="3" />
<circle cx="256" cy="256" r="18" fill="none" stroke="#3B82F6" stroke-width="2" stroke-dasharray="6 3" />
<circle cx="256" cy="256" r="8" fill="#00F2FE" filter="url(#magicGlow)" />
<circle cx="256" cy="256" r="3" fill="#FFFFFF" />
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1791734628751" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5223" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M1020.586667 361.813333c0-92.16-75.093333-167.253333-167.253334-167.253333h-266.24l23.893334 95.573333 228.693333 51.2c20.48 3.413333 37.546667 23.893333 37.546667 44.373334v245.76c0 20.48-17.066667 40.96-37.546667 44.373333l-228.693333 51.2-27.306667 92.16h266.24c92.16 0 167.253333-75.093333 167.253333-167.253333 3.413333 0 3.413333-290.133333 3.413334-290.133334zM187.733333 672.426667c-20.48-3.413333-37.546667-23.893333-37.546666-44.373334v-245.76c0-20.48 17.066667-40.96 37.546666-44.373333l228.693334-51.2 23.893333-95.573333H174.08C81.92 191.146667 6.826667 266.24 6.826667 358.4V648.533333c0 92.16 75.093333 167.253333 167.253333 167.253334h266.24l-23.893333-95.573334c0 3.413333-228.693333-47.786667-228.693334-47.786666z m215.04-211.626667h218.453334v88.746667h-218.453334v-88.746667z" fill="#ff6b01" p-id="5224"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,46 +0,0 @@
import assert from "node:assert/strict"
import { readFile, readdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const projectDir = fileURLToPath(new URL("../", import.meta.url))
const appDir = path.join(projectDir, ".next/server/app")
const outDir = path.join(projectDir, "out")
const categories = JSON.parse(
await readFile(path.join(projectDir, "src/data/categories.json"), "utf8"),
)
const routes = ["/", ...categories.map(({ id }) => `/categories/${id}`)]
let checkedSegments = 0
// 只读构建产物:验证浏览器需要的扁平 URL,绝不修补或复制 out/ 文件。
for (const route of routes) {
const routeDir = path.join(outDir, route)
assert((await readFile(path.join(routeDir, "index.html"))).length > 0)
assert((await readFile(path.join(routeDir, "index.txt"))).length > 0)
const segmentsDir = path.join(
appDir,
`${route === "/" ? "index" : route}.segments`,
)
const files = (await readdir(segmentsDir, { recursive: true })).filter(
(file) => file.endsWith(".segment.rsc"),
)
assert(files.length > 0, `${route} 缺少 RSC segment 构建产物`)
for (const file of files) {
const segment = file
.replaceAll("\\", "/")
.slice(0, -".segment.rsc".length)
.replaceAll("/", ".")
const filename = `__next.${segment}.txt`
const source = await readFile(path.join(segmentsDir, file))
const exported = await readFile(path.join(routeDir, filename))
assert.deepEqual(exported, source, `${route}/${filename} 内容不一致`)
checkedSegments += 1
}
}
assert((await readFile(path.join(outDir, "404.html"))).length > 0)
console.log(
`静态导出检查通过:${routes.length} 个页面、${checkedSegments} 个 RSC 文件及 404.html。`,
)
@@ -6,7 +6,7 @@ import { getLinksByCategory } from "@/services/links"
import CategoryPage, { dynamicParams, generateStaticParams } from "./page"
describe("CategoryPage", () => {
it("为全部 7 个分类生成静态参数并关闭运行时动态参数", () => {
it("为全部 8 个分类生成静态参数并关闭运行时动态参数", () => {
expect(generateStaticParams()).toEqual([
{ categoryId: "ai" },
{ categoryId: "dev" },
@@ -14,11 +14,35 @@ describe("CategoryPage", () => {
{ categoryId: "learning" },
{ categoryId: "efficiency" },
{ categoryId: "media" },
{ categoryId: "game-resources" },
{ categoryId: "life" },
])
expect(dynamicParams).toBe(false)
})
it("归类边界:游戏资料页收录宝可梦维基,娱乐媒体页不再包含", async () => {
const gamePage = await CategoryPage({
params: Promise.resolve({ categoryId: "game-resources" }),
})
render(gamePage)
expect(
screen.getByRole("heading", { level: 1, name: "游戏资料" }),
).toBeInTheDocument()
expect(
screen.getByRole("link", { name: /神奇宝贝百科/ }),
).toBeInTheDocument()
expect(
screen.getByRole("link", { name: /星露谷物语中文维基/ }),
).toBeInTheDocument()
expect(
screen.queryByRole("link", { name: /萌娘百科/ }),
).not.toBeInTheDocument()
expect(getLinksByCategory("media").map(({ id }) => id)).not.toContain(
"wiki-52poke",
)
})
it("通过 service 组合开发工具分类页", async () => {
const links = getLinksByCategory("dev")
const page = await CategoryPage({
@@ -10,7 +10,7 @@ type CategoryPageProps = {
params: Promise<{ categoryId: string }>
}
// 静态导出不支持运行时补生成动态路径,未知分类统一返回 404。
// 仅预生成 generateStaticParams 列出的分类,未知分类统一返回 404。
export const dynamicParams = false
export function generateStaticParams() {
+196 -116
View File
@@ -4,7 +4,7 @@
--color-background: var(--background);
--color-foreground: var(--text-primary);
--color-primary: var(--primary);
--color-primary-foreground: #ffffff;
--color-primary-foreground: #10223e;
--color-muted-foreground: var(--text-secondary);
--color-border: var(--border-subtle);
--radius-lg: var(--radius-card);
@@ -12,41 +12,44 @@
}
:root {
color-scheme: light;
--background: #dcebf5;
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-soft: rgba(59, 130, 246, 0.1);
color-scheme: dark;
--background: #101a2a;
--text-primary: #f4f7fc;
--text-secondary: #d7e2f0;
--text-muted: #bfd0e4;
--primary: #a9c9ff;
--primary-hover: #d3e3ff;
--primary-soft: rgba(147, 190, 255, 0.15);
/* Navigation stays translucent; a future reading surface can use this opaque tone. */
--surface-reading: rgba(13, 23, 39, 0.96);
--surface-sidebar: linear-gradient(
180deg,
rgba(235, 243, 251, 0.52),
rgba(204, 220, 238, 0.34)
155deg,
rgba(23, 34, 53, 0.69),
rgba(15, 26, 44, 0.64)
);
--surface-search: linear-gradient(
135deg,
rgba(240, 246, 252, 0.56),
rgba(210, 225, 241, 0.4)
145deg,
rgba(30, 43, 62, 0.72),
rgba(19, 31, 50, 0.68)
);
--surface-card: linear-gradient(
135deg,
rgba(231, 240, 249, 0.43),
rgba(198, 216, 236, 0.32)
145deg,
rgba(36, 50, 70, 0.64),
rgba(19, 31, 49, 0.61)
);
--surface-card-hover: linear-gradient(
135deg,
rgba(242, 248, 253, 0.6),
rgba(216, 230, 244, 0.48)
145deg,
rgba(49, 65, 87, 0.73),
rgba(27, 42, 63, 0.71)
);
--border-glass: rgba(255, 255, 255, 0.38);
--border-subtle: rgba(15, 23, 42, 0.08);
--shadow-soft: inset 0 1px 0 rgba(255, 255, 255, 0.34);
--border-glass: rgba(222, 234, 255, 0.17);
--border-subtle: rgba(213, 228, 250, 0.16);
--shadow-soft:
0 12px 32px rgba(3, 9, 23, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.12);
--shadow-medium:
0 22px 58px rgba(4, 12, 34, 0.17), inset 0 1px 0 rgba(255, 255, 255, 0.4);
0 20px 48px rgba(3, 9, 23, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.14);
--shadow-panel:
0 28px 72px rgba(4, 12, 34, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.42);
0 24px 64px rgba(3, 9, 23, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.13);
--radius-card: 20px;
--radius-panel: 24px;
--sidebar-expanded: 240px;
@@ -68,15 +71,15 @@ body {
margin: 0;
overflow-x: hidden;
color: var(--text-primary);
background-color: #0b102b;
background-color: var(--background);
background-image: url("/backgrounds/test.jpg");
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-attachment: fixed;
font-family:
Inter,
"SF Pro Display",
"Avenir Next",
Avenir,
"SF Pro Text",
-apple-system,
BlinkMacSystemFont,
@@ -95,9 +98,9 @@ body::before {
pointer-events: none;
background: linear-gradient(
180deg,
rgba(3, 7, 28, 0.2) 0%,
rgba(9, 18, 54, 0.07) 48%,
rgba(216, 230, 244, 0.08) 100%
rgba(5, 12, 25, 0.43) 0%,
rgba(8, 16, 31, 0.37) 48%,
rgba(7, 15, 28, 0.47) 100%
);
-webkit-backdrop-filter: none;
backdrop-filter: none;
@@ -115,7 +118,7 @@ a {
}
:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.34);
outline: 2px solid #c9dfff;
outline-offset: 3px;
}
@@ -124,8 +127,8 @@ a {
border-radius: var(--radius-panel);
background: var(--surface-card);
box-shadow: var(--shadow-soft);
-webkit-backdrop-filter: blur(16px) saturate(134%);
backdrop-filter: blur(16px) saturate(134%);
-webkit-backdrop-filter: blur(18px) saturate(108%);
backdrop-filter: blur(18px) saturate(108%);
}
.app-shell {
@@ -155,8 +158,8 @@ a {
border-radius: var(--radius-panel);
background: var(--surface-sidebar);
box-shadow: var(--shadow-panel);
-webkit-backdrop-filter: blur(18px) saturate(132%);
backdrop-filter: blur(18px) saturate(132%);
-webkit-backdrop-filter: blur(20px) saturate(108%);
backdrop-filter: blur(20px) saturate(108%);
transition: width 180ms ease;
}
@@ -167,7 +170,7 @@ a {
align-items: center;
gap: 10px;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.26);
border-bottom: 1px solid rgba(222, 234, 255, 0.11);
}
.sidebar__brand {
@@ -233,7 +236,7 @@ a {
justify-content: flex-start;
gap: 12px;
padding: 0 14px;
color: #334155;
color: var(--text-secondary);
text-decoration: none;
border: 0;
border-radius: 16px;
@@ -252,6 +255,7 @@ a {
.sidebar__nav-item--active {
color: var(--primary-hover);
background: var(--primary-soft);
box-shadow: inset 0 0 0 1px rgba(169, 201, 255, 0.13);
font-weight: 650;
}
@@ -322,7 +326,7 @@ a {
font-weight: 700;
line-height: 1.16;
letter-spacing: -0.035em;
text-shadow: 0 3px 24px rgba(2, 6, 23, 0.48);
text-shadow: 0 2px 12px rgba(2, 6, 23, 0.8);
text-wrap: balance;
}
@@ -331,7 +335,7 @@ a {
color: rgba(241, 245, 249, 0.86);
font-size: 16px;
line-height: 1.6;
text-shadow: 0 2px 16px rgba(2, 6, 23, 0.42);
text-shadow: 0 2px 12px rgba(2, 6, 23, 0.8);
}
.search-box {
@@ -349,8 +353,8 @@ a {
border-radius: var(--radius-panel);
background: var(--surface-search);
box-shadow: var(--shadow-medium);
-webkit-backdrop-filter: blur(18px) saturate(136%);
backdrop-filter: blur(18px) saturate(136%);
-webkit-backdrop-filter: blur(20px) saturate(108%);
backdrop-filter: blur(20px) saturate(108%);
transition:
border-color 180ms ease,
box-shadow 180ms ease,
@@ -358,14 +362,10 @@ a {
}
.search-box__form:focus-within {
border-color: rgba(191, 219, 254, 0.46);
background: linear-gradient(
135deg,
rgba(245, 249, 253, 0.66),
rgba(218, 231, 245, 0.52)
);
border-color: rgba(185, 214, 255, 0.52);
background: var(--surface-search);
box-shadow:
0 0 0 4px rgba(147, 197, 253, 0.12),
0 0 0 3px rgba(147, 197, 253, 0.17),
var(--shadow-medium);
}
@@ -376,7 +376,7 @@ a {
justify-content: center;
margin-left: 10px;
padding: 0;
color: #334155;
color: var(--text-secondary);
border-radius: 16px;
}
@@ -387,18 +387,41 @@ a {
object-fit: contain;
}
/* The bundled GitHub mark is charcoal; lift only that mark on dark glass. */
.search-provider-menu__icon[src*="github.svg"] {
filter: brightness(0) invert(1);
opacity: 0.88;
}
.search-provider-menu__trigger:hover,
.search-provider-menu__trigger[aria-expanded="true"] {
color: var(--primary-hover);
background: var(--primary-soft);
}
/* The provider menu is portalled outside the app shell. Match the search glass. */
[data-slot="dropdown-menu-content"] {
color: var(--text-primary);
border-color: var(--border-glass);
background: rgba(19, 31, 49, 0.96);
box-shadow: var(--shadow-medium);
}
[data-slot="dropdown-menu-radio-item"]:focus {
color: var(--primary-hover);
background: var(--primary-soft);
}
[data-slot="dropdown-menu-radio-item"][data-state="checked"] {
color: var(--primary-hover);
}
.search-box__icon {
width: 20px;
height: 20px;
flex: none;
margin-left: 10px;
color: rgba(51, 65, 85, 0.58);
color: var(--text-muted);
}
.search-box__input {
@@ -415,10 +438,15 @@ a {
}
.search-box__input::placeholder {
color: rgba(51, 65, 85, 0.62);
color: var(--text-muted);
opacity: 1;
}
.search-box__input:focus-visible {
/* The entire search field receives a visible focus ring above. */
outline: none;
}
.search-box__shortcut {
display: inline-flex;
min-width: 50px;
@@ -432,10 +460,10 @@ a {
font-family: inherit;
font-size: 12px;
line-height: 1;
border: 1px solid rgba(100, 116, 139, 0.2);
border: 1px solid var(--border-subtle);
border-radius: 9px;
background: rgba(255, 255, 255, 0.42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.68);
background: rgba(201, 220, 248, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.local-search-results {
@@ -449,10 +477,10 @@ a {
padding: 8px;
border: 1px solid var(--border-glass);
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
background: rgba(19, 31, 49, 0.95);
box-shadow: var(--shadow-medium);
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
}
.local-search-results__option {
@@ -473,10 +501,14 @@ a {
.local-search-results__option[aria-selected="true"] {
color: var(--primary-hover);
border-color: rgba(59, 130, 246, 0.08);
border-color: rgba(169, 201, 255, 0.17);
background: var(--primary-soft);
}
.local-search-results__option:hover {
background: rgba(147, 190, 255, 0.1);
}
.local-search-results__copy {
display: flex;
min-width: 0;
@@ -521,7 +553,7 @@ a {
color: rgba(248, 250, 252, 0.96);
font-size: 16px;
font-weight: 650;
text-shadow: 0 2px 14px rgba(2, 6, 23, 0.44);
text-shadow: 0 2px 12px rgba(2, 6, 23, 0.8);
}
.quick-links__grid {
@@ -554,9 +586,9 @@ a {
.quick-link-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.54);
border-color: rgba(222, 234, 255, 0.3);
background: var(--surface-card-hover);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
box-shadow: var(--shadow-medium);
}
.quick-link-card > span:last-child {
@@ -574,20 +606,24 @@ a {
}
.category-page__panel {
/* Leave the wallpaper sharp; only the text-bearing elements need glass. */
display: flex;
height: 100%;
min-height: 0;
overflow: hidden;
flex-direction: column;
padding: 48px clamp(32px, 4vw, 64px) 56px;
background: var(--surface-sidebar);
box-shadow: var(--shadow-panel);
-webkit-backdrop-filter: blur(18px) saturate(132%);
backdrop-filter: blur(18px) saturate(132%);
padding: 40px clamp(24px, 3.2vw, 48px) 32px;
border-color: transparent;
background: transparent;
box-shadow: none;
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.category-header {
max-width: 720px;
padding: 16px 22px;
border: 1px solid transparent;
}
.category-header__title {
@@ -597,6 +633,7 @@ a {
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.03em;
text-shadow: 0 2px 16px rgba(2, 8, 19, 0.85);
}
.category-header__description {
@@ -604,6 +641,7 @@ a {
color: var(--text-secondary);
font-size: 16px;
line-height: 1.7;
text-shadow: 0 2px 12px rgba(2, 8, 19, 0.9);
}
.category-tools {
@@ -611,7 +649,7 @@ a {
min-height: 0;
flex: 1;
flex-direction: column;
margin-top: 36px;
margin-top: 28px;
}
.category-tools__viewport {
@@ -620,19 +658,25 @@ a {
overflow-y: auto;
overscroll-behavior-y: contain;
flex: 1;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.36);
padding: 14px;
border: 1px solid rgba(222, 234, 255, 0.2);
border-radius: 20px;
background: rgba(82, 113, 151, 0.09);
background: rgba(5, 14, 29, 0.18);
box-shadow:
inset 0 2px 10px rgba(29, 56, 93, 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
0 14px 36px rgba(2, 8, 19, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.07);
}
.category-tools__viewport:focus-visible {
outline-offset: -4px;
}
.category-tools__viewport:has(> .category-empty-state) {
display: flex;
align-items: center;
justify-content: center;
}
.category-section-header {
display: flex;
flex: none;
@@ -645,34 +689,35 @@ a {
margin: 0;
font-size: 18px;
font-weight: 700;
text-shadow: 0 2px 12px rgba(2, 8, 19, 0.9);
}
.category-section-header__count {
padding: 4px 10px;
color: #334155;
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
border: 1px solid rgba(71, 104, 138, 0.24);
border: 1px solid var(--border-subtle);
border-radius: 999px;
background: rgba(238, 246, 253, 0.88);
background: rgba(10, 21, 38, 0.65);
}
.tool-card {
position: relative;
display: flex;
min-width: 0;
height: 96px;
height: 82px;
align-items: center;
gap: 16px;
padding: 20px 22px;
gap: 14px;
padding: 14px 16px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid var(--border-glass);
border: 1px solid rgba(222, 234, 255, 0.22);
border-radius: 18px;
background: var(--surface-card);
background: rgba(12, 24, 42, 0.65);
box-shadow: var(--shadow-soft);
-webkit-backdrop-filter: blur(16px) saturate(134%);
backdrop-filter: blur(16px) saturate(134%);
-webkit-backdrop-filter: blur(10px) saturate(108%);
backdrop-filter: blur(10px) saturate(108%);
transition:
transform 180ms ease,
background 180ms ease,
@@ -681,8 +726,9 @@ a {
.tool-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.54);
background: var(--surface-card-hover);
border-color: rgba(222, 234, 255, 0.42);
background: rgba(25, 43, 65, 0.84);
box-shadow: var(--shadow-medium);
}
.tool-card__icon {
@@ -739,7 +785,7 @@ a {
padding: 0;
list-style: none;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px 24px;
gap: 14px 16px;
}
.tool-grid__item {
@@ -757,9 +803,12 @@ a {
}
.category-tools__viewport > .category-empty-state {
height: 100%;
min-height: 0;
padding-block: 20px;
width: min(100%, 440px);
min-height: 200px;
flex: none;
padding: 20px;
border-radius: 14px;
background: rgba(10, 21, 38, 0.65);
}
.category-empty-state > svg {
@@ -822,14 +871,14 @@ a {
color: var(--primary-hover);
font-size: 15px;
font-weight: 700;
background: rgba(219, 234, 254, 0.7);
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.08);
background: rgba(138, 180, 238, 0.16);
box-shadow: inset 0 0 0 1px rgba(169, 201, 255, 0.13);
}
.local-search-results,
.sidebar__nav,
.category-tools__viewport {
scrollbar-color: rgba(100, 116, 139, 0.32) transparent;
scrollbar-color: rgba(185, 206, 233, 0.34) transparent;
scrollbar-width: thin;
}
@@ -860,45 +909,43 @@ a {
}
@media (max-width: 767px) {
/* Keep category navigation usable without squeezing the boxed content. */
.app-shell:has(.category-page) .sidebar {
/* A shared top rail keeps home and category navigation usable on phones. */
.app-shell .sidebar {
inset: 12px 12px auto;
width: auto;
height: 124px;
}
.app-shell:has(.category-page) .sidebar__header,
.app-shell:has(.category-page)[data-sidebar-collapsed="true"]
.sidebar__header {
.app-shell .sidebar__header,
.app-shell[data-sidebar-collapsed="true"] .sidebar__header {
min-height: 56px;
flex-direction: row;
padding: 0 12px;
}
.app-shell:has(.category-page) .sidebar__header > button {
.app-shell .sidebar__header > button {
display: none;
}
.app-shell:has(.category-page) .sidebar__brand {
.app-shell .sidebar__brand {
justify-content: flex-start;
}
.app-shell:has(.category-page) .sidebar__nav,
.app-shell:has(.category-page)[data-sidebar-collapsed="true"] .sidebar__nav {
.app-shell .sidebar__nav,
.app-shell[data-sidebar-collapsed="true"] .sidebar__nav {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
padding: 8px 12px 12px;
}
.app-shell:has(.category-page) .sidebar__categories {
.app-shell .sidebar__categories {
flex: none;
flex-direction: row;
}
.app-shell:has(.category-page) .sidebar__nav-item,
.app-shell:has(.category-page)[data-sidebar-collapsed="true"]
.sidebar__nav-item {
.app-shell .sidebar__nav-item,
.app-shell[data-sidebar-collapsed="true"] .sidebar__nav-item {
width: auto;
height: 40px;
min-height: 40px;
@@ -907,8 +954,8 @@ a {
padding-inline: 12px;
}
.app-shell:has(.category-page) .sidebar__site-name.sr-only,
.app-shell:has(.category-page) .sidebar__label.sr-only {
.app-shell .sidebar__site-name.sr-only,
.app-shell .sidebar__label.sr-only {
position: static;
width: auto;
height: auto;
@@ -918,20 +965,44 @@ a {
white-space: nowrap;
}
.app-shell:has(.category-page) .app-shell__content {
.app-shell .app-shell__content {
margin-left: 0;
}
.home-content {
padding: 188px 20px 48px;
}
.search-box {
margin-top: 32px;
}
.quick-links {
margin-top: 36px;
}
.quick-links__grid {
gap: 12px;
}
.quick-link-card {
padding-inline: 14px;
}
.category-page {
padding: 148px 12px 12px;
}
.category-page__panel {
padding: 28px 20px 36px;
padding: 24px 12px 20px;
}
.category-header {
padding: 14px 16px;
}
.category-tools__viewport {
padding: 12px;
padding: 8px;
}
.category-header__title {
@@ -939,6 +1010,20 @@ a {
}
}
@media (max-width: 420px) {
.quick-links__grid {
grid-template-columns: minmax(0, 1fr);
}
.home-content {
padding-inline: 16px;
}
.search-box__shortcut {
display: none;
}
}
/* When the header would leave only a sliver of cards, let the page scroll instead. */
@media (max-height: 540px), (max-width: 767px) and (max-height: 700px) {
.category-page {
@@ -960,11 +1045,6 @@ a {
overflow: visible;
flex: none;
}
.category-tools__viewport > .category-empty-state {
height: auto;
min-height: 200px;
}
}
@media (max-width: 767px) and (max-height: 700px) {
+2 -2
View File
@@ -11,8 +11,8 @@ export const metadata: Metadata = {
}
export const viewport: Viewport = {
colorScheme: "light",
themeColor: "#dcebf5",
colorScheme: "dark",
themeColor: "#101a2a",
}
export default function RootLayout({
@@ -41,6 +41,13 @@
"icon": "circle-play",
"sort": 60
},
{
"id": "game-resources",
"title": "游戏资料",
"description": "游戏攻略维基、资料速查与实用规划工具。",
"icon": "book-search",
"sort": 65
},
{
"id": "life",
"title": "生活服务",
+181 -4
View File
@@ -9,9 +9,12 @@ const hasUniqueValues = (values: string[]) =>
new Set(values).size === values.length
describe("Polaris 静态数据", () => {
it("包含 7 个唯一且结构完整的分类", () => {
expect(categories).toHaveLength(7)
it("包含 8 个唯一且结构完整的分类", () => {
expect(categories).toHaveLength(8)
expect(hasUniqueValues(categories.map(({ id }) => id))).toBe(true)
expect(hasUniqueValues(categories.map(({ sort }) => String(sort)))).toBe(
true,
)
for (const category of categories) {
expect(category).toEqual({
@@ -27,9 +30,9 @@ describe("Polaris 静态数据", () => {
}
})
it("包含 45 条合法链接且分类引用均存在", () => {
it("包含 54 条合法链接且分类引用均存在", () => {
const categoryIds = new Set(categories.map(({ id }) => id))
expect(links).toHaveLength(45)
expect(links).toHaveLength(54)
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
for (const link of links) {
@@ -115,4 +118,178 @@ describe("Polaris 静态数据", () => {
}
})
})
describe("归类边界回归", () => {
const linkIdsByCategory = (categoryId: string) =>
links
.filter((link) => link.categoryId === categoryId)
.map(({ id }) => id)
.sort()
it("游戏维基与攻略工具归入 game-resources", () => {
expect(linkIdsByCategory("game-resources")).toEqual(
expect.arrayContaining([
"wiki-52poke",
"wiki-blhx",
"wiki-fgo",
"wiki-stardew",
"wiki-terraria",
]),
)
})
it("娱乐媒体保留影音社区与 ACG 百科,不混入游戏资料", () => {
const mediaIds = linkIdsByCategory("media")
expect(mediaIds).toEqual(
expect.arrayContaining([
"youtube",
"bilibili",
"spotify",
"nexusmods",
"pokemon-showdown",
"expreview",
"chairo",
"wiki-moegirl",
]),
)
for (const gameId of [
"wiki-52poke",
"wiki-blhx",
"wiki-fgo",
"wiki-stardew",
"wiki-terraria",
]) {
expect(mediaIds).not.toContain(gameId)
}
})
it("Nexus Mods 与 Pokemon Showdown 等实测工具仍留在娱乐媒体", () => {
const mediaIds = linkIdsByCategory("media")
expect(mediaIds).toContain("nexusmods")
expect(mediaIds).toContain("pokemon-showdown")
expect(linkIdsByCategory("game-resources")).not.toContain("nexusmods")
expect(linkIdsByCategory("game-resources")).not.toContain(
"pokemon-showdown",
)
})
})
describe("新增九条链接回归", () => {
const expectedNewLinks = [
{
id: "kzo",
url: "https://kzo.moe",
categoryId: "media",
},
{
id: "manhuagui",
url: "https://www.manhuagui.com",
categoryId: "media",
},
{
id: "linovelib",
url: "https://www.linovelib.com",
categoryId: "media",
},
{
id: "steam",
url: "https://store.steampowered.com",
categoryId: "media",
},
{
id: "tieba",
url: "https://tieba.baidu.com",
categoryId: "media",
},
{
id: "manga-bilibili",
url: "https://manga.bilibili.com",
categoryId: "media",
},
{
id: "zhihu",
url: "https://www.zhihu.com",
categoryId: "media",
},
{
id: "pixiv",
url: "https://www.pixiv.net",
categoryId: "design",
},
{
id: "aliyun-unified-console",
url: "https://home.console.aliyun.com",
categoryId: "efficiency",
},
] as const
it("九条新链接全部存在且总数为 54", () => {
expect(links).toHaveLength(54)
expect(
expectedNewLinks.every(({ id }) =>
links.some((link) => link.id === id),
),
).toBe(true)
})
it.each(expectedNewLinks)(
"$id 归入 $categoryId 且 URL 正确",
(expected) => {
const link = links.find(({ id }) => id === expected.id)
expect(link).toBeDefined()
expect(link).toMatchObject({
url: expected.url,
categoryId: expected.categoryId,
pinned: false,
})
},
)
})
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)
})
})
})
+128 -36
View File
@@ -266,47 +266,27 @@
"title": "神奇宝贝百科",
"description": "宝可梦中文资料维基",
"url": "https://wiki.52poke.com/wiki/%E4%B8%BB%E9%A1%B5",
"categoryId": "media",
"categoryId": "game-resources",
"pinned": false,
"keywords": ["宝可梦", "口袋妖怪", "攻略", "资料"],
"sort": 80
},
{
"id": "wiki-52poke-typechart",
"title": "宝可梦属性相克表",
"description": "宝可梦全属性克制关系速查",
"url": "https://wiki.52poke.com/wiki/%E5%B1%9E%E6%80%A7%E7%9B%B8%E5%85%8B%E8%A1%A8",
"categoryId": "media",
"pinned": false,
"keywords": ["宝可梦", "属性", "相克", "速查"],
"sort": 90
},
{
"id": "wiki-blhx",
"title": "碧蓝航线WIKI",
"description": "碧蓝航线官方中文百科",
"url": "https://wiki.biligame.com/blhx/%E9%A6%96%E9%A1%B5",
"categoryId": "media",
"categoryId": "game-resources",
"pinned": false,
"keywords": ["碧蓝航线", "手游", "攻略", "wiki"],
"sort": 100
},
{
"id": "wiki-blhx-boss",
"title": "碧蓝航线月度BOSS解析",
"description": "大型作战月度 BOSS 攻略解析",
"url": "https://wiki.biligame.com/blhx/%E5%A4%A7%E5%9E%8B%E4%BD%9C%E6%88%98%E6%9C%88%E5%BA%A6BOSS%E8%A7%A3%E6%9E%90",
"categoryId": "media",
"pinned": false,
"keywords": ["碧蓝航线", "boss", "攻略", "大型作战"],
"sort": 110
},
{
"id": "wiki-fgo",
"title": "Mooncell",
"description": "FGO 中文维基与攻略资料站",
"url": "https://fgo.wiki/w/%E9%A6%96%E9%A1%B5",
"categoryId": "media",
"categoryId": "game-resources",
"pinned": false,
"keywords": ["fgo", "mooncell", "从者", "攻略"],
"sort": 120
@@ -316,7 +296,7 @@
"title": "星露谷物语中文维基",
"description": "星露谷物语官方中文资料维基",
"url": "https://zh.stardewvalleywiki.com",
"categoryId": "media",
"categoryId": "game-resources",
"pinned": false,
"keywords": ["星露谷物语", "攻略", "作物", "wiki"],
"sort": 130
@@ -326,7 +306,7 @@
"title": "泰拉瑞亚中文维基",
"description": "泰拉瑞亚官方中文百科",
"url": "https://terraria.wiki.gg/zh",
"categoryId": "media",
"categoryId": "game-resources",
"pinned": false,
"keywords": ["泰拉瑞亚", "攻略", "物品", "wiki"],
"sort": 140
@@ -341,16 +321,6 @@
"keywords": ["动漫", "acg", "百科", "二次元"],
"sort": 150
},
{
"id": "farmgamehub",
"title": "星露谷洒水器规划器",
"description": "星露谷物语洒水器布局可视化工具",
"url": "https://www.farmgamehub.com/zh/tools/stardew-sprinklers",
"categoryId": "media",
"pinned": false,
"keywords": ["星露谷物语", "洒水器", "布局", "工具"],
"sort": 160
},
{
"id": "iodraw",
"title": "iodraw",
@@ -406,7 +376,8 @@
"id": "aliyun-console",
"title": "阿里云轻量控制台",
"description": "阿里云轻量应用服务器控制台",
"url": "https://swas.console.aliyun.com",
"url": "https://console.aliyun.com",
"icon": "local:ali-cloud",
"categoryId": "efficiency",
"pinned": false,
"keywords": ["阿里云", "控制台", "轻量服务器", "云服务"],
@@ -472,5 +443,126 @@
"pinned": false,
"keywords": ["地图", "导航", "出行", "路线"],
"sort": 10
},
{
"id": "kzo",
"title": "Kmoe 漫画",
"description": "Kindle/EPUB 漫画下载与推送",
"url": "https://kzo.moe",
"categoryId": "media",
"pinned": false,
"keywords": ["漫画", "kindle", "epub", "推送"],
"sort": 160
},
{
"id": "manhuagui",
"title": "漫画柜",
"description": "在线漫画阅读站点",
"url": "https://www.manhuagui.com",
"categoryId": "media",
"pinned": false,
"keywords": ["漫画", "在线阅读", "动漫"],
"sort": 170
},
{
"id": "linovelib",
"title": "轻小说文库",
"description": "在线轻小说阅读站点",
"url": "https://www.linovelib.com",
"categoryId": "media",
"pinned": false,
"keywords": ["轻小说", "在线阅读", "文库"],
"sort": 180
},
{
"id": "steam",
"title": "Steam",
"description": "PC 游戏数字发行商店",
"url": "https://store.steampowered.com",
"categoryId": "media",
"pinned": false,
"keywords": ["游戏", "商店", "平台"],
"sort": 190
},
{
"id": "tieba",
"title": "百度贴吧",
"description": "百度旗下主题兴趣社区",
"url": "https://tieba.baidu.com",
"categoryId": "media",
"pinned": false,
"keywords": ["贴吧", "论坛", "社区", "百度"],
"sort": 200
},
{
"id": "manga-bilibili",
"title": "哔哩哔哩漫画",
"description": "哔哩哔哩正版漫画平台",
"url": "https://manga.bilibili.com",
"categoryId": "media",
"pinned": false,
"keywords": ["漫画", "哔哩哔哩", "正版", "阅读"],
"sort": 210
},
{
"id": "zhihu",
"title": "知乎",
"description": "中文问答社区",
"url": "https://www.zhihu.com",
"categoryId": "media",
"pinned": false,
"keywords": ["问答", "社区", "知识"],
"sort": 220
},
{
"id": "pixiv",
"title": "pixiv",
"description": "插画作品分享社区",
"url": "https://www.pixiv.net",
"categoryId": "design",
"pinned": false,
"keywords": ["插画", "绘画", "灵感", "二次元"],
"sort": 40
},
{
"id": "aliyun-unified-console",
"title": "阿里云统一控制台",
"description": "阿里云产品统一管理控制台",
"url": "https://home.console.aliyun.com",
"icon": "local:ali-cloud",
"categoryId": "efficiency",
"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
}
]
@@ -72,6 +72,7 @@ describe("CategoryContent", () => {
"learning",
"efficiency",
"media",
"game-resources",
"life",
] as const)("%s 分类展示服务提供的数量", (categoryId) => {
const category = getCategoryById(categoryId)
@@ -82,4 +83,24 @@ describe("CategoryContent", () => {
expect(screen.getByText(`共 ${links.length} 个`)).toBeInTheDocument()
})
it("归类边界:游戏资料面板只渲染 5 个游戏维基卡片", () => {
const category = getCategoryById("game-resources")
if (!category) throw new Error("测试数据缺少 game-resources 分类")
const links = getLinksByCategory(category.id)
render(<CategoryContent category={category} links={links} />)
expect(links).toHaveLength(5)
const toolLinks = screen
.getByRole("list", { name: "工具列表" })
.querySelectorAll("a")
expect(toolLinks).toHaveLength(5)
expect(
screen.queryByRole("link", { name: /萌娘百科/ }),
).not.toBeInTheDocument()
expect(
screen.queryByRole("link", { name: /Nexus Mods/ }),
).not.toBeInTheDocument()
})
})
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { Category } from "@/types/navigation"
@@ -9,9 +10,18 @@ const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "开发", icon: "code", sort: 10 },
]
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
describe("AppShell", () => {
beforeEach(() => window.localStorage.clear())
afterEach(() => vi.restoreAllMocks())
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it("切换侧栏并持久化状态", () => {
render(
@@ -198,4 +208,67 @@ describe("AppShell", () => {
"page",
)
})
it("展开时分类文字可见,悬停或聚焦不显示提示", async () => {
const user = userEvent.setup()
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
const link = screen.getByRole("link", { name: "开发工具" })
expect(link.querySelector(".sidebar__label")).not.toHaveClass("sr-only")
await user.hover(link)
expect(link).not.toHaveAttribute("data-state")
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()
link.focus()
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()
})
it("收起时悬停分类图标显示分类名提示", async () => {
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
const user = userEvent.setup()
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={true}
>
<main>首页内容</main>
</AppShell>,
)
const link = screen.getByRole("link", { name: "开发工具" })
expect(link.querySelector(".sidebar__label")).toHaveClass("sr-only")
await user.hover(link)
expect(await screen.findByRole("tooltip")).toHaveTextContent("开发工具")
})
it("收起时键盘聚焦分类图标显示分类名提示", async () => {
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
const user = userEvent.setup()
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={true}
>
<main>首页内容</main>
</AppShell>,
)
await user.tab()
await user.tab()
await user.tab()
await user.tab()
const link = screen.getByRole("link", { name: "开发工具" })
expect(link).toHaveFocus()
expect(await screen.findByRole("tooltip")).toHaveTextContent("开发工具")
})
})
@@ -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,
@@ -81,11 +81,9 @@ export function Sidebar({
<div className="sidebar__categories">
{categories.map((category) => {
const isActive = category.id === activeCategoryId
return (
<Tooltip key={category.id}>
<TooltipTrigger asChild>
const categoryLink = (
<Link
key={category.id}
className={cn(
"sidebar__nav-item",
isActive && "sidebar__nav-item--active",
@@ -94,15 +92,19 @@ export function Sidebar({
aria-current={isActive ? "page" : undefined}
>
<CategoryIcon name={category.icon} />
<span
className={cn("sidebar__label", collapsed && "sr-only")}
>
<span className={cn("sidebar__label", collapsed && "sr-only")}>
{category.title}
</span>
</Link>
</TooltipTrigger>
)
return collapsed ? (
<Tooltip key={category.id}>
<TooltipTrigger asChild>{categoryLink}</TooltipTrigger>
<TooltipContent side="right">{category.title}</TooltipContent>
</Tooltip>
) : (
categoryLink
)
})}
</div>
@@ -16,6 +16,7 @@ describe("静态数据 service", () => {
"learning",
"efficiency",
"media",
"game-resources",
"life",
])
expect(first).not.toBe(second)
@@ -55,6 +56,8 @@ describe("静态数据 service", () => {
"iconfont",
"haowallpaper",
"iodraw",
"pixiv",
"lucide",
])
expect(getLinksByCategory("life").map(({ id }) => id)).toEqual(["amap"])
})