Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b72b14a818 | ||
|
|
306107af83 | ||
|
|
ab83ad96b4 | ||
|
|
8c0c9978e5 | ||
|
|
3f16384376 | ||
|
|
8b320817ed | ||
|
|
e4fc4af912 | ||
|
|
d63b006135 | ||
|
|
3280c8a7bc | ||
|
|
d45d713a50 | ||
|
|
d375463de5 | ||
|
|
86f6eb39b2 | ||
|
|
d53dbac88e | ||
|
|
fec2a97936 | ||
|
|
144b8abe49 | ||
|
|
2c674b7f2d | ||
|
|
09e5735162 | ||
|
|
abe4dec0b1 | ||
|
|
6e839019f5 | ||
|
|
95e2799721 | ||
|
|
35aaa0e17e | ||
|
|
e37cf9936e | ||
|
|
6641d181ab | ||
|
|
39bde32376 | ||
|
|
1a48960022 | ||
|
|
e0676bfc03 | ||
|
|
691a169fe9 | ||
|
|
7817e19783 | ||
|
|
babd8f2793 | ||
|
|
4a032ea742 | ||
|
|
64cc3ec381 | ||
|
|
e9f3bf6934 | ||
|
|
5a46186bde | ||
|
|
9754d61ffe | ||
|
|
20fa8f4d5a | ||
|
|
9714bd4ab5 | ||
|
|
b23bd2d5ec | ||
|
|
7eaa9e6f80 | ||
|
|
0913235382 | ||
|
|
0686bd0d87 | ||
|
|
a3272858ad | ||
|
|
2b442221b2 | ||
|
|
817344fc66 | ||
|
|
f175b3a66d | ||
|
|
123b607dc4 | ||
|
|
3902c6996c | ||
|
|
ef6d737d91 | ||
|
|
d4cad276e3 | ||
|
|
a1156d6977 | ||
|
|
f5f2314589 | ||
|
|
0ae0be2f87 | ||
|
|
88861a6da1 | ||
|
|
a52408862d | ||
|
|
6befa47926 |
@@ -0,0 +1,8 @@
|
|||||||
|
# 统一行尾为 LF:Windows / WSL 共享同一仓库时防止行尾伪差异
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# 二进制文件不做任何转换
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
@@ -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"
|
||||||
@@ -45,3 +45,5 @@ Thumbs.db
|
|||||||
|
|
||||||
# ---- Note 目录 ----
|
# ---- Note 目录 ----
|
||||||
note/项目/
|
note/项目/
|
||||||
|
docs/plans
|
||||||
|
go/projects/lyra/data
|
||||||
@@ -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 |
|
| `note/` | 个人笔记(非代码),`note/项目/` 被 gitignore |
|
||||||
| `dart/` | 预留,尚未建立 |
|
| `dart/` | 预留,尚未建立 |
|
||||||
|
|
||||||
## Go 约定
|
## Go 约定
|
||||||
|
|
||||||
- **Module 在 `go/` 目录下,不在仓库根**——所有 go 命令需在 `go/` 内执行
|
- Go 代码集中在 `go/`,目录本身不是 module,按用途分两区:
|
||||||
- 每个叶子目录 = 一个独立小程序(`package main` + `main.go`)
|
- `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))
|
||||||
- 主题目录(basics/concurrency/stdlib/patterns/algorithms/web/testing/project)可随时增删
|
- **go 命令在对应 module 的根目录执行**:练习代码在 `go/practice/` 内执行;projects 每个项目在自己的目录内执行
|
||||||
- 测试文件用 `*_test.go`,运行单个测试:`cd go && go test ./testing/ -run TestSliceAppend`
|
- 练习区中每个叶子目录通常是一个独立小程序(`package main` + `main.go`),多个小程序并存互不冲突;库包(如算法实现)与只有测试文件的包也是合法形态
|
||||||
|
- 主题目录可随时增删;写新程序时挑最贴的主题,在里面建一个新子目录
|
||||||
|
- 测试文件用 `*_test.go`,运行单个测试:`cd go/practice && go test ./testing/slice -run TestSliceAppend`
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
```
|
```
|
||||||
# Go——所有命令在 go/ 目录下执行
|
# Go 练习——在 go/practice/ 目录下执行
|
||||||
cd go
|
cd go/practice
|
||||||
go run ./<主题>/<程序名>/ # 运行单个程序
|
go run ./basics/runtime/ # 运行单个程序
|
||||||
go test ./... # 运行全部测试
|
go test ./... # 运行全部测试(部分练习阻塞/依赖环境,不保证全过)
|
||||||
go test ./testing/ -v # 运行 testing 包测试
|
go test ./testing/... -v # 运行 testing 包及其子包测试
|
||||||
|
go mod tidy # 整理依赖
|
||||||
|
|
||||||
|
# Go projects——在 go/projects/<项目名>/ 各自目录下执行
|
||||||
|
go run . # 运行项目(按实际入口调整)
|
||||||
go mod tidy # 整理依赖
|
go mod tidy # 整理依赖
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,3 +40,5 @@ go mod tidy # 整理依赖
|
|||||||
- 仓库无 CI、无 pre-commit hooks、无代码检查——每个程序自行保证可编译即可
|
- 仓库无 CI、无 pre-commit hooks、无代码检查——每个程序自行保证可编译即可
|
||||||
- 笔记和练习代码严格分离:程序相关说明写在程序目录的 README.md,系统性知识放 `note/`
|
- 笔记和练习代码严格分离:程序相关说明写在程序目录的 README.md,系统性知识放 `note/`
|
||||||
- `.gitignore` 已覆盖 Go 二进制、Dart/Flutter 构建产物、IDE 配置、操作系统文件
|
- `.gitignore` 已覆盖 Go 二进制、Dart/Flutter 构建产物、IDE 配置、操作系统文件
|
||||||
|
- 编辑、暂存或提交代码、笔记、配置时留意真实数据库连接、用户名密码、令牌密钥、私钥、个人路径及意外二进制/生成物
|
||||||
|
- 发现疑似真实敏感信息先暂停,只报告路径、行号和类型(不复述值),建议脱敏或移除、必要时轮换;演示占位值不算真实凭据,经用户确认无敏感风险后可继续
|
||||||
|
|||||||
@@ -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
|
- `dart/` — Dart
|
||||||
- `ts/` — TypeScript / JavaScript(每个实验独立 npm 项目,约定见 [ts/README.md](ts/README.md))
|
- `ts/` — TypeScript / JavaScript(每个实验独立 npm 项目,约定见 [ts/README.md](ts/README.md))
|
||||||
|
|
||||||
|
|||||||
+10
-20
@@ -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 行为等) |
|
| [`practice/`](practice/README.md) | 练习区:共用一个 module(`go.mod` 在 `practice/` 内),语法 / 算法 / 测试等日常练手代码 |
|
||||||
| `concurrency/` | 并发:goroutine / channel / select / context |
|
| [`projects/`](projects/README.md) | 项目区:每个子目录是独立 module 的完整小应用,自带 `go.mod`、自行管理依赖 |
|
||||||
| `stdlib/` | 标准库练习(net/http、encoding/json、io…) |
|
|
||||||
| `patterns/` | 设计模式 |
|
|
||||||
| `algorithms/` | 数据结构与算法 |
|
|
||||||
| `web/` | net/http、Gin 等框架 |
|
|
||||||
| `testing/` | 练习 Go 测试机制(`*_test.go`、`go test`、table-driven、testify) |
|
|
||||||
| `project/` | 完整小项目(多文件) |
|
|
||||||
|
|
||||||
> 主题目录可随时增删。写新程序时挑最贴的主题,在里面建一个新子目录放 `main.go` 即可。
|
## 边界
|
||||||
|
|
||||||
|
- 写小段代码验证语言行为、练语法和标准库 → 放 `practice/` 对应主题目录。
|
||||||
|
- 程序长成完整应用,或依赖需要独立于练习区管理 → 在 `projects/` 下新建目录,单独 `go mod init`。
|
||||||
|
- 两个区互不包含:`practice/` 的 module 不覆盖 `projects/`,每个 projects 项目也是独立 module。
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
module lab
|
|
||||||
|
|
||||||
go 1.26.2
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/richardlehane/mscfb v1.0.7 // indirect
|
|
||||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
|
||||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
|
||||||
github.com/xuri/efp v0.0.1 // indirect
|
|
||||||
github.com/xuri/excelize/v2 v2.11.0 // indirect
|
|
||||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
|
||||||
golang.org/x/crypto v0.53.0 // indirect
|
|
||||||
golang.org/x/net v0.56.0 // indirect
|
|
||||||
golang.org/x/text v0.38.0 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
|
|
||||||
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
|
||||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
|
||||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
|
||||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
|
||||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
|
||||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
|
||||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
|
||||||
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
|
|
||||||
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
|
|
||||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
|
||||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
|
||||||
@@ -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`。
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package linkedlist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node 是单向链表的一个节点,持有数据与指向下一个节点的指针。
|
||||||
|
type Node[T comparable] struct {
|
||||||
|
Data T
|
||||||
|
Next *Node[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedList 是持有头指针的单向链表,零值即为空表。
|
||||||
|
type LinkedList[T comparable] struct {
|
||||||
|
head *Node[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertHead 将新节点插入表头。
|
||||||
|
func (l *LinkedList[T]) InsertHead(data T) {
|
||||||
|
l.head = &Node[T]{Data: data, Next: l.head}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertTail 将新节点追加到表尾;空表时新节点直接成为表头。
|
||||||
|
func (l *LinkedList[T]) InsertTail(data T) {
|
||||||
|
newNode := &Node[T]{Data: data}
|
||||||
|
if l.head == nil {
|
||||||
|
l.head = newNode
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := l.head
|
||||||
|
for current.Next != nil {
|
||||||
|
current = current.Next
|
||||||
|
}
|
||||||
|
current.Next = newNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteHead 删除表头节点;空表时无操作。
|
||||||
|
func (l *LinkedList[T]) DeleteHead() {
|
||||||
|
if l.head != nil {
|
||||||
|
l.head = l.head.Next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTail 删除表尾节点;空表时无操作,仅剩一个节点时表变空。
|
||||||
|
func (l *LinkedList[T]) DeleteTail() {
|
||||||
|
switch {
|
||||||
|
case l.head == nil:
|
||||||
|
case l.head.Next == nil:
|
||||||
|
l.head = nil
|
||||||
|
default:
|
||||||
|
current := l.head
|
||||||
|
for current.Next.Next != nil {
|
||||||
|
current = current.Next
|
||||||
|
}
|
||||||
|
current.Next = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search 返回首个 Data 等于 data 的节点;不存在时返回 nil。
|
||||||
|
func (l *LinkedList[T]) Search(data T) *Node[T] {
|
||||||
|
for current := l.head; current != nil; current = current.Next {
|
||||||
|
if current.Data == data {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len 返回链表的节点个数。
|
||||||
|
func (l *LinkedList[T]) Len() int {
|
||||||
|
count := 0
|
||||||
|
for current := l.head; current != nil; current = current.Next {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse 原地反转链表。
|
||||||
|
func (l *LinkedList[T]) Reverse() {
|
||||||
|
var prev, current *Node[T]
|
||||||
|
current = l.head
|
||||||
|
for current != nil {
|
||||||
|
next := current.Next
|
||||||
|
current.Next = prev
|
||||||
|
prev = current
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
l.head = prev
|
||||||
|
}
|
||||||
|
|
||||||
|
// String 以 "1 -> 2 -> nil" 的格式输出整条链,便于打印与调试。
|
||||||
|
func (l LinkedList[T]) String() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for current := l.head; current != nil; current = current.Next {
|
||||||
|
fmt.Fprintf(&sb, "%v -> ", current.Data)
|
||||||
|
}
|
||||||
|
sb.WriteString("nil")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"lab/algorithms/linkedlist"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var list linkedlist.LinkedList[int] // 零值即为空表
|
||||||
|
list.InsertTail(3)
|
||||||
|
list.InsertTail(5)
|
||||||
|
list.InsertHead(1)
|
||||||
|
list.InsertHead(0)
|
||||||
|
fmt.Println(&list) // 0 -> 1 -> 3 -> 5 -> nil
|
||||||
|
|
||||||
|
list.DeleteHead()
|
||||||
|
list.DeleteTail()
|
||||||
|
fmt.Println(&list) // 1 -> 3 -> nil
|
||||||
|
|
||||||
|
found := list.Search(3)
|
||||||
|
if found != nil {
|
||||||
|
fmt.Printf("找到节点,Data = %d\n", found.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Reverse()
|
||||||
|
fmt.Println(&list) // 3 -> 1 -> nil
|
||||||
|
fmt.Printf("链表长度: %d\n", list.Len())
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
_ "net/http/pprof"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 来自文章 https://www.cnblogs.com/hwnd/p/20682814
|
||||||
|
func simulateRequest() {
|
||||||
|
timeout := 500 * time.Millisecond
|
||||||
|
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(timeout))
|
||||||
|
defer cancel()
|
||||||
|
resultCh := make(chan string) // 无缓冲!
|
||||||
|
|
||||||
|
// 启动 worker goroutine 处理"请求"
|
||||||
|
go func() {
|
||||||
|
time.Sleep(time.Duration(rand.Intn(500)+300) * time.Millisecond)
|
||||||
|
select {
|
||||||
|
case <-time.After(timeout):
|
||||||
|
fmt.Println("worker 超时,放弃处理")
|
||||||
|
case <-ctx.Done():
|
||||||
|
case resultCh <- fmt.Sprintf("处理完成,耗时 %dms", rand.Intn(500)+300):
|
||||||
|
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// TODO: 模拟请求取消或超时的情况(比如 500ms 后调用方不再关心结果)
|
||||||
|
// 如果 worker 还没完成,它就 leak 了
|
||||||
|
select {
|
||||||
|
case resultCh := <-resultCh:
|
||||||
|
fmt.Println(resultCh)
|
||||||
|
case <-ctx.Done():
|
||||||
|
fmt.Println("请求方取消了请求")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
simulateRequest()
|
||||||
|
fmt.Printf("第 %d 个请求处理完毕\n\n", i+1)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
fmt.Println("所有请求处理完毕")
|
||||||
|
// 运行后观察:goroutine 数量只增不减
|
||||||
|
http.ListenAndServe(":6060", nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
_ "net/http/pprof" // 注册 pprof 相关 handler
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memoryBlocks 保留一部分内存块,方便内存剖析观察。
|
||||||
|
// 这个全局变量会一直存活,便于分析堆上的对象。
|
||||||
|
var memoryBlocks [64][]byte
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 启动 pprof HTTP 服务
|
||||||
|
go func() {
|
||||||
|
log.Println("pprof server started at http://localhost:6060/debug/pprof/")
|
||||||
|
if err := http.ListenAndServe("localhost:6060", nil); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 模拟 CPU 密集型业务
|
||||||
|
go cpuIntensiveTask()
|
||||||
|
|
||||||
|
// 模拟内存分配型业务
|
||||||
|
go memoryIntensiveTask()
|
||||||
|
|
||||||
|
// 阻塞主协程,让程序持续运行
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fib 递归计算斐波那契数列,非常耗费 CPU
|
||||||
|
func fib(n int) int {
|
||||||
|
if n <= 1 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return fib(n-1) + fib(n-2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cpuIntensiveTask 循环执行 CPU 密集型计算
|
||||||
|
func cpuIntensiveTask() {
|
||||||
|
for {
|
||||||
|
_ = fib(30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryIntensiveTask 循环分配内存并保留在全局切片中
|
||||||
|
func memoryIntensiveTask() {
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
idx := i % len(memoryBlocks)
|
||||||
|
// 每次分配 1MB,替换掉旧的内存块
|
||||||
|
memoryBlocks[idx] = make([]byte, 1<<20) // 1 << 20 = 1MB
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
var retained [][]byte // 全局变量,用来保持对象可达
|
||||||
|
|
||||||
|
func printMemory(stage string) {
|
||||||
|
var m runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&m)
|
||||||
|
|
||||||
|
fmt.Printf("%s: HeapAlloc=%d MiB, TotalAlloc=%d MiB\n",
|
||||||
|
stage,
|
||||||
|
m.HeapAlloc/1024/1024,
|
||||||
|
m.TotalAlloc/1024/1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
runtime.GC()
|
||||||
|
printMemory("开始")
|
||||||
|
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
block := make([]byte, 1024*1024) // 每次创建约 1 MiB 的底层数组
|
||||||
|
block[0] = byte(i)
|
||||||
|
retained = append(retained, block)
|
||||||
|
}
|
||||||
|
printMemory("分配后")
|
||||||
|
|
||||||
|
retained = nil // 程序不再持有这些数组
|
||||||
|
runtime.GC()
|
||||||
|
printMemory("GC 后")
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mockUsers = map[string]string{
|
||||||
|
"zhangsan": "123456",
|
||||||
|
"lisi": "654321",
|
||||||
|
}
|
||||||
|
// secretKey = "your-secret-key"
|
||||||
|
hmacSecretKey = "5KiinhGcvoXFmgOttvgMWd9ugadE0KVP"
|
||||||
|
blockSecretKey = "OLZmfTWHGyjCfJoH1Y8SjbSY"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionTTL = 60 * 5
|
||||||
|
|
||||||
|
func sessionOptions(maxAge int) sessions.Options {
|
||||||
|
return sessions.Options{
|
||||||
|
Path: "/",
|
||||||
|
Domain: "", // 设置为 "" 表示当前域名
|
||||||
|
MaxAge: maxAge, // 单位为秒。0 表示不写Max-Age(浏览器会话cookie); -1 表示删除 cookie
|
||||||
|
Secure: false, // 生产走 HTTPS 时改 true
|
||||||
|
HttpOnly: true, // true 表示禁止 JS 读取 cookie
|
||||||
|
SameSite: http.SameSiteStrictMode, // 严格模式,仅在同源请求中发送 cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代码来自文章 https://www.cnblogs.com/XY-Heruo/p/22864951
|
||||||
|
func main() {
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// Create cookie-based session store with a secret key
|
||||||
|
// 传单个密钥时, 只做签名认证,不加密cookie内容. 认证密钥建议 32 或 64 字节
|
||||||
|
// 传一对密钥时,第一个用于认证(HMAC),第二个用于加密(AES-128, AES-192, AES-256)。加密密钥必须是16、24或32字节长度,对应 AES-128/192/256。
|
||||||
|
// 多对密钥用于密钥轮换,第一对用于当前加密和认证,后续的用于解密和认证旧的cookie。Example: cookie.NewStore([]byte("newAuth"), []byte("newEnc"), []byte("oldAuth"), []byte("oldEnc"))
|
||||||
|
store := cookie.NewStore([]byte(hmacSecretKey), []byte(blockSecretKey))
|
||||||
|
store.Options(sessionOptions(sessionTTL)) // 全局设置 cookie 过期时间为 5 分钟
|
||||||
|
r.Use(sessions.Sessions("mysession", store))
|
||||||
|
|
||||||
|
r.POST("/login", login)
|
||||||
|
|
||||||
|
auth := r.Group("/", authRequired())
|
||||||
|
|
||||||
|
auth.GET("/profile", profile)
|
||||||
|
auth.POST("/logout", logout)
|
||||||
|
|
||||||
|
r.Run(":8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
username := c.PostForm("username")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
|
||||||
|
if username == "" || password == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "username or password is empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mockUsers[username] != password {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Set("user", username)
|
||||||
|
session.Set("expire_at", time.Now().Add(time.Second*sessionTTL).Unix()) // 设置过期时间
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "save session failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "logged in"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Clear()
|
||||||
|
session.Options(sessionOptions(-1)) // 让浏览器删除 cookie
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "save session failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func profile(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
user := session.Get("user").(string)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||||
|
}
|
||||||
|
|
||||||
|
func authRequired() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
if session.Get("user") == nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if expireAt, ok := session.Get("expire_at").(int64); !ok || time.Now().Unix() > expireAt {
|
||||||
|
session.Clear()
|
||||||
|
session.Options(sessionOptions(-1))
|
||||||
|
session.Save()
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "expired session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
module lab
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.2 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.1 // 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.3 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
github.com/gorilla/sessions v1.4.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.23 // 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.4.3 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.60.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.8.0 // indirect
|
||||||
|
golang.org/x/arch v0.29.0 // indirect
|
||||||
|
golang.org/x/net v0.57.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.41.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
|
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||||
|
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||||
|
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||||
|
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||||
|
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/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sessions v1.1.2 h1:utWRqOHT/kbqxxqF/mtutV9wfGLi1+uu5YuvWUhj/Ow=
|
||||||
|
github.com/gin-contrib/sessions v1.1.2/go.mod h1:qRxYwA/TYJw0wv28oFzs4gY/8G8z69id862SNgX9Gmw=
|
||||||
|
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=
|
||||||
|
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.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||||
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||||
|
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||||
|
github.com/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/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||||
|
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
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.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.4.3/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/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||||
|
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.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||||
|
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||||
|
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
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/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.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.8.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=
|
||||||
|
golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
|
||||||
|
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
|
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||||
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidPassword = errors.New("invalid password")
|
||||||
|
ErrInvalidHash = errors.New("invalid password hash")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Hasher struct {
|
||||||
|
cost int // cost 可以理解成 bcrypt 的“计算难度”参数,cost 越高,计算 bcrypt hash 就越慢,攻击者暴力破解密码也越贵
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHasher(cost int) (*Hasher, error) {
|
||||||
|
if cost < bcrypt.MinCost || cost > bcrypt.MaxCost {
|
||||||
|
return nil, errors.New("invalid bcrypt cost")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Hasher{
|
||||||
|
cost: cost,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash 用于注册、重置密码、修改密码。
|
||||||
|
func (h *Hasher) Hash(password string) (string, error) {
|
||||||
|
if len(password) == 0 {
|
||||||
|
return "", ErrInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// bcrypt 只使用密码前 72 bytes。
|
||||||
|
// 生产环境建议显式拒绝,而不是静默截断。
|
||||||
|
if len([]byte(password)) > 72 {
|
||||||
|
return "", ErrInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword(
|
||||||
|
[]byte(password),
|
||||||
|
h.cost,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 用于登录时验证密码。
|
||||||
|
func (h *Hasher) Verify(password, encodedHash string) (bool, error) {
|
||||||
|
if len(password) == 0 || len([]byte(password)) > 72 {
|
||||||
|
return false, ErrInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
if encodedHash == "" {
|
||||||
|
return false, ErrInvalidHash
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bcrypt.CompareHashAndPassword(
|
||||||
|
[]byte(encodedHash),
|
||||||
|
[]byte(password),
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return true, nil
|
||||||
|
|
||||||
|
case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
|
||||||
|
return false, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 例如数据库里的 hash 已损坏或格式不合法。
|
||||||
|
return false, ErrInvalidHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsRehash 判断数据库中的 hash 是否使用了较旧的 cost。
|
||||||
|
func (h *Hasher) NeedsRehash(encodedHash string) bool {
|
||||||
|
cost, err := bcrypt.Cost([]byte(encodedHash))
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return cost != h.cost
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBcrypt(t *testing.T) {
|
||||||
|
oldHasher, err := NewHasher(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newHasher, err := NewHasher(12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
password := "password"
|
||||||
|
oldHash, err := oldHasher.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log(oldHash)
|
||||||
|
newHash, err := newHasher.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log(newHash)
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(oldHash), []byte(password))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashPassword(t *testing.T) {
|
||||||
|
hasher, err := NewHasher(12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
password := "password"
|
||||||
|
hash, err := hasher.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log(hash)
|
||||||
|
if ok, err := hasher.Verify(password, hash); err != nil || !ok {
|
||||||
|
t.Fatal("verify failed")
|
||||||
|
}
|
||||||
|
if ok := hasher.NeedsRehash(hash); !ok {
|
||||||
|
t.Fatal("needs rehash failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateTime(t *testing.T) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
_, err := bcrypt.GenerateFromPassword(
|
||||||
|
[]byte("TestPassword123!"),
|
||||||
|
14,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log(time.Since(start), err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
// 引入两个包进行对比
|
||||||
|
jsonV1 "encoding/json"
|
||||||
|
jsonV2 "encoding/json/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestV2Null(t *testing.T) {
|
||||||
|
type Product struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
p := Product{
|
||||||
|
Name: "product1",
|
||||||
|
}
|
||||||
|
data1, _ := jsonV1.Marshal(p)
|
||||||
|
data2, _ := jsonV2.Marshal(p)
|
||||||
|
t.Log(string(data1))
|
||||||
|
t.Log(string(data2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOmitZeroVsEmpty(t *testing.T) {
|
||||||
|
// ===== V1 结构体:使用 omitempty =====
|
||||||
|
type EventV1 struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"` // ⚠️ omitempty 对 time.Time 无效
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== V2 结构体:使用 omitzero =====
|
||||||
|
type EventV2 struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitzero"` // ✅ omitzero 能识别 IsZero()
|
||||||
|
}
|
||||||
|
// 都不给 CreatedAt 赋值,保持 Go 的零值 time.Time{}
|
||||||
|
|
||||||
|
// 1. 测试 v1 + omitempty
|
||||||
|
e1 := EventV1{Title: "Go Meetup"}
|
||||||
|
data1, _ := jsonV1.Marshal(e1)
|
||||||
|
fmt.Println("v1 + omitempty 输出:", string(data1))
|
||||||
|
|
||||||
|
// 2. 测试 v2 + omitzero
|
||||||
|
e2 := EventV2{Title: "Go Meetup"}
|
||||||
|
data2, _ := jsonV2.Marshal(e2)
|
||||||
|
fmt.Println("v2 + omitzero 输出:", string(data2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiStyle(t *testing.T) {
|
||||||
|
type MultiStyleUser struct {
|
||||||
|
// 1. 严格匹配:JSON key 必须精确是 "user_id"
|
||||||
|
ID string `json:"user_id,case:strict"`
|
||||||
|
|
||||||
|
// 2. 忽略大小写:JSON key 是 "Name"、"NAME"、"name" 都能成功赋值
|
||||||
|
Name string `json:"name,case:ignore"`
|
||||||
|
}
|
||||||
|
// 假设第三方传来的 JSON 风格混杂
|
||||||
|
jsonData := []byte(`{
|
||||||
|
"user_id": "1001",
|
||||||
|
"NAME": "Gopher"
|
||||||
|
}`)
|
||||||
|
var m MultiStyleUser
|
||||||
|
jsonV2.Unmarshal(jsonData, &m)
|
||||||
|
t.Log(m)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package testing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormal(t *testing.T) {
|
||||||
|
numbers := map[int][]int{
|
||||||
|
1: {8, 2, 4, 4},
|
||||||
|
2: {0, 5, 3},
|
||||||
|
3: {1},
|
||||||
|
}
|
||||||
|
for _, numbers := range numbers {
|
||||||
|
middle := GetMiddNumber(numbers)
|
||||||
|
t.Log(middle)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMiddNumber(numbers []int) float64 {
|
||||||
|
sort.Ints(numbers)
|
||||||
|
length := len(numbers)
|
||||||
|
isOddNUmber := length%2 == 1
|
||||||
|
if !isOddNUmber {
|
||||||
|
return float64(numbers[length/2-1]+numbers[length/2]) / 2.0
|
||||||
|
}
|
||||||
|
return float64(numbers[length/2])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannel(t *testing.T) {
|
||||||
|
var ball = make(chan string)
|
||||||
|
kickBall := func(playerName string) {
|
||||||
|
for {
|
||||||
|
t.Log(<-ball, "传球", "\n")
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
ball <- playerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go kickBall("张三")
|
||||||
|
go kickBall("李四")
|
||||||
|
go kickBall("王二麻子")
|
||||||
|
go kickBall("刘大")
|
||||||
|
ball <- "裁判" // 开球
|
||||||
|
var c chan bool // 一个零值nil通道
|
||||||
|
<-c // 永久阻塞在此
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeature1(t *testing.T) {
|
||||||
|
type Person struct {
|
||||||
|
Sex string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
type Student struct {
|
||||||
|
StudentID int
|
||||||
|
Person
|
||||||
|
}
|
||||||
|
|
||||||
|
s1 := Student{
|
||||||
|
StudentID: 1,
|
||||||
|
Sex: "male",
|
||||||
|
Name: "张三",
|
||||||
|
}
|
||||||
|
t.Log(s1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeature2(t *testing.T) {
|
||||||
|
// Go 1.27 之前:必须手动写出类型实参 [int],否则编译报错
|
||||||
|
fn1 := IntToString(FormatToString[int])
|
||||||
|
fn2 := IntToString(FormatToString)
|
||||||
|
|
||||||
|
oldFormat := []IntToString{FormatToString[int]}
|
||||||
|
newFormat := []IntToString{FormatToString}
|
||||||
|
|
||||||
|
t.Log(fn1(1))
|
||||||
|
t.Log(fn2(2))
|
||||||
|
for i, fn := range oldFormat {
|
||||||
|
t.Log(fn(i))
|
||||||
|
}
|
||||||
|
for i, fn := range newFormat {
|
||||||
|
t.Log(fn(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一个泛型格式化函数
|
||||||
|
func FormatToString[T any](v T) string {
|
||||||
|
return fmt.Sprintf("value: %v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 具名函数类型:接收一个 int,返回 string
|
||||||
|
type IntToString func(int) string
|
||||||
|
|
||||||
|
func TestSort(t *testing.T) {
|
||||||
|
slice := []int{8, 2, 4, 4}
|
||||||
|
Sort1(slice)
|
||||||
|
t.Log(slice)
|
||||||
|
}
|
||||||
|
func Sort1(slice []int) {
|
||||||
|
for i := 0; i < len(slice)-1; i++ {
|
||||||
|
for j := 0; j < len(slice)-1-i; j++ {
|
||||||
|
if slice[j] > slice[j+1] {
|
||||||
|
slice[j], slice[j+1] = slice[j+1], slice[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 # 整理依赖
|
||||||
|
```
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Lyra
|
||||||
|
|
||||||
|
链接、分类、设置等资源的后端服务(实现中)。目标结构与技术选型见
|
||||||
|
[docs/architecture.md](docs/architecture.md)。
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
HTTP 通路已延伸到数据库:启动时加载本地 `.env.local` 与 `etc/lyra.toml`,经
|
||||||
|
sqlx 连接 PostgreSQL(注册 pgx stdlib 驱动,驱动名取自配置),组装依赖后注册
|
||||||
|
`GET /test`——由 `handler.TestHandler` 调用 `model.CategoryModel.FindAll` 查询
|
||||||
|
`public.categories`(当前固定 `sort >= 1`、按 sort/id 排序、limit 10),并经
|
||||||
|
显式转换为本轮新增的 `api.CategoryResponse` 响应 DTO 后输出。该接口用于验证
|
||||||
|
handler→model→数据库链路,不是完整业务 API。
|
||||||
|
|
||||||
|
数据库连接代码当前在入口 `main` 中,`internal/postgres` 与 `internal/service`
|
||||||
|
仍是占位(仅 `AGENTS.md`),业务 service 未实现;连接与查询未设置超时,连接
|
||||||
|
真实数据库的端到端验证本轮也未做。
|
||||||
|
|
||||||
|
`internal/model` 已落地 `Category` 结构、`CategoryModel` 接口与 Squirrel +
|
||||||
|
sqlx 实现。路由与 handler 均有基于替身的 httptest 回归测试(不监听实际端口、
|
||||||
|
不连接数据库),不证明真实 SQL 可用。各内部目录的职责与依赖约束记录在各目录
|
||||||
|
`AGENTS.md` 中。数据库迁移 SQL 已就位:`migrations/00001_init.sql`(初始函数
|
||||||
|
与三张表),通过手工 goose CLI 执行,操作手册见
|
||||||
|
[docs/goose.md](docs/goose.md),迁移目录说明见
|
||||||
|
[migrations/README.md](migrations/README.md);Goose 的 Go 依赖仍未引入。
|
||||||
|
架构文档中标注“待新增”的文件(links 等)尚未创建。
|
||||||
|
|
||||||
|
## 目录职责
|
||||||
|
|
||||||
|
| 路径 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `cmd/lyra/` | 程序入口:启动、依赖组装、退出清理(数据库连接当前建在此处) |
|
||||||
|
| `internal/api/` | HTTP 请求/响应 DTO,按资源分文件(平铺) |
|
||||||
|
| `internal/config/` | 配置定义与加载 |
|
||||||
|
| `internal/postgres/` | PostgreSQL 连接的目标位置(占位:连接暂在入口 main) |
|
||||||
|
| `internal/router/` | 路由注册、项目路径前缀(router.go,平铺) |
|
||||||
|
| `internal/handler/` | 参数绑定、DTO 转换、响应输出;可按业务分包,服务级简单接口平铺在根包 |
|
||||||
|
| `internal/service/` | 业务规则、事务协调;可按业务分包,服务级简单接口平铺在根包(占位) |
|
||||||
|
| `internal/model/` | 数据结构定义与数据访问(SQL,平铺) |
|
||||||
|
| `internal/httpx/` | 公共响应辅助 |
|
||||||
|
| `migrations/` | Goose SQL 迁移文件(00001 初始迁移已就位) |
|
||||||
|
| `docs/` | 架构与计划文档 |
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
Go 命令在本目录(module 根)下执行;`.env.local` 与配置路径 `etc/lyra.toml`
|
||||||
|
均按当前工作目录解析,换目录运行会找不到它们:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/lyra # 启动 HTTP 服务,监听地址与端口见 etc/lyra.toml
|
||||||
|
go test ./... # 回归测试(不监听实际端口、不连接数据库)
|
||||||
|
```
|
||||||
|
|
||||||
|
启动前提:本地存在 `.env.local`(提供数据库口令等环境变量,已被 gitignore、
|
||||||
|
不入库,内容不在文档展开)、`etc/lyra.toml` 配置完整,且其指向的 PostgreSQL
|
||||||
|
可达;连接失败时启动即退出。
|
||||||
|
|
||||||
|
配置中的 `app.env` 直接传给 `gin.SetMode`,仅支持 `debug`/`release`/`test`。
|
||||||
|
环境变量方面当前仅 `database.password` 绑定了 `PG_PASSWORD`,其余配置项不
|
||||||
|
支持环境变量覆盖,也没有校验与默认值。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
已引入并使用:Gin(HTTP 路由)、Viper(读取 TOML 配置)、godotenv(加载
|
||||||
|
`.env.local`)、sqlx + Squirrel(查询构造与执行)、pgx stdlib(PostgreSQL
|
||||||
|
驱动)。Goose 仅以独立 CLI(v3.29.0)手工执行迁移,Go 依赖尚未引入。
|
||||||
@@ -0,0 +1,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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 服务失败,请检查端口是否被占用")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 # 使用预先创建的外部网络
|
||||||
@@ -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)与中间件集成方式。
|
||||||
@@ -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;
|
||||||
@@ -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 方法。
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# api 包
|
||||||
|
|
||||||
|
- 定义 HTTP 请求/响应 DTO,按资源分文件。
|
||||||
|
- DTO 与内部模型分开:即使初期字段相近,也不把数据库模型直接暴露为响应,由 handler 层完成显式转换。
|
||||||
|
- 不引用 handler、service、model,也不依赖 Gin,避免传输契约与实现耦合或循环引用。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// CategoryResponse 分类列表响应 DTO,字段名显式约定小写 JSON 键。
|
||||||
|
type CategoryResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// ErrCode 错误码,前3位数表示错误类型,后3位数表示具体错误
|
||||||
|
type ErrorCode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CodeInvalidArgument ErrorCode = 10001
|
||||||
|
CodeInternalError ErrorCode = 10002
|
||||||
|
CodeLinkNotFound ErrorCode = 10003
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e ErrorCode) Message() string {
|
||||||
|
switch e {
|
||||||
|
case CodeInvalidArgument:
|
||||||
|
return "请求参数不正确"
|
||||||
|
case CodeLinkNotFound:
|
||||||
|
return "链接不存在"
|
||||||
|
default:
|
||||||
|
return "服务暂时不可用"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
Error *ErrorInfo `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorInfo struct {
|
||||||
|
Code ErrorCode `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Debug string `json:"debug,omitempty"`
|
||||||
|
}
|
||||||
@@ -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/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
||||||
@@ -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/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
-- Lyra 初始迁移:与 docs/demo.sql 的建库内容保持一致(函数、三张表、约束、注释、索引、触发器)。
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
-- updated_at 维护函数
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE FUNCTION public.set_updated_at()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at := now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- 用户表
|
||||||
|
CREATE TABLE public.users (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
account text NOT NULL UNIQUE,
|
||||||
|
password_hash text NOT NULL,
|
||||||
|
display_name text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT users_account_not_empty CHECK (btrim(account) <> ''),
|
||||||
|
CONSTRAINT users_display_name_not_empty CHECK (btrim(display_name) <> '')
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.users IS '后台管理用户表';
|
||||||
|
COMMENT ON COLUMN public.users.id IS '用户 ID';
|
||||||
|
COMMENT ON COLUMN public.users.account IS '登录账号,当前大小写敏感';
|
||||||
|
COMMENT ON COLUMN public.users.password_hash IS '密码哈希';
|
||||||
|
COMMENT ON COLUMN public.users.display_name IS '展示昵称';
|
||||||
|
COMMENT ON COLUMN public.users.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN public.users.updated_at IS '更新时间,触发器自动刷新';
|
||||||
|
|
||||||
|
-- 分类表
|
||||||
|
CREATE TABLE public.categories (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
slug text NOT NULL UNIQUE,
|
||||||
|
title text NOT NULL,
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
icon text NOT NULL,
|
||||||
|
sort integer NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT categories_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
|
||||||
|
CONSTRAINT categories_title_not_empty CHECK (btrim(title) <> '')
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.categories IS '链接分类表';
|
||||||
|
COMMENT ON COLUMN public.categories.id IS '分类 ID,内部关联用';
|
||||||
|
COMMENT ON COLUMN public.categories.slug IS '页面地址(路由)标识';
|
||||||
|
COMMENT ON COLUMN public.categories.title IS '分类标题';
|
||||||
|
COMMENT ON COLUMN public.categories.description IS '分类描述';
|
||||||
|
COMMENT ON COLUMN public.categories.icon IS '分类图标标识';
|
||||||
|
COMMENT ON COLUMN public.categories.sort IS '分类排序,越小越靠前';
|
||||||
|
COMMENT ON COLUMN public.categories.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN public.categories.updated_at IS '更新时间,触发器自动刷新';
|
||||||
|
|
||||||
|
-- 链接表
|
||||||
|
CREATE TABLE public.links (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
category_id bigint NOT NULL,
|
||||||
|
title text NOT NULL,
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
url text NOT NULL,
|
||||||
|
icon text,
|
||||||
|
keywords text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||||
|
sort integer NOT NULL DEFAULT 0,
|
||||||
|
pinned boolean NOT NULL DEFAULT false,
|
||||||
|
pinned_sort integer,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT links_category_fk FOREIGN KEY (category_id) REFERENCES public.categories (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT links_title_not_empty CHECK (btrim(title) <> ''),
|
||||||
|
CONSTRAINT links_url_not_empty CHECK (btrim(url) <> ''),
|
||||||
|
CONSTRAINT links_pinned_sort_consistent CHECK (pinned = (pinned_sort IS NOT NULL)),
|
||||||
|
CONSTRAINT links_keywords_one_dimension CHECK (cardinality(keywords) = 0 OR array_ndims(keywords) = 1),
|
||||||
|
CONSTRAINT links_keywords_no_null_elements CHECK (array_position(keywords, NULL) IS NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.links IS '链接表,归属分类';
|
||||||
|
COMMENT ON COLUMN public.links.id IS '链接 ID';
|
||||||
|
COMMENT ON COLUMN public.links.category_id IS '所属分类 ID,删分类前需先处理链接';
|
||||||
|
COMMENT ON COLUMN public.links.title IS '链接标题';
|
||||||
|
COMMENT ON COLUMN public.links.description IS '链接描述';
|
||||||
|
COMMENT ON COLUMN public.links.url IS '链接地址,应用层校验,去尾斜杠后全局唯一';
|
||||||
|
COMMENT ON COLUMN public.links.icon IS '链接图标,可空,前端回退';
|
||||||
|
COMMENT ON COLUMN public.links.keywords IS '搜索关键词数组,供本地搜索匹配';
|
||||||
|
COMMENT ON COLUMN public.links.sort IS '分类内排序,越小越靠前';
|
||||||
|
COMMENT ON COLUMN public.links.pinned IS '首页快捷入口(手动固定),非点击排行';
|
||||||
|
COMMENT ON COLUMN public.links.pinned_sort IS '首页快捷入口顺序,越小越靠前';
|
||||||
|
COMMENT ON COLUMN public.links.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN public.links.updated_at IS '更新时间,触发器自动刷新';
|
||||||
|
|
||||||
|
-- 索引
|
||||||
|
CREATE INDEX categories_order_idx
|
||||||
|
ON public.categories (sort, id);
|
||||||
|
|
||||||
|
CREATE INDEX links_category_order_idx
|
||||||
|
ON public.links (category_id, sort, id);
|
||||||
|
|
||||||
|
CREATE INDEX links_pinned_order_idx
|
||||||
|
ON public.links (pinned_sort, id)
|
||||||
|
WHERE pinned;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX links_normalized_url_unique
|
||||||
|
ON public.links (rtrim(url, '/'));
|
||||||
|
|
||||||
|
-- 触发器
|
||||||
|
CREATE TRIGGER users_set_updated_at
|
||||||
|
BEFORE UPDATE ON public.users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER categories_set_updated_at
|
||||||
|
BEFORE UPDATE ON public.categories
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER links_set_updated_at
|
||||||
|
BEFORE UPDATE ON public.links
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- 按依赖逆序删除:链接表 → 分类表 → 用户表 → 函数。
|
||||||
|
-- 删表会一并移除表上的触发器与索引,故无需单独 DROP;
|
||||||
|
-- 不用 CASCADE / IF EXISTS,出现意外依赖时让删除直接报错。
|
||||||
|
DROP TABLE public.links;
|
||||||
|
DROP TABLE public.categories;
|
||||||
|
DROP TABLE public.users;
|
||||||
|
DROP FUNCTION public.set_updated_at();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# migrations
|
||||||
|
|
||||||
|
Lyra 的 [Goose](https://pressly.github.io/goose/) SQL 迁移目录,是数据库结构的**唯一正式执行来源**。
|
||||||
|
[docs/demo.sql](../docs/demo.sql) 仅保留作参考,不要再手工执行它,避免与迁移重复建表。
|
||||||
|
|
||||||
|
## 操作手册
|
||||||
|
|
||||||
|
安装 goose CLI、配置 `.env.local` 连接、首次初始化、日常新增迁移、回滚警告与
|
||||||
|
故障排查等全部操作步骤,见唯一完整手册:[docs/goose.md](../docs/goose.md)。
|
||||||
|
|
||||||
|
## 现有迁移
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `00001_init.sql` | 初始结构:`set_updated_at()` 函数、`users`/`categories`/`links` 三张表、约束、注释、索引与触发器,建库内容与 `docs/demo.sql` 一致 |
|
||||||
|
|
||||||
|
## 执行模型约定
|
||||||
|
|
||||||
|
- 迁移只作用于 `public` schema,沿用现有设计,暂不做 schema 划分。
|
||||||
|
- 单个操作者串行执行,不引入数据库锁等并发控制。
|
||||||
|
- 部署执行入口是手工 CLI,服务启动时**不会**自动执行迁移。
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package testing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNormal(t *testing.T) {
|
|
||||||
numbers := map[int][]int{
|
|
||||||
1: {8, 2, 4, 4},
|
|
||||||
2: {0, 5, 3},
|
|
||||||
3: {1},
|
|
||||||
}
|
|
||||||
for _, numbers := range numbers {
|
|
||||||
middle := GetMiddNumber(numbers)
|
|
||||||
t.Log(middle)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetMiddNumber(numbers []int) float64 {
|
|
||||||
sort.Ints(numbers)
|
|
||||||
length := len(numbers)
|
|
||||||
isOddNUmber := length%2 == 1
|
|
||||||
if !isOddNUmber {
|
|
||||||
return float64(numbers[length/2-1]+numbers[length/2]) / 2.0
|
|
||||||
}
|
|
||||||
return float64(numbers[length/2])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannel(t *testing.T) {
|
|
||||||
var ball = make(chan string)
|
|
||||||
kickBall := func(playerName string) {
|
|
||||||
for {
|
|
||||||
t.Log(<-ball, "传球", "\n")
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
ball <- playerName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
go kickBall("张三")
|
|
||||||
go kickBall("李四")
|
|
||||||
go kickBall("王二麻子")
|
|
||||||
go kickBall("刘大")
|
|
||||||
ball <- "裁判" // 开球
|
|
||||||
var c chan bool // 一个零值nil通道
|
|
||||||
<-c // 永久阻塞在此
|
|
||||||
}
|
|
||||||
@@ -25,13 +25,13 @@
|
|||||||
#### 三、远程操作
|
#### 三、远程操作
|
||||||
|
|
||||||
| 操作 | 命令 | 说明 |
|
| 操作 | 命令 | 说明 |
|
||||||
| ---------------- | --------------------------------------------------------- | ------------------- |
|
| ---------------- | ----------------------------------------------------------- | ------------------- |
|
||||||
| 克隆仓库 | `git clone <url>` | 拉取远程项目 |
|
| 克隆仓库 | `git clone <url>` | 拉取远程项目 |
|
||||||
| 拉取远程代码 | `git pull` | 获取并合并远程分支 |
|
| 拉取远程代码 | `git pull` | 获取并合并远程分支 |
|
||||||
| 推送代码 | `git push` | 推送当前分支到远程 |
|
| 推送代码 | `git push` | 推送当前分支到远程 |
|
||||||
| 推送并设置上游 | `git push -u origin <branch>` | 建立跟踪关系 |
|
| 推送并设置上游 | `git push -u origin <branch>` | 建立跟踪关系 |
|
||||||
| 查看远程地址 | `git remote -v` | 显示远程仓库地址 |
|
| 查看远程地址 | `git remote -v` | 显示远程仓库地址 |
|
||||||
| 远程查询所有标签 | git ls-remote --tags https://github.com/xxx.git \| grep X | 查看X相关的所有标签 |
|
| 远程查询所有标签 | `git ls-remote --tags https://github.com/xxx.git \| grep X` | 查看X相关的所有标签 |
|
||||||
|
|
||||||
#### 四、合并与变基
|
#### 四、合并与变基
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# MySQL 空表 JOIN 导致 ORDER BY 索引失效——实验笔记
|
||||||
|
|
||||||
|
> 环境:MySQL 8.0.18+(hash join 自 8.0.18 引入,实测 8.0.28)
|
||||||
|
> 现象:一条 `ORDER BY ... LIMIT` 查询原本走主表复合索引(免排序),LEFT JOIN 一张**空表**后,执行计划退化为全量读取 + `Using temporary; Using filesort`,主表索引"失效"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、一句话结论
|
||||||
|
|
||||||
|
**不是空表禁用了别的表的索引,而是空表让优化器为该 JOIN 选择了 hash join;hash join 不保序,导致"借主表索引序完成 ORDER BY"的整个计划作废**,主表随之退化为全量读取 + 临时表排序。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、原理
|
||||||
|
|
||||||
|
### 1. 为什么嵌套循环(Nested Loop Join)能"免排序"
|
||||||
|
|
||||||
|
```
|
||||||
|
按 idx(created_at, id) 倒序逐行读主表 → 每行对被 join 表做索引点查(eq_ref)→ 输出
|
||||||
|
```
|
||||||
|
|
||||||
|
- 嵌套循环的输出顺序 = 驱动表(主表)的访问顺序
|
||||||
|
- 主表走 `(created_at, id)` 复合索引倒序扫描,输出天然满足 `ORDER BY created_at DESC, id DESC`
|
||||||
|
- `LIMIT 10` 可以提前终止:读到 10 行即停,`ORDER BY` 零成本(EXPLAIN 中体现为 `type=index` + `Backward index scan`,无 temporary/filesort)
|
||||||
|
|
||||||
|
### 2. hash join 为什么会破坏这一切
|
||||||
|
|
||||||
|
- hash join 把被 join 表整棵读进内存哈希表,再用驱动表逐行**探测**,输出顺序不再对应任何一方的索引序
|
||||||
|
- 一旦计划中任何一个 JOIN 环节是 hash join,结果集都必须整体重排
|
||||||
|
- 优化器权衡:"反正要排序,主表走哪个访问路径都无所谓" → 放弃排序专用索引,选最便宜的读取方式(全表扫或全索引扫,见第四节变体)
|
||||||
|
|
||||||
|
### 3. 为什么偏偏是空表触发 hash join(反直觉)
|
||||||
|
|
||||||
|
- 空表没有统计信息,优化器估算行数 ≈ 1
|
||||||
|
- 估算"把空表整棵读进哈希表(≈1 次 IO)再探测" 比 "对主表每行做一次 B+ 树点查(N 次树遍历)" 更便宜
|
||||||
|
- 于是选了 hash join —— 对空表本身这是**正确的成本决策**(0 行数据怎么访问都对),副作用是拖垮了主表的排序计划
|
||||||
|
- 表有了真实数据后,统计信息更新,优化器改回索引点查(eq_ref),保序计划自动恢复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、建表与造数(可直接执行)
|
||||||
|
|
||||||
|
### 1. 主表:10 万行,带 `(created_at, id)` 复合索引
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP TABLE IF EXISTS users, user_profiles, user_tags_empty, user_tags_noindex;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(64) NOT NULL,
|
||||||
|
nickname VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_created_id (created_at, id)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
-- 递归 CTE 造数需要先放开深度限制(仅当前会话)
|
||||||
|
SET SESSION cte_max_recursion_depth = 100001;
|
||||||
|
|
||||||
|
INSERT INTO users (username, nickname, created_at)
|
||||||
|
WITH RECURSIVE seq AS (
|
||||||
|
SELECT 1 AS n
|
||||||
|
UNION ALL
|
||||||
|
SELECT n + 1 FROM seq WHERE n < 100000
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
CONCAT('user_', n),
|
||||||
|
CONCAT('nick_', n),
|
||||||
|
DATE_ADD('2025-01-01 00:00:00', INTERVAL FLOOR(RAND() * 31536000) SECOND)
|
||||||
|
FROM seq;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 对照用被 join 表:有数据,join 列唯一索引
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_profiles (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
city VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
UNIQUE KEY uk_user_id (user_id)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
INSERT INTO user_profiles (user_id, city)
|
||||||
|
WITH RECURSIVE seq AS (
|
||||||
|
SELECT 1 AS n
|
||||||
|
UNION ALL
|
||||||
|
SELECT n + 1 FROM seq WHERE n < 100000
|
||||||
|
)
|
||||||
|
SELECT n, CONCAT('city_', FLOOR(RAND() * 100)) FROM seq;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. "罪魁"空表:结构与 user_profiles 完全同构,只是**不插入任何数据**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_tags_empty (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
tag VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
UNIQUE KEY uk_user_id (user_id)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
-- 故意不插数据
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、对照实验
|
||||||
|
|
||||||
|
> 每组实验观察四处:主表 users 的 `type` / `key` / `rows` / `Extra`,以及被 join 表的访问方式。
|
||||||
|
> **注意**:实验 SQL 故意 SELECT 了 `username`/`nickname` 等**索引外列**,原因见实验二的"变体"说明。
|
||||||
|
|
||||||
|
### 实验一(对照组):只 JOIN 有数据的表 → 索引完美生效
|
||||||
|
|
||||||
|
```sql
|
||||||
|
EXPLAIN
|
||||||
|
SELECT u.id, u.username, u.nickname, u.created_at, p.city
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||||
|
ORDER BY u.created_at DESC, u.id DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
|
||||||
|
| table | type | key | rows | Extra |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| u | **index** | **idx_created_id** | **10** | **Backward index scan** |
|
||||||
|
| p | eq_ref | uk_user_id | 1 | (空) |
|
||||||
|
|
||||||
|
主表倒序走复合索引只读 10 行(LIMIT 提前终止),被 join 表逐行点查,无排序开销。保序计划下 SELECT 索引外列不影响走索引(逐行回表而已)。
|
||||||
|
|
||||||
|
### 实验二:仅追加一个 LEFT JOIN 空表 → 计划全面崩坏
|
||||||
|
|
||||||
|
```sql
|
||||||
|
EXPLAIN
|
||||||
|
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||||
|
LEFT JOIN user_tags_empty t ON t.user_id = u.id
|
||||||
|
ORDER BY u.created_at DESC, u.id DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期结果(SELECT 含索引外列时):**
|
||||||
|
|
||||||
|
| table | type | key | rows | Extra |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| u | **ALL** | NULL | **100003** | **Using temporary; Using filesort** |
|
||||||
|
| p | eq_ref | uk_user_id | 1 | (空) |
|
||||||
|
| t | **ALL** | NULL | 1 | **Using where; Using join buffer (hash join)** |
|
||||||
|
|
||||||
|
三个观察点:
|
||||||
|
|
||||||
|
1. 空表 t 的索引 `uk_user_id` 明明存在,却 `type=ALL` + hash join(空表上这是合理的成本选择)
|
||||||
|
2. 主表 u 的 `idx_created_id` 被**整个放弃**:全表扫描 + 临时表 + 文件排序,rows 从 10 涨到全量(LIMIT 提前终止失效)
|
||||||
|
3. 注意 p 的 eq_ref 没有受影响——**别的表的索引并没有被禁用,坏掉的是主表的"排序专用访问路径"**
|
||||||
|
|
||||||
|
**变体:主表退化的两种形态(重要,实测踩过的坑)**
|
||||||
|
|
||||||
|
如果 SELECT **只查索引内的列**(如仅 `u.id, u.created_at`),主表退化后不是 `ALL`,而是:
|
||||||
|
|
||||||
|
```
|
||||||
|
u: type=index, key=idx_created_id, rows=100003, Extra: Using index; Using temporary; Using filesort
|
||||||
|
```
|
||||||
|
|
||||||
|
机制:排序计划作废后优化器选"最便宜的读取方式"——查询列全在索引里时,扫**窄的二级索引**(覆盖扫描,`Using index` 免回表)比扫**宽的聚簇索引**(ALL)更便宜。`type=index` 全索引扫描与 `ALL` 全表扫本质相同:**都是全量读取 + 显式排序**,同样没有 LIMIT 提前终止。
|
||||||
|
|
||||||
|
判断"是否退化"的可靠标志是 `rows` 是否涨到全量、`Using temporary; Using filesort` 是否出现,而**不是** type 显示 ALL 还是 index。生产查询通常 SELECT 大量业务列(覆盖不成立),多表现为 ALL 形态;本实验因此故意带上索引外列。
|
||||||
|
|
||||||
|
> 加 `FORMAT=TREE` 可以更直观看到 `Left (hash join)` 节点:
|
||||||
|
> `EXPLAIN FORMAT=TREE <实验二的查询>;`
|
||||||
|
|
||||||
|
### 实验三:FORCE INDEX 把空表钉回点查 → 计划恢复
|
||||||
|
|
||||||
|
```sql
|
||||||
|
EXPLAIN
|
||||||
|
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||||
|
LEFT JOIN user_tags_empty t FORCE INDEX (uk_user_id) ON t.user_id = u.id
|
||||||
|
ORDER BY u.created_at DESC, u.id DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期结果:** t 变为 `eq_ref`,主表 u 恢复 `index + Backward index scan, rows=10`,temporary/filesort 消失。
|
||||||
|
|
||||||
|
结论:只要所有 JOIN 都是保序的嵌套循环点查,主表就能借索引序免排序。
|
||||||
|
|
||||||
|
### 实验四:空表填入数据 + 刷新统计信息 → 自愈
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO user_tags_empty (user_id, tag)
|
||||||
|
WITH RECURSIVE seq AS (
|
||||||
|
SELECT 1 AS n
|
||||||
|
UNION ALL
|
||||||
|
SELECT n + 1 FROM seq WHERE n < 100000
|
||||||
|
)
|
||||||
|
SELECT n, 'vip' FROM seq;
|
||||||
|
|
||||||
|
ANALYZE TABLE user_tags_empty;
|
||||||
|
|
||||||
|
-- 重跑实验二的 EXPLAIN
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期结果:** 与实验一/三相同,t 走 `eq_ref`,主表走 `idx_created_id`,无需任何 hint。
|
||||||
|
|
||||||
|
补充观察:只插入少量行(比如 50 行)时优化器可能仍选 hash join——对几十行的表,hash join 确实可能更便宜,这属于**合理决策**而非问题;关注点应是"数据量与计划是否匹配",而不是"必须永远走索引"。
|
||||||
|
|
||||||
|
### 进阶实验五:与空无关——任何原因导致的 hash join 都会触发同样的退化
|
||||||
|
|
||||||
|
证明"根因是 hash join 破坏保序计划,空表只是诱因之一":
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 有 10 万行、但 join 列上【没有索引】的表
|
||||||
|
CREATE TABLE user_tags_noindex (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
tag VARCHAR(32) NOT NULL DEFAULT ''
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
INSERT INTO user_tags_noindex (user_id, tag)
|
||||||
|
WITH RECURSIVE seq AS (
|
||||||
|
SELECT 1 AS n
|
||||||
|
UNION ALL
|
||||||
|
SELECT n + 1 FROM seq WHERE n < 100000
|
||||||
|
)
|
||||||
|
SELECT n, 'new' FROM seq;
|
||||||
|
|
||||||
|
EXPLAIN
|
||||||
|
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||||
|
LEFT JOIN user_tags_noindex t ON t.user_id = u.id
|
||||||
|
ORDER BY u.created_at DESC, u.id DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期结果:** 与实验二相同的崩坏形态(t 为 `ALL + hash join`,主表全量读取 + temporary + filesort)。
|
||||||
|
|
||||||
|
这说明日常更常见的场景——**join 列漏建索引**——和空表是同一个病:hash join 一出现,依赖主表索引序的 ORDER BY + LIMIT 计划就没了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、机制小结(对照表)
|
||||||
|
|
||||||
|
| JOIN 实现方式 | 输出是否保序 | LIMIT 能否提前终止 | 对主表排序索引的影响 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 嵌套循环 + eq_ref 点查 | ✅ 顺序=驱动表访问序 | ✅ 读够即停 | 可借索引序免排序 |
|
||||||
|
| hash join | ❌ 哈希探测序 | ❌ 需物化后整体排序 | 排序计划作废,主表退化为全量读取 |
|
||||||
|
|
||||||
|
主表退化后的具体形态由 SELECT 列集合决定:
|
||||||
|
|
||||||
|
| SELECT 列 | 退化形态 | 特征 |
|
||||||
|
|---|---|---|
|
||||||
|
| 含索引外列(生产常态) | `type=ALL` 全表扫 | key=NULL |
|
||||||
|
| 全在索引内 | `type=index` 全索引扫 | key=该索引,`Using index`(覆盖免回表) |
|
||||||
|
|
||||||
|
两种形态共同点:rows ≈ 全量、`Using temporary; Using filesort`、LIMIT 无法提前终止。
|
||||||
|
|
||||||
|
优化器的行为链条:
|
||||||
|
|
||||||
|
```
|
||||||
|
被 join 表选了 hash join
|
||||||
|
→ 结果集顺序不可保证
|
||||||
|
→ ORDER BY 必须显式排序(temporary + filesort)
|
||||||
|
→ "主表走排序专用索引"失去意义
|
||||||
|
→ 主表改用最便宜的读取方式(ALL 或覆盖索引全扫)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、实践建议
|
||||||
|
|
||||||
|
1. **排查口诀**:`ORDER BY ... LIMIT` 查询变慢时,先看 EXPLAIN 里是否出现 `Using join buffer (hash join)`——它常常是主表索引"无故失效"的信号
|
||||||
|
2. **判断退化看 rows 和 temporary/filesort**,不要只盯 type:`type=index` 也可能是全索引扫描的退化形态(对比正常时 rows 应接近 LIMIT 值)
|
||||||
|
3. **根治**:给被 join 表的 join 列建索引(唯一列建唯一索引,非唯一建普通索引),让点查比 hash join 更便宜
|
||||||
|
4. **空表/新表场景**(刚上线、还没数据的表):如果线上已出现该退化且影响明显,可用 `FORCE INDEX` 过渡;表有数据后可移除 hint(低频后台查询甚至可以不处理)
|
||||||
|
5. **不要盲信"必须走索引"**:空表/极小表走全表扫本身是正确的成本决策,要修的是"hash join 破坏排序计划"这个副作用,而不是全表扫本身
|
||||||
|
6. **改动 join 结构后**(新增关联表、调整关联条件)重新 EXPLAIN 一次带排序的查询,确认 `Backward index scan` / rows 接近 LIMIT 值的计划仍在
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、清理
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP TABLE IF EXISTS users, user_profiles, user_tags_empty, user_tags_noindex;
|
||||||
|
SET SESSION cte_max_recursion_depth = DEFAULT;
|
||||||
|
```
|
||||||
@@ -109,15 +109,48 @@ func main() {
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# mysql根据远程数据库表生成
|
# mysql根据远程数据库表生成
|
||||||
goctl model mysql datasource --url="olive:xxvkikaDDx01&&99@tcp(127.0.0.1:3306)/ebming" --table="user_invitation_record" --dir="." -c --home ../../../../deploy/goctl/1.8.3
|
goctl model mysql datasource --url="user:password@tcp(127.0.0.1:3306)/database" --table="user_table" --dir="." -c --home D:/template/1.8.3
|
||||||
|
|
||||||
# mysql根据ddl生成
|
# mysql根据ddl生成
|
||||||
goctl model mysql ddl --src h5.sql --dir . -c --home D:/zhangyuxin/template/1.8.3
|
goctl model mysql ddl --src h5.sql --dir . -c --home D:/template/1.8.3
|
||||||
|
|
||||||
# pgsql根据远程数据库表生成
|
# pgsql根据远程数据库表生成
|
||||||
goctl model pg datasource -url "postgres://labman:tN5bWz2KpL9Fy4@127.0.0.1:15432/sern" -table user -dir ./user --cache --home ../../goctl_template/1.8.3
|
goctl model pg datasource -url "postgres://user:password@127.0.0.1:15432/database" -table user -dir ./user --cache --home D:/template/1.8.3
|
||||||
|
|
||||||
# pgsql根据远程数据库表生成,带模式(schema)
|
# pgsql根据远程数据库表生成,带模式(schema)
|
||||||
goctl model pg datasource -url "postgres://admin:zhangyuxin@127.0.0.1:25432/test?sslmode=disable" -s "users" -table "profile" -dir ./go-zero/model/users/ --cache --home ../../deploy/goctl
|
goctl model pg datasource -url "postgres://user:password@127.0.0.1:25432/database?sslmode=disable" -s "users" -table "profile" -dir ./go-zero/model/users/ --cache --home ../../deploy/goctl
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 有的时候会遇到`unsupported database type`的错误,如果需要支持该字段,需要在`goctl.yaml`文件添加数据库类型映射配置,并保证goctl环境变量`GOCTL_EXPERIMENTAL=on`开启。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## config
|
||||||
|
|
||||||
|
`goctl config`是用于对goctl静态配置文件做管理。
|
||||||
|
|
||||||
|
```
|
||||||
|
$ goctl config --help
|
||||||
|
Usage:
|
||||||
|
goctl config [command]
|
||||||
|
|
||||||
|
Available Commands:
|
||||||
|
clean Clean goctl config file
|
||||||
|
init Initialize goctl config file
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
-h, --help help for config
|
||||||
|
|
||||||
|
|
||||||
|
Use "goctl config [command] --help" for more information about a command.
|
||||||
|
```
|
||||||
|
|
||||||
|
`goctl.yaml`文件字段
|
||||||
|
|
||||||
|
目前暂时仅支持对 model 数据类型映射的配置
|
||||||
|
|
||||||
|
| 字段名称 | 说明 |
|
||||||
|
| --------------- | ------------------------------------------------------------ |
|
||||||
|
| model | model 配置 |
|
||||||
|
| model.types_map | model 配置之类型映射规则,是一个 map<string,obj> 结构,key 为数据库类型,value 为映射对象 |
|
||||||
|
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
### validator
|
|
||||||
|
|
||||||
[GitHub地址](github.com/go-playground/validator)
|
|
||||||
|
|
||||||
包验证器,可以用于验证参数是否符合规则,比如下方的比较验证,还有其他可以查看网址的详细介绍
|
|
||||||
go-zero自带的有校验器,可以使用
|
|
||||||
|
|
||||||
| Tag | Description |
|
|
||||||
| ---- | --------------------- |
|
|
||||||
| eq | Equals |
|
|
||||||
| gt | Greater than |
|
|
||||||
| gte | Greater than or equal |
|
|
||||||
| lt | Less Than |
|
|
||||||
| lte | Less Than or Equal |
|
|
||||||
| ne | Not Equal |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### goverter
|
|
||||||
|
|
||||||
[GitHub地址](https://github.com/jmattheis/goverter)
|
|
||||||
|
|
||||||
类型转换器,可以用于类型的转换,比如下面的代码
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (l *CreatePopupLogic) CreatePopup(in *backgroundrpcmodel.ResPopup) (*backgroundrpcmodel.ResResult, error) {
|
|
||||||
_, err := l.svcCtx.PopupModel.Insert(l.ctx, u.Convert.SysPopupR2S(in.Info))
|
|
||||||
if err != nil {
|
|
||||||
return nil, rerr.NewFromSQL(err)
|
|
||||||
}
|
|
||||||
return &backgroundrpcmodel.ResResult{
|
|
||||||
Result: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
使用
|
|
||||||
|
|
||||||
```
|
|
||||||
goverter gen .
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### asynq
|
|
||||||
|
|
||||||
[GitHub网址](https://github.com/hibiken/asynq)
|
|
||||||
|
|
||||||
- 入队流程
|
|
||||||
|
|
||||||
```go
|
|
||||||
payload := &jobtype.xxxPayload{}
|
|
||||||
payloadByte, err := jsoniter.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
logt.Error("xxx jsoniter.Marshal err:", err)
|
|
||||||
return nil, zerr.New(rescode.DataFormatConversionError)
|
|
||||||
}
|
|
||||||
_, err = l.svcCtx.AsynqClient.Enqueue(asynq.NewTask(jobtype.xxx, payloadBytes, asynq.Queue(consts.QueueLow)))
|
|
||||||
if err != nil {
|
|
||||||
logt.Error("xxx enqueue err:", err)
|
|
||||||
return nil, zerr.New(rescode.EnqueueErr)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 自行定义了队列优先级,在入队的时候可以加参数进行选择
|
|
||||||
|
|
||||||
```
|
|
||||||
consts.QueueCritical: 6,
|
|
||||||
consts.QueueDefault: 3,
|
|
||||||
consts.QueueLow: 1,
|
|
||||||
```
|
|
||||||
|
|
||||||
- 处理前流程(反序列化,获取数据)
|
|
||||||
|
|
||||||
```go
|
|
||||||
var payload jobtype.xxx
|
|
||||||
err := json.Unmarshal(task.Payload(), &payload)
|
|
||||||
if err != nil {
|
|
||||||
logt.Error("xxx unmarshal payload err:", err)
|
|
||||||
return zerr.New(rescode.DataFormatConversionError)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### gorm
|
|
||||||
|
|
||||||
[官网文档](https://gorm.io/zh_CN/docs/)
|
|
||||||
|
|
||||||
**使用gorm + sqlx,但是复用sql连接**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func NewServiceContext(c config.Config) *ServiceContext {
|
|
||||||
logx.Infof("Cache config: %+v", c.CacheRedis)
|
|
||||||
|
|
||||||
gormDB, err := gorm.Open(postgres.Open(c.Database.DataSource), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
msg := fmt.Sprintf("连接数据库失败 %s ", err.Error())
|
|
||||||
logx.Error(msg)
|
|
||||||
panic(msg)
|
|
||||||
}
|
|
||||||
sqlDB, err := gormDB.DB()
|
|
||||||
if err != nil {
|
|
||||||
msg := fmt.Sprintf("获取底层连接失败: %s", err.Error())
|
|
||||||
logx.Error(msg)
|
|
||||||
panic(msg)
|
|
||||||
}
|
|
||||||
sqlxConn := sqlx.NewSqlConnFromDB(sqlDB)
|
|
||||||
return &ServiceContext{
|
|
||||||
Config: c,
|
|
||||||
UserAuthModel: models.NewUserAuthModel(sqlxConn, c.CacheRedis, gormDB),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### excelize
|
|
||||||
|
|
||||||
读写Excel的Go库
|
|
||||||
|
|
||||||
[github](https://github.com/qax-os/excelize)
|
|
||||||
|
|
||||||
#### 创建工作簿
|
|
||||||
|
|
||||||
```go
|
|
||||||
f := excelize.NewFile()
|
|
||||||
defer func() {
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
// 创建一个新的Sheet,返回的index为该工作簿的索引
|
|
||||||
index, err := f.NewSheet("SheetName")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("创建Sheet失败: %w", err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 打开文件
|
|
||||||
|
|
||||||
```go
|
|
||||||
f, err := excelize.OpenFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("打开 Excel失败: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 写入cell值
|
|
||||||
|
|
||||||
```go
|
|
||||||
f.SetCellValue("SheetName", "B2", 100)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 设置工作簿默认活动工作表
|
|
||||||
|
|
||||||
```go
|
|
||||||
f.SetActiveSheet(index)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 创建或更新excel表
|
|
||||||
|
|
||||||
```go
|
|
||||||
if err := f.SaveAs("Book1.xlsx"); err != nil {
|
|
||||||
return fmt.Errorf("保存失败: %w", err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 获取指定cell值
|
|
||||||
|
|
||||||
```go
|
|
||||||
cell, err := f.GetCellValue("Sheet1", "B2")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("读取cell失败: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Println(cell)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 获取所有工作簿的cell值(二维数组)
|
|
||||||
|
|
||||||
```go
|
|
||||||
rows, err := f.GetRows("Sheet1")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("读取失败: %w", err)
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
for _, colCell := range row {
|
|
||||||
fmt.Print(colCell, "\t")
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Go 常用第三方库
|
||||||
|
|
||||||
|
- [validator](validator.md) —— 参数校验器,验证参数是否符合规则
|
||||||
|
- [goverter](goverter.md) —— 类型转换器,用于类型转换代码生成
|
||||||
|
- [asynq](asynq.md) —— 异步任务队列,任务的入队与处理流程
|
||||||
|
- [gorm](gorm.md) —— ORM,gorm + sqlx 复用 sql 连接的用法
|
||||||
|
- [excelize](excelize.md) —— 读写 Excel 的 Go 库
|
||||||
|
- [bcrypt](bcrypt.md) —— 密码哈希;Go 团队维护的官方扩展库 `golang.org/x/crypto/bcrypt`,不属于标准库
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# asynq
|
||||||
|
|
||||||
|
[GitHub网址](https://github.com/hibiken/asynq)
|
||||||
|
|
||||||
|
- 入队流程
|
||||||
|
|
||||||
|
```go
|
||||||
|
payload := &jobtype.xxxPayload{}
|
||||||
|
payloadByte, err := jsoniter.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
logt.Error("xxx jsoniter.Marshal err:", err)
|
||||||
|
return nil, zerr.New(rescode.DataFormatConversionError)
|
||||||
|
}
|
||||||
|
_, err = l.svcCtx.AsynqClient.Enqueue(asynq.NewTask(jobtype.xxx, payloadBytes, asynq.Queue(consts.QueueLow)))
|
||||||
|
if err != nil {
|
||||||
|
logt.Error("xxx enqueue err:", err)
|
||||||
|
return nil, zerr.New(rescode.EnqueueErr)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 自行定义了队列优先级,在入队的时候可以加参数进行选择
|
||||||
|
|
||||||
|
```
|
||||||
|
consts.QueueCritical: 6,
|
||||||
|
consts.QueueDefault: 3,
|
||||||
|
consts.QueueLow: 1,
|
||||||
|
```
|
||||||
|
|
||||||
|
- 处理前流程(反序列化,获取数据)
|
||||||
|
|
||||||
|
```go
|
||||||
|
var payload jobtype.xxx
|
||||||
|
err := json.Unmarshal(task.Payload(), &payload)
|
||||||
|
if err != nil {
|
||||||
|
logt.Error("xxx unmarshal payload err:", err)
|
||||||
|
return zerr.New(rescode.DataFormatConversionError)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# 官方扩展库 `bcrypt`
|
||||||
|
|
||||||
|
## `bcrypt` 是什么
|
||||||
|
|
||||||
|
`bcrypt` 是专门用于**密码哈希**的算法,不是可逆加密。
|
||||||
|
|
||||||
|
```text
|
||||||
|
password
|
||||||
|
↓
|
||||||
|
bcrypt
|
||||||
|
↓
|
||||||
|
hash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心原则
|
||||||
|
|
||||||
|
```text
|
||||||
|
数据库只存 hash
|
||||||
|
bcrypt 自动处理 salt
|
||||||
|
使用 CompareHashAndPassword 验证
|
||||||
|
cost 根据服务器性能决定
|
||||||
|
不要自己用 MD5 / SHA256 存密码
|
||||||
|
```
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
### cost
|
||||||
|
|
||||||
|
`cost` 是 bcrypt 的计算难度。
|
||||||
|
|
||||||
|
生产环境通常可以从 `10 ~ 12` 开始测试,具体值应根据服务器性能决定。单次 bcrypt 计算控制在大约 `100ms ~ 300ms` 是比较常见的工程范围。
|
||||||
|
|
||||||
|
`cost` 每增加 1,计算量大约接近翻倍。
|
||||||
|
|
||||||
|
cost 越高:
|
||||||
|
|
||||||
|
- 计算越慢
|
||||||
|
- 暴力破解成本越高
|
||||||
|
- 服务器 CPU 消耗也越高
|
||||||
|
|
||||||
|
bcrypt 生成的 hash 本身会记录当时使用的 `cost`。
|
||||||
|
|
||||||
|
因此,即使生产环境将当前配置从:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cost = 10
|
||||||
|
```
|
||||||
|
|
||||||
|
升级为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cost = 12
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库中原本使用 `cost = 10` 生成的旧 hash,仍然可以通过:
|
||||||
|
|
||||||
|
```go
|
||||||
|
bcrypt.CompareHashAndPassword(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
正常验证。
|
||||||
|
|
||||||
|
`CompareHashAndPassword` 会根据已有 hash 中记录的信息进行密码验证,不依赖当前系统配置的 cost。
|
||||||
|
|
||||||
|
登录成功后,可以通过 `bcrypt.Cost()` 获取旧 hash 使用的 cost。如果低于当前配置,可以重新生成 hash 并更新数据库。
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
### 72-byte 限制
|
||||||
|
|
||||||
|
bcrypt 密码输入最多支持约 `72 bytes`。
|
||||||
|
|
||||||
|
如果只允许 ASCII:
|
||||||
|
|
||||||
|
```text
|
||||||
|
72 bytes ≈ 72 个字符
|
||||||
|
```
|
||||||
|
|
||||||
|
通常已经足够。
|
||||||
|
|
||||||
|
建议业务层主动限制:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if len([]byte(password)) > 72 {
|
||||||
|
return errors.New("password too long")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
## 基础使用
|
||||||
|
|
||||||
|
### 依赖导入
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
```
|
||||||
|
|
||||||
|
`golang.org/x/crypto/bcrypt` 属于 Go 官方维护的扩展库,但不属于 Go 标准库。
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
### 生成 hash
|
||||||
|
|
||||||
|
```go
|
||||||
|
hash, err := bcrypt.GenerateFromPassword(
|
||||||
|
[]byte(password),
|
||||||
|
12,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
生成的 hash 类似:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$2a$12$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
其中大致包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$2a$ → bcrypt 版本
|
||||||
|
12$ → cost
|
||||||
|
后续内容 → 编码后的 salt + hash
|
||||||
|
```
|
||||||
|
|
||||||
|
bcrypt 会自动生成随机 salt,因此不需要自己生成或额外保存 salt。
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
### 验证密码
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := bcrypt.CompareHashAndPassword(
|
||||||
|
[]byte(passwordHash),
|
||||||
|
[]byte(password),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// 密码正确
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
### 判断密码是否相同
|
||||||
|
|
||||||
|
不能直接比较两个 bcrypt hash,因为 bcrypt 每次都会生成新的随机 salt。
|
||||||
|
|
||||||
|
即使密码完全相同:
|
||||||
|
|
||||||
|
```text
|
||||||
|
hash1 != hash2
|
||||||
|
```
|
||||||
|
|
||||||
|
判断某个密码是否和旧密码相同时,应使用:
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := bcrypt.CompareHashAndPassword(
|
||||||
|
[]byte(oldHash),
|
||||||
|
[]byte(password),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// password 与 oldHash 对应的密码相同
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
### 获取 hash 的 cost
|
||||||
|
|
||||||
|
```go
|
||||||
|
cost, err := bcrypt.Cost(
|
||||||
|
[]byte(user.PasswordHash),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
通常用于**用户登录成功后**,判断当前密码 hash 是否使用了旧的 cost。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
数据库 hash cost = 10
|
||||||
|
当前系统 cost = 12
|
||||||
|
↓
|
||||||
|
登录成功
|
||||||
|
↓
|
||||||
|
重新生成 cost = 12 的 hash
|
||||||
|
↓
|
||||||
|
更新数据库
|
||||||
|
```
|
||||||
|
|
||||||
|
这样可以在用户正常登录过程中逐步升级旧密码 hash。
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Casbin 学习笔记
|
||||||
|
|
||||||
|
> 版本基线:casbin **v3.11.0**;Go import 路径 `github.com/casbin/casbin/v3`(源码仓库 `apache/casbin`)。本文是概念速记,不是教程。
|
||||||
|
> Casbin 负责**授权**(能不能做某事),不负责**身份认证**(你是谁)。
|
||||||
|
|
||||||
|
## 1. 心智模型:PERM
|
||||||
|
|
||||||
|
| 段 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `[request_definition]` `r = sub, obj, act` | 一次访问请求长什么样 |
|
||||||
|
| `[policy_definition]` `p = sub, obj, act` | 一条策略规则长什么样 |
|
||||||
|
| `[policy_effect]` `e = some(where (p.eft == allow))` | 多条策略如何合成结论 |
|
||||||
|
| `[matchers]` `m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act` | `r` 与 `p` 如何配对 |
|
||||||
|
|
||||||
|
- `sub, obj, act` 只是最典型的字段,不是硬编码:可加 `dom`、资源属性,也可用对象做 ABAC。
|
||||||
|
- RBAC 再加 `[role_definition] g = _,_`,表示主体与角色的继承关系;`g(a, b)` 意为“a 属于 b”,角色链会递归解析。
|
||||||
|
- 模型只描述“怎么判”,策略才决定“允许谁”,两者可分开存储与更新。
|
||||||
|
|
||||||
|
## 2. 核心组件
|
||||||
|
|
||||||
|
Casbin 运行时由以下组件构成,多数可在不同程度上替换或选配:
|
||||||
|
|
||||||
|
| 组件 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| Model | PERM 四段组成的规则模型,描述匹配逻辑,不含具体策略 |
|
||||||
|
| Policy | 具体策略规则的集合,可在内存或外部存储 |
|
||||||
|
| Enforcer | 判定入口:把请求套进模型与策略,得到 true/false |
|
||||||
|
| Adapter | 策略的存储读写层(DB/文件等),由应用选配 |
|
||||||
|
| RoleManager | 维护角色继承关系,支撑 `g()` |
|
||||||
|
| Watcher | 感知其他实例的变更并触发重载;Dispatcher 负责派发变更事件 |
|
||||||
|
|
||||||
|
组件协作:Model + Policy 交给 Enforcer 判定;Adapter 负责把 Policy 读入/写出;RoleManager 供 `g()` 解析继承;Watcher/Dispatcher 让多实例感知变更。
|
||||||
|
|
||||||
|
## 3. 判定链与策略示意
|
||||||
|
|
||||||
|
请求 → matcher 匹配 → 命中的策略 effect 合成 → 结果。
|
||||||
|
|
||||||
|
```
|
||||||
|
g, alice, admin # alice 属于 admin
|
||||||
|
p, admin, report, read # admin 可读 report
|
||||||
|
Enforce(alice, report, read) => true # 经角色继承
|
||||||
|
Enforce(bob, report, read) => false # 无授权
|
||||||
|
```
|
||||||
|
|
||||||
|
- 判定按请求逐条扫描策略:matcher 命中后,由 effect 合成最终结论。
|
||||||
|
- `some(where (p.eft == allow))` 是白名单语义:只要有一条 allow 就放行。
|
||||||
|
|
||||||
|
## 4. 关键提醒
|
||||||
|
|
||||||
|
- **deny 与 error 都 fail-closed**:`false` 是判定为拒,`err != nil` 是判定失败;安全场景两者都当拒绝,别把错误当放行。
|
||||||
|
- **并发优先 `SyncedEnforcer`**:常用 API 有锁;普通 `Enforcer` 需自行加锁,也不要假设所有接口都并发安全。
|
||||||
|
- **内存策略不会自动跨实例同步**:Casbin 核心不内置同步,多副本要自己配置 Watcher 通知或周期重载/对账。
|
||||||
|
- **ABAC 能判断单条对象的属性/所有权,但不会自动生成 SQL 查询过滤**,列表与行级数据范围仍需业务层实现。
|
||||||
|
- **迁移 v2 → v3**:只确认 adapter 自身路径带 `/v3` 不够,要核对其 `go.mod` 是否依赖 `github.com/casbin/casbin/v3`;v2 与 v3 的 `model.Model` 是不同 Go 包类型。
|
||||||
|
- `Enforce` 的参数要与 `r` 定义一致,类型/语义由模型与 matcher 决定,不是固定三元组。
|
||||||
|
|
||||||
|
## 官方来源
|
||||||
|
|
||||||
|
- v3.0.0 release(module path 变更):<https://github.com/apache/casbin/releases/tag/v3.0.0>
|
||||||
|
- v3.11.0 `persist/adapter.go`:<https://github.com/apache/casbin/blob/v3.11.0/persist/adapter.go>
|
||||||
|
- ABAC 文档:<https://casbin.org/docs/abac>
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
### excelize库
|
# excelize
|
||||||
|
|
||||||
读写Excel的Go库
|
读写Excel的Go库
|
||||||
|
|
||||||
[github](https://github.com/qax-os/excelize)
|
[github](https://github.com/qax-os/excelize)
|
||||||
|
|
||||||
## 基本用法
|
## 创建工作簿
|
||||||
|
|
||||||
#### 创建工作簿
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
f := excelize.NewFile()
|
f := excelize.NewFile()
|
||||||
@@ -22,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 打开文件
|
## 打开文件
|
||||||
|
|
||||||
```go
|
```go
|
||||||
f, err := excelize.OpenFile(filePath)
|
f, err := excelize.OpenFile(filePath)
|
||||||
@@ -36,19 +34,19 @@
|
|||||||
}()
|
}()
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 写入cell值
|
## 写入cell值
|
||||||
|
|
||||||
```go
|
```go
|
||||||
f.SetCellValue("SheetName", "B2", 100)
|
f.SetCellValue("SheetName", "B2", 100)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 设置工作簿默认活动工作表
|
## 设置工作簿默认活动工作表
|
||||||
|
|
||||||
```go
|
```go
|
||||||
f.SetActiveSheet(index)
|
f.SetActiveSheet(index)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 创建或更新excel表
|
## 创建或更新excel表
|
||||||
|
|
||||||
```go
|
```go
|
||||||
if err := f.SaveAs("Book1.xlsx"); err != nil {
|
if err := f.SaveAs("Book1.xlsx"); err != nil {
|
||||||
@@ -56,7 +54,7 @@ f.SetActiveSheet(index)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 获取指定cell值
|
## 获取指定cell值
|
||||||
|
|
||||||
```go
|
```go
|
||||||
cell, err := f.GetCellValue("Sheet1", "B2")
|
cell, err := f.GetCellValue("Sheet1", "B2")
|
||||||
@@ -66,7 +64,7 @@ f.SetActiveSheet(index)
|
|||||||
fmt.Println(cell)
|
fmt.Println(cell)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 获取所有工作簿的cell值(二维数组)
|
## 获取所有工作簿的cell值(二维数组)
|
||||||
|
|
||||||
```go
|
```go
|
||||||
rows, err := f.GetRows("Sheet1")
|
rows, err := f.GetRows("Sheet1")
|
||||||
@@ -80,4 +78,3 @@ f.SetActiveSheet(index)
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# gorm
|
||||||
|
|
||||||
|
[官网文档](https://gorm.io/zh_CN/docs/)
|
||||||
|
|
||||||
|
**使用gorm + sqlx,但是复用sql连接**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
|
logx.Infof("Cache config: %+v", c.CacheRedis)
|
||||||
|
|
||||||
|
gormDB, err := gorm.Open(postgres.Open(c.Database.DataSource), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("连接数据库失败 %s ", err.Error())
|
||||||
|
logx.Error(msg)
|
||||||
|
panic(msg)
|
||||||
|
}
|
||||||
|
sqlDB, err := gormDB.DB()
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("获取底层连接失败: %s", err.Error())
|
||||||
|
logx.Error(msg)
|
||||||
|
panic(msg)
|
||||||
|
}
|
||||||
|
sqlxConn := sqlx.NewSqlConnFromDB(sqlDB)
|
||||||
|
return &ServiceContext{
|
||||||
|
Config: c,
|
||||||
|
UserAuthModel: models.NewUserAuthModel(sqlxConn, c.CacheRedis, gormDB),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# goverter
|
||||||
|
|
||||||
|
[GitHub地址](https://github.com/jmattheis/goverter)
|
||||||
|
|
||||||
|
类型转换器,可以用于类型的转换,比如下面的代码
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (l *CreatePopupLogic) CreatePopup(in *backgroundrpcmodel.ResPopup) (*backgroundrpcmodel.ResResult, error) {
|
||||||
|
_, err := l.svcCtx.PopupModel.Insert(l.ctx, u.Convert.SysPopupR2S(in.Info))
|
||||||
|
if err != nil {
|
||||||
|
return nil, rerr.NewFromSQL(err)
|
||||||
|
}
|
||||||
|
return &backgroundrpcmodel.ResResult{
|
||||||
|
Result: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
使用
|
||||||
|
|
||||||
|
```
|
||||||
|
goverter gen .
|
||||||
|
```
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# validator
|
||||||
|
|
||||||
|
[GitHub地址](github.com/go-playground/validator)
|
||||||
|
|
||||||
|
包验证器,可以用于验证参数是否符合规则,比如下方的比较验证,还有其他可以查看网址的详细介绍
|
||||||
|
go-zero自带的有校验器,可以使用
|
||||||
|
|
||||||
|
| Tag | Description |
|
||||||
|
| ---- | --------------------- |
|
||||||
|
| eq | Equals |
|
||||||
|
| gt | Greater than |
|
||||||
|
| gte | Greater than or equal |
|
||||||
|
| lt | Less Than |
|
||||||
|
| lte | Less Than or Equal |
|
||||||
|
| ne | Not Equal |
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
## `errors.AsType`
|
## `errors`
|
||||||
|
|
||||||
|
### `errors.AsType`
|
||||||
|
|
||||||
> *用泛型把* `errors.As` *的“先声明变量再传指针”简化为“直接返回* `(E, bool)`*”,是* `errors.As` *的语法糖升级*
|
> *用泛型把* `errors.As` *的“先声明变量再传指针”简化为“直接返回* `(E, bool)`*”,是* `errors.As` *的语法糖升级*
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
## 1.27版本变化
|
||||||
|
|
||||||
|
### 支持泛型方法
|
||||||
|
|
||||||
|
Go 1.27 虽然开放了泛型方法,但有明确的限制:
|
||||||
|
|
||||||
|
1.接口定义中不允许包含泛型方法。
|
||||||
|
|
||||||
|
2.拥有泛型方法的方法,不能用于实现接口。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 普通结构体,本身没有任何泛型参数
|
||||||
|
type Serializer struct{}
|
||||||
|
|
||||||
|
// T 属于方法 Serialize,每个调用可以独立拥有不同的 T
|
||||||
|
func (s *Serializer) Serialize[T any](data T) string {
|
||||||
|
return fmt.Sprintf("%v", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用:
|
||||||
|
sz := Serializer{}
|
||||||
|
|
||||||
|
// 同一个 sz 实例,每次调用都可以处理完全不同的类型!
|
||||||
|
str1 := sz.Serialize("hello") // 自动推导 T = string
|
||||||
|
str2 := sz.Serialize(123) // 自动推导 T = int
|
||||||
|
str3 := sz.Serialize(true) // 自动推导 T = bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 结构体字面量:字段选择器当 key
|
||||||
|
|
||||||
|
嵌套或匿名结构体中的字段,现在可以直接初始化,不必再一层层嵌套字面量
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Person struct {
|
||||||
|
Sex string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
type Student struct {
|
||||||
|
StudentID int
|
||||||
|
Person
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go 1.27 之前:必须显式嵌套 Person
|
||||||
|
s1 := Student{
|
||||||
|
StudentID: 1,
|
||||||
|
Person: Person{
|
||||||
|
Sex: "male",
|
||||||
|
Name: "张三",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Go 1.27:嵌套结构体Person的字段可以直接当 key 用
|
||||||
|
s1 := Student{
|
||||||
|
StudentID: 1,
|
||||||
|
Sex: "male",
|
||||||
|
Name: "张三",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 函数类型推断:从“手动挡”到“自动挡”
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Go 1.27 之前:必须手动写出类型实参 [int],否则编译报错
|
||||||
|
fn := IntToString(FormatToString[int])
|
||||||
|
formatter:= []IntToString{FormatToString[int]}
|
||||||
|
|
||||||
|
// Go 1.27 后:自动推导,不用手写
|
||||||
|
fn := IntToString(FormatToString)
|
||||||
|
formatter := []IntToString{FormatToString}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 标准库更新:`encoding/json/v2`
|
||||||
|
|
||||||
|
> Go 1.27 标准库中的 v1 `encoding/json` 内部已经被改用 v2 引擎驱动,保留了 v1 的原始 API 签名和行为
|
||||||
|
|
||||||
|
新旧版本主要行为对比
|
||||||
|
|
||||||
|
| **场景** | **encoding/json (v1)** | **encoding/json/v2** |
|
||||||
|
| ------------------------------- | -------------------------- | -------------------------------------------- |
|
||||||
|
| **`nil` 切片/Map 序列化** | 转为 `null` | 转为 `[]` 和 `{}` *(符合语义,防前端空指针)* |
|
||||||
|
| **重复键处理 (Duplicate Keys)** | 允许,后面的覆盖前面的 | **直接报错** *(增强安全性,防 JSON 注入)* |
|
||||||
|
| **非法 UTF-8 字符** | 格式化替换,静默忽略 | **直接报错** *(要求严格符合规范)* |
|
||||||
|
| **字段名匹配 (大小写敏感)** | 默认**忽略**大小写匹配 | 默认**严格匹配**大小写 *(提升解析性能)* |
|
||||||
|
| **固定长度数组解包** | 长度不一致时自动截断或补零 | 必须与 JSON 数组长度严格相等,否则报错 |
|
||||||
|
|
||||||
|
支持可选项
|
||||||
|
|
||||||
|
| Options | 作用 |
|
||||||
|
| -------------------------------------- | ----------------------- |
|
||||||
|
| `json.FormatNilSliceAsNull(true)` | 允许使用 v1 的 nil 行为 |
|
||||||
|
| `json.MatchCaseInsensitiveNames(true)` | 允许忽略大小写 |
|
||||||
|
|
||||||
|
新增标签`omitzero`
|
||||||
|
|
||||||
|
| **维度** | **omitempty** | **omitzero** |
|
||||||
|
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||||
|
| **判断标准** | **是否为“空 (Empty)”** (如:空字符串 `""`、长度为 0 的切片/Map、`false`、`0`、指针 `nil`) | **是否为“ Go 类型的零值 (Zero Value)”** (满足 `reflect.Value.IsZero()` 或实现 `IsZero() bool` 接口) |
|
||||||
|
| **结构体/时间类型处理** | **有重大缺陷**,对非指针的结构体(如 `time.Time`)无效!依然会被序列化。 | **完美支持** 未初始化的 `time.Time{}` 或任何嵌套结构体零值均会被正确跳过。 |
|
||||||
|
| **自定义类型零值** | 不支持自定义零值判断逻辑。 | 支持只要实现 `IsZero() bool` 方法即可自定义是否跳过。 |
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# squirrel 的坑
|
||||||
|
|
||||||
|
> `github.com/Masterminds/squirrel`,纯 SQL 查询构建器(query builder),2026-09 查证。
|
||||||
|
|
||||||
|
## 坑 1:Like 家族多 key 不排序,SQL 输出不确定(真雷)
|
||||||
|
|
||||||
|
`Eq` / `NotEq` / `Lt` / `Gt` 等 map 条件生成 SQL 前会对 key 做 `sort.Strings` 字典序排序(v1.1.0 起,`expr.go` 的 `getSortedKeys`),输出确定。
|
||||||
|
|
||||||
|
但 `Like` / `NotLike` / `ILike` / `NotILike` 的 `toSql` 是直接 `for key, val := range lk`——跟随 Go map 随机遍历序,**多 key 时每次生成的 SQL 条件顺序随机**:
|
||||||
|
|
||||||
|
```
|
||||||
|
第1次: WHERE nickname LIKE ? AND mobile LIKE ? | args=[%张% %138%]
|
||||||
|
第3次: WHERE mobile LIKE ? AND nickname LIKE ? | args=[%138% %张%] ← 顺序翻转
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后果
|
||||||
|
|
||||||
|
- 查询结果没错(AND 交换律),但 **SQL 字符串 + args 顺序在变**
|
||||||
|
- 如果用「SQL 字符串 + 参数」生成缓存 key(hash 后当 Redis key 之类),同一查询会打出多个 key:
|
||||||
|
- 命中率暴跌:n 个 Like 条件最多 n! 种排列 → n! 个缓存 key
|
||||||
|
- 同一份数据存多份副本,TTL 内占内存
|
||||||
|
- 依赖 SQL diff 的单测会随机失败
|
||||||
|
|
||||||
|
### 规避
|
||||||
|
|
||||||
|
- `Like` 只放**单个 key**
|
||||||
|
- 多模糊条件拆成多次 `.Where(...)`(内部是 append 切片,天然保序)或用 `sq.And{...}` 显式控制顺序
|
||||||
|
|
||||||
|
### 上游状态
|
||||||
|
|
||||||
|
- 无任何 issue 报告过此事(全量搜索过,含 closed),作者从未回应
|
||||||
|
- 当年修 Eq 排序的 PR #141 只改了 Eq/Lt 两处,紧邻的 Like 一行未动,讨论里也没人发现——纯遗漏,非有意设计(作者在 #135 明确说过支持排序)
|
||||||
|
- 库近乎停滞:最新 release v1.5.4(2023-03),master 最后 commit 2024-02,**别指望上游修**
|
||||||
|
|
||||||
|
## 坑 2:Eq 排序按字典序,与联合索引列序无关(知道即可)
|
||||||
|
|
||||||
|
`Eq{"b":2, "a":1}` 永远生成 `WHERE a = ? AND b = ?`,与你书写的顺序无关(issue #321)。
|
||||||
|
|
||||||
|
对主流数据库**基本无影响**:MySQL / PostgreSQL / SQL Server / Oracle 的 CBO 都会重排谓词,索引选择看的是「条件覆盖了哪些列(最左前缀)、选择度、类型是否隐式转换」,不是 WHERE 里的书写顺序。只有对谓词顺序敏感的引擎/老优化器才会踩到。
|
||||||
|
|
||||||
|
## 顺带:这个库的亮点
|
||||||
|
|
||||||
|
- builder 是**值语义不可变**的(基于 lann/builder 持久化数据结构),每次链式调用返回新副本,可安全分叉复用,不像 GORM 那样共享 Statement、分叉前要 `Session()` 克隆
|
||||||
|
- `Eq` 家族的确定性输出有源码保证(`expr.go` 排序逻辑),可放心用于缓存 key 场景
|
||||||
|
- 子查询:`FromSelect(builder, alias)`,或任何 builder 作为 `Sqlizer` 嵌进 `Where/Having/Join`
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Gitleaks 使用笔记
|
||||||
|
|
||||||
|
Gitleaks 是扫描疑似密钥的命令行工具,不需要部署 Docker。它通过规则匹配识别令牌、云平台凭据、私钥等;命中不代表凭据仍有效,未命中也不代表绝对安全。
|
||||||
|
|
||||||
|
## Linux 安装
|
||||||
|
|
||||||
|
从 [官方 Releases](https://github.com/gitleaks/gitleaks/releases/latest) 下载对应架构的 Linux 压缩包:`uname -m` 为 `x86_64` 时选 `linux_x64`,为 `aarch64` 时选 `linux_arm64`。核对下载页提供的校验和,解压后安装:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -m 0755 gitleaks /usr/local/bin/gitleaks
|
||||||
|
gitleaks version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 提交前自动检测
|
||||||
|
|
||||||
|
在目标 Git 仓库中检查 `.git/hooks/pre-commit` 是否已存在;若存在,将下面的检测命令并入现有脚本,**不要覆盖**。新建时内容为:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
gitleaks git --staged --redact --verbose .
|
||||||
|
```
|
||||||
|
|
||||||
|
然后运行 `chmod +x .git/hooks/pre-commit`。正常流程是 `git add <文件>` → `git commit -m "提交说明"`:Git 自动执行 hook;发现疑似密钥时命令返回非零状态,提交被阻止。`--redact` 避免在终端输出完整密钥。若设置了 `core.hooksPath`,应在指定的 hook 目录配置,而非 `.git/hooks/`。
|
||||||
|
|
||||||
|
## 手动扫描与测试
|
||||||
|
|
||||||
|
在仓库目录下运行:
|
||||||
|
|
||||||
|
| 命令 | 检查范围 |
|
||||||
|
| --- | --- |
|
||||||
|
| `gitleaks git --staged --redact --verbose .` | 本次暂存变更的新增内容,与上面的 hook 一致 |
|
||||||
|
| `gitleaks git --redact .` | Git 提交历史中的新增内容 |
|
||||||
|
| `gitleaks dir --redact .` | 当前目录中的文件,包括尚未暂存的文件 |
|
||||||
|
|
||||||
|
手动运行第一条命令可以先确认工具正常工作;没有命中通常返回 `0`,发现疑似密钥或扫描出错返回 `1`。普通文件修改不会因为装了 Gitleaks 就被阻止。要验证**自动拦截**,只用无效的模拟令牌测试,并先确认手动扫描能命中;随后运行普通的 `git commit`,预期 hook 阻止提交。若扫描未命中,别贸然提交测试令牌;若意外提交成功,不要推送,先检查 hook 是否执行及暂存内容。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `--staged` 扫描的是暂存差异中的新增内容,不等于检查每个暂存文件的全部历史内容;未暂存的修改也不在其中。
|
||||||
|
- 本地 hook 通常只对当前仓库有效,且可被 `git commit --no-verify` 绕过;重要项目还应在 CI 中扫描。
|
||||||
|
- 发现真实凭据时,先从暂存内容中移除并修复;若已推送或暴露,应尽快轮换凭据。误报可审慎使用 allowlist 或 `.gitleaksignore`,不要直接忽略未知来源的命中。
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Goose 数据库迁移笔记
|
||||||
|
|
||||||
|
Goose 用迁移文件管理数据库结构变更,并在数据库中记录已执行的迁移版本。
|
||||||
|
|
||||||
|
本文以 **PostgreSQL、Goose v3.29.0** 为例,命令使用 Bash 语法。
|
||||||
|
|
||||||
|
## 1. 基本概念
|
||||||
|
|
||||||
|
- **迁移文件**:按版本组织的数据库变更脚本。
|
||||||
|
- **Up**:应用变更,例如建表、增加字段。
|
||||||
|
- **Down**:撤销对应变更;能否恢复数据取决于具体 SQL。
|
||||||
|
- **版本记录**:Goose 默认通过 `goose_db_version` 表跟踪迁移状态。
|
||||||
|
- **工具版本与迁移版本不同**:`goose -version` 查看工具版本,`goose version` 查看数据库迁移版本。
|
||||||
|
|
||||||
|
通常将迁移文件放在 `migrations/` 目录。下文命令在该目录的上一级执行,目标数据库需事先创建。
|
||||||
|
|
||||||
|
## 2. 安装
|
||||||
|
|
||||||
|
需要先安装 Go,再安装指定版本的 Goose CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install github.com/pressly/goose/v3/cmd/goose@v3.29.0
|
||||||
|
goose -version
|
||||||
|
```
|
||||||
|
|
||||||
|
固定工具版本有助于保持不同环境的行为一致。Goose 可以作为独立命令行工具使用。
|
||||||
|
|
||||||
|
默认安装位置是 `$(go env GOPATH)/bin`;若设置了 `GOBIN`,则安装到该目录。找不到命令时,将实际安装目录加入 `PATH`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 未设置 GOBIN 时
|
||||||
|
export PATH="$PATH:$(go env GOPATH)/bin"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 配置数据库连接
|
||||||
|
|
||||||
|
在工作目录创建 `.env.local`,填写连接配置:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
GOOSE_DRIVER=postgres
|
||||||
|
GOOSE_MIGRATION_DIR=./migrations
|
||||||
|
GOOSE_DBSTRING="postgres://example_user:URL_ENCODED_PASSWORD@127.0.0.1:5432/example_db?sslmode=disable"
|
||||||
|
```
|
||||||
|
|
||||||
|
以上用户名、密码和数据库名都是占位值,地址与端口也需按实际环境填写。
|
||||||
|
|
||||||
|
- `GOOSE_DRIVER`:数据库驱动;连接 PostgreSQL 时使用 `postgres`。
|
||||||
|
- `GOOSE_MIGRATION_DIR`:迁移文件目录。
|
||||||
|
- `GOOSE_DBSTRING`:数据库连接串。Goose 不会自动从应用配置文件或零散的应用环境变量中拼装它。
|
||||||
|
- Goose 默认读取 `.env`;本文统一使用 `-env=.env.local` 显式指定配置文件。
|
||||||
|
- `sslmode=disable` 适用于本地无 TLS 要求的场景;其他环境按实际 TLS 要求配置。
|
||||||
|
|
||||||
|
将含凭据的配置文件加入版本控制忽略规则。类 Unix 系统还可限制文件权限:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
系统中已有的同名环境变量可能优先于配置文件生效。修改文件后连接行为未改变时,可只列出相关变量名排查,避免打印连接串:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env | cut -d= -f1 | grep -i '^GOOSE'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 连接串中的 URL 编码
|
||||||
|
|
||||||
|
使用 URL 格式的连接串时,用户名、密码中的特殊字符需要进行百分号编码。
|
||||||
|
|
||||||
|
| 字符 | 编码 | 字符 | 编码 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `@` | `%40` | `:` | `%3A` |
|
||||||
|
| `#` | `%23` | `/` | `%2F` |
|
||||||
|
| `%` | `%25` | `?` | `%3F` |
|
||||||
|
|
||||||
|
例如,演示密码 `demo#pass` 在连接串中写成 `demo%23pass`。
|
||||||
|
|
||||||
|
- 分别编码用户名和密码,不要编码整个 URL。
|
||||||
|
- 避免对已编码的内容重复编码。
|
||||||
|
- 编码只改变连接串的写法,不改变数据库中的实际密码。
|
||||||
|
- 不要将真实密码提交给在线编码网站。
|
||||||
|
|
||||||
|
## 5. 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看 Goose 工具版本
|
||||||
|
goose -version
|
||||||
|
|
||||||
|
# 离线检查迁移文件是否能被解析
|
||||||
|
goose -env=.env.local validate
|
||||||
|
|
||||||
|
# 查看迁移状态与数据库当前迁移版本
|
||||||
|
goose -env=.env.local status
|
||||||
|
goose -env=.env.local version
|
||||||
|
|
||||||
|
# 应用尚未执行的迁移
|
||||||
|
goose -env=.env.local up
|
||||||
|
|
||||||
|
# 回滚最近一个已应用的迁移;执行前检查对应 Down SQL
|
||||||
|
goose -env=.env.local down
|
||||||
|
```
|
||||||
|
|
||||||
|
`validate` 检查注解与语句拆分,不连接数据库,也不验证 SQL 在 PostgreSQL 上的语义。校验通过不代表数据库连接正常或迁移一定执行成功。
|
||||||
|
|
||||||
|
在 v3.29.0 中,`validate` 不接受位置路径参数。迁移目录通过 `GOOSE_MIGRATION_DIR` 或 `-dir` 指定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
goose -env=.env.local -dir=./migrations validate
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 创建与执行迁移
|
||||||
|
|
||||||
|
### 创建迁移文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
goose -env=.env.local -s create create_example_items sql
|
||||||
|
```
|
||||||
|
|
||||||
|
`-s` 表示使用顺序编号,版本号按目录中已有迁移延续。例如,目录为空时,首个文件可命名为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
migrations/00001_create_example_items.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 编写 Up 与 Down
|
||||||
|
|
||||||
|
下面用一张示例表演示迁移结构:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE public.example_items (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE public.example_items;
|
||||||
|
```
|
||||||
|
|
||||||
|
该示例的 Down 会删除表及其中的数据。
|
||||||
|
|
||||||
|
### 检查并执行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
goose -env=.env.local validate
|
||||||
|
goose -env=.env.local status
|
||||||
|
goose -env=.env.local up
|
||||||
|
goose -env=.env.local status
|
||||||
|
goose -env=.env.local version
|
||||||
|
```
|
||||||
|
|
||||||
|
执行前确认目标数据库已创建,且待创建的对象不存在。`status` 中的 `Pending` 表示对应迁移尚未应用。
|
||||||
|
|
||||||
|
Goose 会记录成功应用的迁移;再次执行 `up` 时,已应用版本会被跳过。
|
||||||
|
|
||||||
|
## 7. 变更与回滚原则
|
||||||
|
|
||||||
|
- **已执行的迁移文件不再修改**;后续结构变更通过新增迁移实现。
|
||||||
|
- 为需要回滚的迁移编写对应 Down,并检查它能否正确撤销结构变更。
|
||||||
|
- `down` 只回滚最近一个已应用迁移,不是清库或重建数据库的通用命令。
|
||||||
|
- 删除表或字段可能丢失数据;反向 SQL 不等于数据恢复。
|
||||||
|
- 回滚前阅读对应 SQL、评估影响并备份数据库。
|
||||||
|
- 避免手工执行与迁移文件重复的建表脚本,以免对象和版本记录不一致。
|
||||||
|
|
||||||
|
## 8. 常见问题
|
||||||
|
|
||||||
|
| 报错或现象 | 排查方向 |
|
||||||
|
|---|---|
|
||||||
|
| 找不到 `goose` 命令 | 检查安装是否成功,以及实际安装目录是否已加入 `PATH` |
|
||||||
|
| `hostname resolving error`,主机名混入非法字符 | 检查连接串分隔符,以及用户名、密码的 URL 编码 |
|
||||||
|
| `password authentication failed` | 核对用户名、密码及编码;检查系统同名环境变量是否覆盖配置 |
|
||||||
|
| `connection refused` / `i/o timeout` | 检查监听地址、端口、容器端口映射和防火墙 |
|
||||||
|
| `already exists` | 检查对象是否已被手工创建;先比较现有结构与迁移内容,再确定处理方式 |
|
||||||
|
| 修改 `.env.local` 后配置似乎未生效 | 确认使用了 `-env=.env.local`,并排查系统中已有的同名环境变量 |
|
||||||
|
|
||||||
|
遇到对象已存在的问题,不要直接删除现有数据,也不要手工插入版本记录来伪造迁移状态。
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
常见图片格式的核心区别,主要在于 **是否压缩、是否支持透明、是否支持矢量、文件大小、适合什么场景**
|
||||||
|
|
||||||
|
| 格式 | 类型 | 透明背景 | 动画 | 放大是否模糊 | 文件大小 | 常见用途 |
|
||||||
|
| -------------- | ---- | ---------- | ------ | ------------ | -------- | ------------------------ |
|
||||||
|
| **JPG / JPEG** | 位图 | ❌ | ❌ | ✅ 会模糊 | 小 | 照片、摄影、电商图 |
|
||||||
|
| **PNG** | 位图 | ✅ | ❌ | ✅ 会模糊 | 中~大 | Logo、截图、透明图片、UI |
|
||||||
|
| **SVG** | 矢量 | ✅ | 可实现 | ❌ 不模糊 | 通常很小 | Logo、图标、网页插画 |
|
||||||
|
| **WebP** | 位图 | ✅ | ✅ | ✅ 会模糊 | 很小 | 网站图片、App 图片 |
|
||||||
|
| **GIF** | 位图 | ✅但有限 | ✅ | ✅ 会模糊 | 通常较大 | 简单动图、表情包 |
|
||||||
|
| **AVIF** | 位图 | ✅ | ✅ | ✅ 会模糊 | 很小 | 新一代网页图片 |
|
||||||
|
| **BMP** | 位图 | 通常不支持 | ❌ | ✅ 会模糊 | 非常大 | 较少使用 |
|
||||||
|
| **TIFF** | 位图 | 视情况 | ❌ | ✅ 会模糊 | 很大 | 印刷、扫描、专业摄影 |
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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>
|
||||||
@@ -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` 只放镜像引用,不放凭据。
|
||||||
|
- 若在仓库中发现疑似真实凭据:先停止操作,只报告路径、行号与类型(不复述值),
|
||||||
|
随后脱敏/移除并轮换该凭据。
|
||||||
@@ -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`
|
> 实现分支:`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. 背景与目标
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,17 @@
|
|||||||
> [Polaris 项目说明文档](./Polaris%20项目说明文档.md) —— 项目定位、技术选型、数据结构、开发阶段
|
> [Polaris 项目说明文档](./Polaris%20项目说明文档.md) —— 项目定位、技术选型、数据结构、开发阶段
|
||||||
> [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. 页面定位
|
## 1. 页面定位
|
||||||
|
|
||||||
Polaris 的分类页用于展示某一类导航资源,例如:
|
Polaris 的分类页用于展示某一类导航资源,例如:
|
||||||
@@ -72,6 +83,8 @@ Figma
|
|||||||
|
|
||||||
其他分类页面复用相同结构,只需要替换分类名称、描述和链接数据。
|
其他分类页面复用相同结构,只需要替换分类名称、描述和链接数据。
|
||||||
|
|
||||||
|
> 2026-10-14 说明:本节与第 12 节的链接清单、数量均为立项期示意数据,不代表当前 `src/data/` 内容。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 与首页的关系
|
## 3. 与首页的关系
|
||||||
@@ -198,6 +211,8 @@ background: rgba(99, 102, 241, 0.1);
|
|||||||
color: #2563eb;
|
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);
|
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.1);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 2026-10-14 实现状态:上方白色玻璃面板为历史方案。当前面板容器已透明化(见第 0 章):`GlassPanel` 结构保留,但边框、背景、阴影与 backdrop-filter 均被覆盖为透明或无,背景素材直接可见。
|
||||||
|
|
||||||
容器与页面边距:
|
容器与页面边距:
|
||||||
|
|
||||||
```css
|
```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);
|
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 状态:
|
Hover 状态:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
@@ -678,6 +697,8 @@ JavaScript 包管理器
|
|||||||
|
|
||||||
## 12. 开发工具页数据示例
|
## 12. 开发工具页数据示例
|
||||||
|
|
||||||
|
> 2026-10-14 说明:本节数据为立项期示例(15 条),不代表当前 `src/data/`;当前全站 8 类 51 条链接,开发工具分类 12 条。字段结构与现行 `NavLink` 类型一致,仍可作为格式参考。
|
||||||
|
|
||||||
### 12.1 分类数据
|
### 12.1 分类数据
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -955,11 +976,15 @@ Hover 时只做轻微反馈:
|
|||||||
768px - 1279px:两列
|
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
|
```text
|
||||||
平板端:边栏宽度略小(200px - 220px),工具卡片两列
|
平板端:边栏宽度略小(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) —— 首页布局、搜索交互、视觉规范
|
||||||
> [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. 项目名称
|
## 1. 项目名称
|
||||||
|
|
||||||
@@ -20,15 +22,15 @@ Polaris 原意为“北极星”,具有方向、导航、定位、指引的含
|
|||||||
|
|
||||||
Polaris 不是后台管理系统,也不是信息密集型 Dashboard。
|
Polaris 不是后台管理系统,也不是信息密集型 Dashboard。
|
||||||
|
|
||||||
> 一个具有个人审美、可自定义背景、可快速搜索和访问常用链接的浏览器起始页。
|
> 一个具有个人审美、可快速搜索和访问常用链接的浏览器起始页。
|
||||||
|
|
||||||
产品形态是**动态可配置的个人导航站**:第一版可以静态部署,数据写死在本地配置中,但页面具有动态交互(实时时钟、搜索、侧边栏折叠、快捷键等),后续将本地数据源替换为 Go 后端 API,不需要推翻前端整体结构。
|
产品形态是**个人导航站**:数据内置于本地 JSON 配置、随构建打包,页面具有动态交互(实时时钟、搜索、侧边栏折叠、快捷键等)。页面统一经 service 层取数,不依赖具体数据源形态;后续数据源迁移另行规划。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 项目目标
|
## 3. 项目目标
|
||||||
|
|
||||||
第一阶段目标:
|
第一阶段目标(已全部实现):
|
||||||
|
|
||||||
1. 提供一个美观的个人导航首页;
|
1. 提供一个美观的个人导航首页;
|
||||||
2. 支持快速访问常用链接;
|
2. 支持快速访问常用链接;
|
||||||
@@ -36,40 +38,49 @@ Polaris 不是后台管理系统,也不是信息密集型 Dashboard。
|
|||||||
4. 支持搜索框(Google / Bing / GitHub / 本地);
|
4. 支持搜索框(Google / Bing / GitHub / 本地);
|
||||||
5. 支持当前时间与日期展示;
|
5. 支持当前时间与日期展示;
|
||||||
6. 支持左侧边栏折叠(状态持久化);
|
6. 支持左侧边栏折叠(状态持久化);
|
||||||
7. 支持背景自定义的设计预留;
|
7. 数据内置于本地 JSON 配置,结构由 Vitest 校验测试兜底;
|
||||||
8. 数据第一版写死在本地 JSON 数据配置中;
|
8. 页面组件统一经 service 层取数。
|
||||||
9. 代码结构预留未来接入 Go 后端接口的能力。
|
|
||||||
|
|
||||||
后续阶段目标:
|
背景自定义目前停留在数据模型与校验预留(`settings.background`),不驱动样式,见 6.3。
|
||||||
|
|
||||||
|
后续阶段目标(未实现):
|
||||||
|
|
||||||
1. 从 Go 后端 API 获取链接、分类、设置等数据;
|
1. 从 Go 后端 API 获取链接、分类、设置等数据;
|
||||||
2. 使用数据库持久化配置;
|
2. 使用数据库持久化配置;
|
||||||
3. 支持后台或隐藏入口管理链接;
|
3. 支持后台或隐藏入口管理链接;
|
||||||
4. 支持用户自定义背景;
|
4. 让 `settings.theme` / `settings.background` 真正驱动界面,支持自定义背景与主题切换;
|
||||||
5. 支持主题色、深色模式、导入导出、移动端适配等个性化能力。
|
5. 支持导入导出等个性化能力。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 已确认的关键决策
|
## 4. 已确认的关键决策
|
||||||
|
|
||||||
以下决策已于 2026-08-21 确认,作为实施基线:
|
以下决策于 2026-08-21 确认,是当时的实施基线;标注「历史」的条目已被后续实现取代,以当前实现为准:
|
||||||
|
|
||||||
| # | 决策项 | 结论 |
|
| # | 决策项 | 结论 | 状态 |
|
||||||
| --- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| --- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||||
| 1 | 技术栈 | 以本文档为准:Next.js + TypeScript + Tailwind CSS + shadcn/ui + lucide-react(早期 README 中 Astro 设想作废) |
|
| 1 | 技术栈 | Next.js + TypeScript + Tailwind CSS + shadcn/ui + lucide-react(早期 README 中 Astro 设想作废) | 现行 |
|
||||||
| 2 | 渲染策略 | 静态导出(`output: 'export'`),时钟/搜索/侧边栏折叠为 client 岛屿 |
|
| 2 | 渲染策略 | 静态导出(`output: 'export'`),时钟/搜索/侧边栏折叠为 client 岛屿 | 历史 |
|
||||||
| 3 | 包管理器 | pnpm |
|
| 3 | 包管理器 | pnpm | 现行 |
|
||||||
| 4 | 项目位置 | `ts/web/nextjs/polaris/` |
|
| 4 | 项目位置 | `ts/web/nextjs/polaris/` | 现行 |
|
||||||
| 5 | Logo | 现有 `logo.svg` 仅作占位,后续替换,不投入设计精力 |
|
| 5 | Logo | `logo.svg` 不投入设计精力 | 现行 |
|
||||||
| 6 | 数据载体 | 本地 JSON 数据文件(`links.json` / `categories.json` / `settings.json`),结构以 Vitest 校验测试兜底 |
|
| 6 | 数据载体 | 本地 JSON 数据文件(`links.json` / `categories.json` / `settings.json`),结构以 Vitest 校验测试兜底 | 现行 |
|
||||||
| 7 | 第一版数据范围 | 「开发工具」15 条 + 首页常用链接 8 条(跨分类条目归入真实分类,见首页设计文档 8.1);设计资源、学习资料、生活服务三个分类展示空状态 |
|
| 7 | 第一版数据范围 | 「开发工具」15 条 + 首页常用链接 8 条;设计资源、学习资料、生活服务三个分类展示空状态 | 历史 |
|
||||||
| 8 | 默认背景 | CSS 低饱和青蓝渐变,不依赖图片资产;`settings.background.type` 预留 `image` |
|
| 8 | 默认背景 | CSS 低饱和青蓝渐变,不依赖图片资产;`settings.background.type` 预留 `image` | 历史 |
|
||||||
| 9 | 深色模式 | 第一版不做;CSS 变量按双主题友好组织,后续以 `[data-theme="dark"]` 覆盖 |
|
| 9 | 深色模式 | 第一版不做;CSS 变量按双主题友好组织,后续以 `[data-theme="dark"]` 覆盖 | 历史 |
|
||||||
| 10 | 移动端 | 第一版不适配,仅桌面端 |
|
| 10 | 移动端 | 第一版不适配,仅桌面端 | 历史 |
|
||||||
| 11 | 快捷键 | Ctrl+K(macOS ⌘K)聚焦搜索框,仅首页生效;提示按平台检测显示 |
|
| 11 | 快捷键 | Ctrl+K(macOS ⌘K)聚焦搜索框,仅首页生效;提示按平台检测显示 | 现行 |
|
||||||
| 12 | 图标方案 | 四级 fallback:自定义图标 → Simple Icons / 本地图标库 → favicon 自动抓取 → 文字占位(见 6.4) |
|
| 12 | 图标方案 | 四级 fallback:自定义图标 → Simple Icons / 本地图标库 → favicon 自动抓取 → 文字占位(见 6.4) | 现行 |
|
||||||
| 13 | 后端数据库 | PostgreSQL(后续阶段);表结构不锁死 PG 特有语法(JSON 存通用 TEXT) |
|
| 13 | 后端数据库 | PostgreSQL(后续阶段);表结构不锁死 PG 特有语法(JSON 存通用 TEXT) | 现行(未来项) |
|
||||||
| 14 | 搜索行为 | 按模式严格分离:选中哪个引擎就只用哪个引擎搜索(回车直跳);仅 Local 模式显示本地结果下拉 |
|
| 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**:适合维护链接、分类、配置等结构化数据;
|
2. **TypeScript**:适合维护链接、分类、配置等结构化数据;
|
||||||
3. **Tailwind CSS**:适合快速实现玻璃感、响应式、主题变量;
|
3. **Tailwind CSS**:配合 CSS 变量实现玻璃表面、响应式与视觉 token;
|
||||||
4. **shadcn/ui**:用于基础组件,例如 Button、Input、Dialog、Command;
|
4. **shadcn/ui**:用于基础组件(Button、DropdownMenu、Tooltip);
|
||||||
5. **lucide-react**:用于侧边栏和操作图标;
|
5. **lucide-react**:用于侧边栏和操作图标;
|
||||||
6. **simple-icons**:品牌图标库(npm 包,按 slug 引用)。
|
6. **simple-icons**:品牌图标库(npm 包,按 slug 引用)。
|
||||||
|
|
||||||
测试:Vitest + Testing Library,重点覆盖纯函数(问候语分段、本地搜索匹配排序、icon 解析、搜索 URL 模板)与核心交互组件。
|
测试:Vitest + Testing Library,覆盖静态数据结构校验、service 层行为、纯函数(时间格式化与问候语分段、本地搜索匹配排序、icon 候选解析、搜索 URL 模板、偏好读写)与核心交互组件。
|
||||||
|
|
||||||
### 5.2 渲染策略
|
### 5.2 渲染策略
|
||||||
|
|
||||||
采用静态导出:
|
采用 standalone 输出:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// next.config.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 组件实现;
|
- 时钟、搜索、侧边栏折叠等交互以 client 组件实现;
|
||||||
- 产物为纯静态文件,可部署到任意静态服务器;
|
- 构建产物为 `.next/standalone` 下的最小 Node 服务器(`server.js`),`public` 与 `.next/static` 需按 Next 文档在部署时手动复制。
|
||||||
- 后续接 Go 后端时,改造 service 层为 fetch,数据页面改为客户端获取。
|
|
||||||
|
|
||||||
### 5.3 数据访问层
|
### 5.3 数据访问层(已实现)
|
||||||
|
|
||||||
**页面组件不直接依赖数据文件,一律通过 service 层获取数据。**
|
**页面组件不直接依赖数据文件,一律通过 service 层获取数据。**
|
||||||
|
|
||||||
第一版:
|
当前 service 层已实现,页面与数据文件解耦:
|
||||||
|
|
||||||
```ts
|
```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[] {
|
// src/services/categories.ts
|
||||||
return links
|
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
|
数据源切换的迁移方案另行规划;当前已确保页面组件只依赖 service 接口,不直接依赖数据文件。
|
||||||
export async function getLinks() {
|
|
||||||
const res = await fetch("/api/links")
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error("Failed to fetch links")
|
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
这样页面组件不用大规模重构。
|
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(决策 #13,未实施)。
|
||||||
|
|
||||||
后端由 Go 实现,数据库 PostgreSQL。
|
|
||||||
|
|
||||||
候选组合:
|
候选组合:
|
||||||
|
|
||||||
@@ -152,6 +162,16 @@ Go + Chi + sqlc —— 结构清晰、SQL 可控(推荐)
|
|||||||
Go + Gin + GORM —— 开发更快、上手成本低
|
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. 数据结构
|
## 6. 数据结构
|
||||||
@@ -178,6 +198,8 @@ icon: 图标名称(侧边栏,lucide 图标名)
|
|||||||
sort: 排序值
|
sort: 排序值
|
||||||
```
|
```
|
||||||
|
|
||||||
|
当前数据共 8 个分类:AI 工具、开发工具、设计资源、学习资料、效率工具、娱乐媒体、游戏资料、生活服务。
|
||||||
|
|
||||||
### 6.2 链接数据
|
### 6.2 链接数据
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -210,6 +232,8 @@ keywords: 本地搜索关键词
|
|||||||
sort: 分类内排序值
|
sort: 分类内排序值
|
||||||
```
|
```
|
||||||
|
|
||||||
|
当前数据共 51 条链接,各分类均有内容。首页常用链接 6 条(按 `pinnedSort`):GitHub、ChatGPT、Gmail、Notion、YouTube、Bilibili;`getPinnedLinks` 默认上限 8 条,取 `min(pinned 数量, 8)`。
|
||||||
|
|
||||||
### 6.3 设置数据
|
### 6.3 设置数据
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -232,6 +256,8 @@ sort: 分类内排序值
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **注意**:`theme` 与 `background` 目前仅作为数据模型与校验存在(`services/settings.ts` 解析校验,`theme` 当前仅接受 `"light-glass"`),页面未消费这两个字段,**不驱动样式**。界面视觉为固定深色玻璃,见 5.5。页面实际消费的字段是 `siteName`、`search.*` 与 `sidebar.defaultCollapsed`。
|
||||||
|
|
||||||
### 6.4 统一图标方案
|
### 6.4 统一图标方案
|
||||||
|
|
||||||
链接卡片与搜索结果的图标按以下顺序解析,逐级 fallback:
|
链接卡片与搜索结果的图标按以下顺序解析,逐级 fallback:
|
||||||
@@ -243,11 +269,11 @@ sort: 分类内排序值
|
|||||||
4. 文字占位 以上全部失败 → 标题首字符 + 圆角玻璃底兜底
|
4. 文字占位 以上全部失败 → 标题首字符 + 圆角玻璃底兜底
|
||||||
```
|
```
|
||||||
|
|
||||||
侧边栏分类导航图标使用 lucide-react(如 `home` / `code` / `palette`),不参与此 fallback 链。
|
侧边栏分类导航图标使用 lucide-react(如 `home` / `code` / `sparkles`),不参与此 fallback 链。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 未来 Go 后端 API 设计
|
## 7. 未来 Go 后端 API 设计(未实现)
|
||||||
|
|
||||||
后续接入 Go 后端时,推荐 API:
|
后续接入 Go 后端时,推荐 API:
|
||||||
|
|
||||||
@@ -282,7 +308,7 @@ PUT /api/settings/background
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 数据库表设计草案
|
## 8. 数据库表设计草案(未来)
|
||||||
|
|
||||||
> 草案使用跨数据库通用语法;JSON 以 TEXT 存储。PostgreSQL 下可将 `TEXT`(存 JSON)字段替换为 `JSONB` 以获得索引与查询能力。
|
> 草案使用跨数据库通用语法;JSON 以 TEXT 存储。PostgreSQL 下可将 `TEXT`(存 JSON)字段替换为 `JSONB` 以获得索引与查询能力。
|
||||||
|
|
||||||
@@ -337,41 +363,45 @@ CREATE TABLE settings (
|
|||||||
|
|
||||||
## 9. 开发阶段
|
## 9. 开发阶段
|
||||||
|
|
||||||
### 第一阶段:前端静态版(当前阶段)
|
第一至第三阶段已实现(以当前代码为准),第四、五阶段为未来规划。
|
||||||
|
|
||||||
|
### 第一阶段:前端基础版(已完成)
|
||||||
|
|
||||||
目标:完成首页与分类页基础体验。
|
目标:完成首页与分类页基础体验。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
页面背景(CSS 渐变)与背景遮罩
|
背景层与遮罩(样式由 CSS 定义,素材可替换)
|
||||||
左侧边栏 + 折叠(localStorage 持久化)+ 导航选中态
|
左侧边栏 + 折叠(localStorage 持久化)+ 导航选中态
|
||||||
时间 / 日期 / 问候语
|
时间 / 日期 / 问候语
|
||||||
搜索框(Google / Bing / GitHub / 本地搜索)+ Ctrl+K 快捷键
|
搜索框(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
|
```text
|
||||||
补全其余 6 个分类的链接数据
|
补全全部分类的链接数据 → 已完成(8 个分类均有内容)
|
||||||
深色玻璃主题
|
深色玻璃主题 → 已完成(固定深色玻璃,见 5.5)
|
||||||
移动端 / 平板适配
|
移动端 / 平板适配 → 移动端已完成(顶部横栏,见 5.5);
|
||||||
|
平板沿用断点降级,未做专项设计
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第三阶段:数据访问层抽象
|
### 第三阶段:数据访问层抽象(已完成)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
封装 links / categories / settings service
|
封装 links / categories / settings service → 已实现(src/services/)
|
||||||
统一 TypeScript 类型
|
统一 TypeScript 类型 → 已实现(src/types/)
|
||||||
统一错误处理与 loading 状态
|
统一错误处理与 loading 状态 → 数据解析校验失败即抛错;
|
||||||
|
loading 状态针对远程数据,当前本地数据不涉及
|
||||||
```
|
```
|
||||||
|
|
||||||
此阶段完成后,页面组件不直接关心数据来自本地配置还是 API。
|
此阶段完成后,页面组件不直接关心数据来自本地配置还是 API——当前已达成:页面只依赖 service 接口。
|
||||||
|
|
||||||
### 第四阶段:Go 后端 API
|
### 第四阶段:Go 后端 API(未来)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Go API 服务
|
Go API 服务
|
||||||
@@ -381,7 +411,7 @@ links / categories / settings 查询接口
|
|||||||
Docker 部署预留
|
Docker 部署预留
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第五阶段:管理能力
|
### 第五阶段:管理能力(未来)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
新增 / 编辑 / 删除链接
|
新增 / 编辑 / 删除链接
|
||||||
@@ -396,33 +426,49 @@ Docker 部署预留
|
|||||||
|
|
||||||
## 10. 项目目录结构
|
## 10. 项目目录结构
|
||||||
|
|
||||||
|
实际目录按 feature-first 组织:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
polaris/
|
polaris/
|
||||||
public/
|
public/
|
||||||
|
backgrounds/ # 背景素材(可随时替换,不作为设计基线)
|
||||||
|
brand/ # 站点 Logo
|
||||||
icons/ # 自定义 SVG 图标(icon: "local:xxx" 引用)
|
icons/ # 自定义 SVG 图标(icon: "local:xxx" 引用)
|
||||||
logo.svg
|
|
||||||
|
|
||||||
src/
|
src/
|
||||||
app/
|
app/
|
||||||
layout.tsx # 背景层 + 字体
|
layout.tsx # 根布局、元信息、viewport(深色)
|
||||||
page.tsx # 首页
|
page.tsx # 首页(server 组件,经 service 取数)
|
||||||
categories/
|
categories/[categoryId]/page.tsx # 分类页(generateStaticParams,未知分类 404)
|
||||||
[categoryId]/
|
globals.css # Tailwind + 深色玻璃设计 token 与组件样式(见 5.5)
|
||||||
page.tsx # 分类页(generateStaticParams 生成)
|
|
||||||
globals.css # Tailwind + CSS 变量(浅色值,深色预留覆盖位)
|
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/
|
components/
|
||||||
layout/ # AppShell / Sidebar / SidebarNavItem
|
shared/ # site-icon(四级 fallback)/ icon-candidates /
|
||||||
home/ # ClockGreeting / SearchBox / SearchResults / QuickLinks
|
# simple-icon-map / glass-panel
|
||||||
category/ # CategoryHeader / ToolGrid / ToolCard
|
ui/ # button / dropdown-menu / tooltip(shadcn/ui)
|
||||||
common/ # SiteIcon(四级 fallback)/ GlassPanel
|
|
||||||
|
|
||||||
data/ # links.json / categories.json / settings.json
|
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
|
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
|
vitest.config.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -430,4 +476,4 @@ polaris/
|
|||||||
|
|
||||||
## 11. 最终目标
|
## 11. 最终目标
|
||||||
|
|
||||||
> 打开 Polaris 后,用户可以立刻搜索、立刻访问常用网站,也可以通过侧边栏进入分类页面。页面具有个人氛围,但保持干净、高效、克制。第一版数据本地配置,后续平滑切换到 Go 后端。
|
> 打开 Polaris 后,用户可以立刻搜索、立刻访问常用网站,也可以通过侧边栏进入分类页面。页面具有个人氛围,但保持干净、高效、克制。当前数据内置于本地 JSON 配置,页面统一经 service 层取数。
|
||||||
|
|||||||
@@ -6,6 +6,43 @@
|
|||||||
>
|
>
|
||||||
> 配套设计图:[Polaris 页面设计图](../assets/Polaris页面设计图.png)(示意图,视觉细节以本文档文字规范为准)
|
> 配套设计图:[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. 页面定位
|
## 1. 页面定位
|
||||||
|
|
||||||
Polaris 首页是一个**浏览器起始页**,不是后台管理系统,也不是信息密集型 Dashboard。
|
Polaris 首页是一个**浏览器起始页**,不是后台管理系统,也不是信息密集型 Dashboard。
|
||||||
@@ -40,6 +77,8 @@ Polaris 首页是一个**浏览器起始页**,不是后台管理系统,也
|
|||||||
|
|
||||||
> 轻量玻璃感 + 极简浏览器起始页 + 柔和个人空间感
|
> 轻量玻璃感 + 极简浏览器起始页 + 柔和个人空间感
|
||||||
|
|
||||||
|
> 2026-10-14 实现状态:该方向已落地,但配色从浅色玻璃改为**深色玻璃**(深蓝底、冷白文字,见第 0 章);关键词中「玻璃感、低饱和、可换背景、安静」依然成立。
|
||||||
|
|
||||||
设计关键词:
|
设计关键词:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -136,6 +175,8 @@ AI 工具
|
|||||||
生活服务
|
生活服务
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 2026-10-14 实现状态:当前侧边栏为「首页 + 8 个分类」,新增了「游戏资料」分类,上表为立项时的 7 分类历史列表;折叠时导航项只显示图标并提供 Tooltip,移动端顶部横栏下不显示折叠按钮。
|
||||||
|
|
||||||
「首页」指向 `/`,各分类指向 `/categories/{categoryId}`。当前路由对应的导航项处于选中态,选中样式见[分类页设计文档](./Polaris%20分类页设计文档.md) 6.2 节。
|
「首页」指向 `/`,各分类指向 `/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;
|
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;
|
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
|
```css
|
||||||
@@ -311,7 +356,7 @@ height: 68px;
|
|||||||
高度:64px - 72px
|
高度:64px - 72px
|
||||||
```
|
```
|
||||||
|
|
||||||
> 移动端尺寸第一版不定义,移动端整体适配为后续阶段工作。
|
> 历史说明:立项时移动端尺寸未定义。当前移动端已适配:搜索框宽度随容器收缩,≤420px 时隐藏快捷键提示(见第 0 章)。
|
||||||
|
|
||||||
### 6.4 搜索框视觉样式
|
### 6.4 搜索框视觉样式
|
||||||
|
|
||||||
@@ -325,6 +370,8 @@ border-radius: 24px;
|
|||||||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12);
|
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 统一为:
|
placeholder 统一为:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -480,6 +527,8 @@ border-radius: 20px;
|
|||||||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12);
|
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
|
```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` 仅用于分类内排序,避免首页顺序与分类页顺序相互约束。
|
数据来源:`pinned: true` 的链接,按 `pinnedSort` 排序取前 8 个;超出 8 个时按 4 列网格换行展示。`sort` 仅用于分类内排序,避免首页顺序与分类页顺序相互约束。
|
||||||
|
|
||||||
第一版数据构成(8 条 pinned 分布于各分类,归入真实分类):
|
第一版数据构成(8 条 pinned 分布于各分类,归入真实分类):
|
||||||
@@ -576,6 +627,8 @@ border-radius: 20px;
|
|||||||
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.1);
|
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.1);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 2026-10-14 实现状态:上方浅色样式为历史方案。当前卡片为深色玻璃(`--surface-card` + blur(16px) saturate(134%)),高 76px、圆角 20px,hover 上浮与过渡不变。
|
||||||
|
|
||||||
推荐尺寸:
|
推荐尺寸:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -622,6 +675,8 @@ Polaris 支持背景自定义的设计预留。
|
|||||||
|
|
||||||
**第一版默认背景为 CSS 低饱和青蓝渐变**(模拟晨雾 / 天空氛围),不依赖图片资产。
|
**第一版默认背景为 CSS 低饱和青蓝渐变**(模拟晨雾 / 天空氛围),不依赖图片资产。
|
||||||
|
|
||||||
|
> **2026-10-14 实现状态(重要)**:背景已不再是 CSS 渐变,改为可替换的背景素材层 + 深色遮罩(由 `body` 背景层加载,见第 0 章);背景层显示与遮罩样式由现有 CSS 管理,素材的类型、路径、画面与题材由用户自行选择、不定期更换,**不写入设计规范**。下方「图片背景建议」仅作当时的设计联想,不构成约束。`settings.background.type` 目前仅有数据模型与校验,未接入渲染;「CSS 低饱和青蓝渐变」为历史方案。
|
||||||
|
|
||||||
后续支持:
|
后续支持:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -650,6 +705,8 @@ Polaris 支持背景自定义的设计预留。
|
|||||||
|
|
||||||
由于用户未来可能更换任意背景,因此需要增加背景遮罩层,保证内容可读。
|
由于用户未来可能更换任意背景,因此需要增加背景遮罩层,保证内容可读。
|
||||||
|
|
||||||
|
> 2026-10-14 实现状态:遮罩已实现,但形态与下方两侧历史值不同——当前为固定深色渐变(顶部 rgba(5, 12, 25, 0.43) → 中部 0.37 → 底部 0.47,无 backdrop blur);浅色遮罩值为历史方案,「深色主题(后续阶段预留)」未按该形式落地。
|
||||||
|
|
||||||
浅色主题:
|
浅色主题:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
@@ -668,9 +725,9 @@ backdrop-filter: blur(8px);
|
|||||||
|
|
||||||
## 10. 配色系统
|
## 10. 配色系统
|
||||||
|
|
||||||
默认采用浅色玻璃主题。
|
> 2026-10-14 实现状态:当前默认为**深色玻璃主题**(无主题切换),tokens 直接定义在 `globals.css` 的 `:root`,未使用 `[data-theme]` 覆盖;`settings.theme` 未接入样式。现行值见第 0 章;下方「推荐 CSS 变量」为立项时的浅色方案,仅作历史参考。
|
||||||
|
|
||||||
推荐 CSS 变量:
|
历史方案(浅色玻璃)推荐 CSS 变量:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
:root {
|
: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;
|
sans-serif;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 2026-10-14 实现状态:当前字体栈以 Avenir Next / Avenir 开头,未引入 Inter;中文回退与上方一致。
|
||||||
|
|
||||||
字体原则:
|
字体原则:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -775,6 +834,8 @@ font-family:
|
|||||||
第一版仅实现浅色主题、仅适配桌面端
|
第一版仅实现浅色主题、仅适配桌面端
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 历史说明:末行「仅浅色主题、仅适配桌面端」为第一版约束,已被后续实现取代——当前为深色玻璃默认主题,移动端适配已完成。
|
||||||
|
|
||||||
最终目标:
|
最终目标:
|
||||||
|
|
||||||
> 打开 Polaris 后,用户可以立刻搜索、立刻访问常用网站,也可以通过侧边栏进入分类页面。页面具有个人氛围,但保持干净、高效、克制。
|
> 打开 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 工作树。纯文档更新不要求构建或测试。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user