Compare commits
59
Commits
f40efea159
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c812cfb663 | ||
|
|
97b26635fa | ||
|
|
bef30be3fe | ||
|
|
cd0e13325f | ||
|
|
759bc3fc35 | ||
|
|
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"
|
||||||
+19
@@ -19,6 +19,20 @@ vendor/
|
|||||||
.pub/
|
.pub/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# ---- Node / TypeScript ----
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
coverage/
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# ---- Claude Code ----
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
# ---- IDE / 编辑器 ----
|
# ---- IDE / 编辑器 ----
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -28,3 +42,8 @@ build/
|
|||||||
# ---- 操作系统 ----
|
# ---- 操作系统 ----
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# ---- Note 目录 ----
|
||||||
|
note/项目/
|
||||||
|
docs/plans
|
||||||
|
go/projects/lyra/data
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## 仓库结构
|
||||||
|
|
||||||
|
按语言分层的个人练习仓库,每种语言一个顶层目录,各自独立管理依赖。
|
||||||
|
|
||||||
|
| 目录 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `go/` | Go 代码:`practice/` 是共用 module(`module lab`)的练习区,`projects/` 各项目为独立 module |
|
||||||
|
| `note/` | 个人笔记(非代码),`note/项目/` 被 gitignore |
|
||||||
|
| `dart/` | 预留,尚未建立 |
|
||||||
|
|
||||||
|
## Go 约定
|
||||||
|
|
||||||
|
- Go 代码集中在 `go/`,目录本身不是 module,按用途分两区:
|
||||||
|
- `go/practice/`——共用 module 的练习区,`go.mod` 在该目录内(见 [go/practice/README.md](go/practice/README.md))
|
||||||
|
- `go/projects/<项目名>/`——每个项目一个独立 module,自带 `go.mod`(见 [go/projects/README.md](go/projects/README.md))
|
||||||
|
- **go 命令在对应 module 的根目录执行**:练习代码在 `go/practice/` 内执行;projects 每个项目在自己的目录内执行
|
||||||
|
- 练习区中每个叶子目录通常是一个独立小程序(`package main` + `main.go`),多个小程序并存互不冲突;库包(如算法实现)与只有测试文件的包也是合法形态
|
||||||
|
- 主题目录可随时增删;写新程序时挑最贴的主题,在里面建一个新子目录
|
||||||
|
- 测试文件用 `*_test.go`,运行单个测试:`cd go/practice && go test ./testing/slice -run TestSliceAppend`
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
```
|
||||||
|
# Go 练习——在 go/practice/ 目录下执行
|
||||||
|
cd go/practice
|
||||||
|
go run ./basics/runtime/ # 运行单个程序
|
||||||
|
go test ./... # 运行全部测试(部分练习阻塞/依赖环境,不保证全过)
|
||||||
|
go test ./testing/... -v # 运行 testing 包及其子包测试
|
||||||
|
go mod tidy # 整理依赖
|
||||||
|
|
||||||
|
# Go projects——在 go/projects/<项目名>/ 各自目录下执行
|
||||||
|
go run . # 运行项目(按实际入口调整)
|
||||||
|
go mod tidy # 整理依赖
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 仓库无 CI、无 pre-commit hooks、无代码检查——每个程序自行保证可编译即可
|
||||||
|
- 笔记和练习代码严格分离:程序相关说明写在程序目录的 README.md,系统性知识放 `note/`
|
||||||
|
- `.gitignore` 已覆盖 Go 二进制、Dart/Flutter 构建产物、IDE 配置、操作系统文件
|
||||||
|
- 编辑、暂存或提交代码、笔记、配置时留意真实数据库连接、用户名密码、令牌密钥、私钥、个人路径及意外二进制/生成物
|
||||||
|
- 发现疑似真实敏感信息先暂停,只报告路径、行号和类型(不复述值),建议脱敏或移除、必要时轮换;演示占位值不算真实凭据,经用户确认无敏感风险后可继续
|
||||||
@@ -4,8 +4,9 @@
|
|||||||
|
|
||||||
## 当前语言
|
## 当前语言
|
||||||
|
|
||||||
- `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))
|
||||||
|
|
||||||
## 约定
|
## 约定
|
||||||
|
|
||||||
|
|||||||
+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。
|
||||||
|
|||||||
@@ -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,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
type User struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUser() *User {
|
||||||
|
u := User{}
|
||||||
|
return &u
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClosureFunc() func() int {
|
||||||
|
a := 1
|
||||||
|
return func() int {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func main() {
|
||||||
|
fmt.Println(NewUser())
|
||||||
|
cf:= ClosureFunc()
|
||||||
|
fmt.Println(cf())
|
||||||
|
}
|
||||||
@@ -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,34 @@
|
|||||||
|
package error
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MatchError struct {
|
||||||
|
code int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MatchError) Error() string {
|
||||||
|
return fmt.Sprintf("code: %d, error: %v", e.code, e.err)
|
||||||
|
}
|
||||||
|
func GetMatchError() error {
|
||||||
|
return &MatchError{code: 1, err: errors.New("123")}
|
||||||
|
}
|
||||||
|
func GetError() error {
|
||||||
|
return errors.New("123")
|
||||||
|
}
|
||||||
|
func TestError(t *testing.T) {
|
||||||
|
if err, ok := errors.AsType[*MatchError](GetMatchError()); ok {
|
||||||
|
t.Log(err)
|
||||||
|
} else {
|
||||||
|
t.Errorf("expected MatchError, got %T", GetMatchError())
|
||||||
|
}
|
||||||
|
if err, ok := errors.AsType[*MatchError](GetError()); ok {
|
||||||
|
t.Log(err)
|
||||||
|
} else {
|
||||||
|
t.Errorf("expected MatchError, got %T", GetError())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,18 @@
|
|||||||
|
package net
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNet(t *testing.T) {
|
||||||
|
s := net.JoinHostPort("127.0.0.1", "8080")
|
||||||
|
|
||||||
|
t.Log(s)
|
||||||
|
t.Log(s)
|
||||||
|
addrs, err := net.LookupAddr("198.18.0.18")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log(addrs)
|
||||||
|
}
|
||||||
@@ -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,34 @@
|
|||||||
|
package regularmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChineseRegExp(t *testing.T) {
|
||||||
|
reg, err := regexp.Compile("[\u4e00-\u9fa5]")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to compile regex: %v", err)
|
||||||
|
}
|
||||||
|
matchStrSlice := reg.FindAllString("hello 世界", -1)
|
||||||
|
|
||||||
|
if len(matchStrSlice) != 2 {
|
||||||
|
t.Errorf("Expected 2 matches, got %d", len(matchStrSlice))
|
||||||
|
}
|
||||||
|
if matchStrSlice[0] != "世" || matchStrSlice[1] != "界" {
|
||||||
|
t.Errorf("Expected [世, 界], got %v", matchStrSlice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChinaMobile(t *testing.T) {
|
||||||
|
reg, err := regexp.Compile(`^1(3\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\d|9[0-35-9])\d{8}$`)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to compile regex: %v", err)
|
||||||
|
}
|
||||||
|
// matchStrSlice := reg.FindAllString("123-12345678 1234-12345678", -1)
|
||||||
|
// if len(matchStrSlice) != 2 {
|
||||||
|
// t.Errorf("Expected 2 matches, got %d", len(matchStrSlice))
|
||||||
|
// }
|
||||||
|
matchStrSlice := reg.FindAllString("17623862704", -1)
|
||||||
|
t.Log(matchStrSlice[0])
|
||||||
|
}
|
||||||
@@ -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,33 @@
|
|||||||
|
# lyra 项目约定
|
||||||
|
|
||||||
|
## Redis 缓存键
|
||||||
|
|
||||||
|
- 格式:`<项目>:cache:<资源>:<查询>:v<版本>`,例如 `CategoryListCacheKey = "lyra:cache:category:list:v1"`。
|
||||||
|
- 常量命名:`<资源><查询>CacheKey`,集中定义在 `internal/pkg/constant/cacheKey.go`,业务代码引用常量,不硬编码键字符串。
|
||||||
|
- 分段含义:项目名(本项目为 `lyra`)隔离不同应用;`cache` 区分缓存与其他 Redis 用途;版本用于隔离结构不兼容的缓存数据。
|
||||||
|
- 参数化查询的键必须包含影响查询结果的条件参数;键中不得出现密码、令牌等敏感信息。
|
||||||
|
|
||||||
|
## 日志
|
||||||
|
|
||||||
|
- 请求链路(middleware、handler、service、model)需要日志时使用 `util.Logger(ctx)`,使用结构化属性记录上下文与错误。
|
||||||
|
- `util` 只从 context 读取 request ID;request ID 由 `RequestIDMiddleware` 注入,不由 `util` 生成。
|
||||||
|
- Gin 调用链向下传递 `c.Request.Context()`,不要继续传 `*gin.Context`。
|
||||||
|
- 禁止在请求处理中使用 `fmt.Printf` 或包级 `slog` 记录业务日志。
|
||||||
|
- 无请求上下文的进程启动、初始化日志可直接使用 `slog`,不强制统一为 `util.Logger`。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- 底层错误在合适的处理边界记录一次,避免每层重复记录。
|
||||||
|
- 公开响应只返回约定的业务码与对外文案,不暴露底层错误、SQL、连接信息等内部细节。
|
||||||
|
|
||||||
|
## 启动与组装
|
||||||
|
|
||||||
|
- `cmd/lyra/bootstrap.go` 负责数据库、Redis 等基础设施初始化;`cmd/lyra/dependency.go` 负责依赖组装;`cmd/lyra/main.go` 负责入口流程编排。
|
||||||
|
- `internal/config` 只定义配置结构与加载,不创建客户端;数据库/Redis 初始化留在 `cmd/lyra`,不另建占位包。
|
||||||
|
- 不全局创建数据库或 Redis 客户端,实例由入口创建并注入。
|
||||||
|
- 运行路径以 module 根为工作目录解析(例如 `etc/lyra.toml`)。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- 默认自动测试应独立于真实 PostgreSQL、Redis 和真实端口。
|
||||||
|
- 需要真实服务的联调测试单独说明和执行,不混入默认测试。
|
||||||
@@ -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,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"lyra/internal/config"
|
||||||
|
"lyra/internal/middleware"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
// 注册 pgx 驱动,供 sqlx 以 "pgx" 驱动名使用。
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setLogger(cfg *config.Config) error {
|
||||||
|
var level slog.Level
|
||||||
|
switch cfg.Log.Level {
|
||||||
|
case "debug":
|
||||||
|
level = slog.LevelDebug
|
||||||
|
case "info":
|
||||||
|
level = slog.LevelInfo
|
||||||
|
case "warn":
|
||||||
|
level = slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
level = slog.LevelError
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持的日志级别: %s", cfg.Log.Level)
|
||||||
|
}
|
||||||
|
logger := slog.New(
|
||||||
|
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: level,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPRouter(mode string) (*gin.Engine, error) {
|
||||||
|
gin.SetMode(mode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(middleware.RequestIDMiddleware())
|
||||||
|
r.Use(middleware.AccessLogMiddleware())
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// 当前没有反向代理,不信任客户端转发的 IP 头。
|
||||||
|
if err := r.SetTrustedProxies(nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("设置代理信任失败: %w", err)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDatabase(ctx context.Context, cfg config.DatabaseConfig) (*sqlx.DB, error) {
|
||||||
|
dataSourceName := fmt.Sprintf(
|
||||||
|
"host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
|
||||||
|
cfg.Host, cfg.Port, cfg.User, cfg.Name, cfg.Password,
|
||||||
|
)
|
||||||
|
db, err := sqlx.ConnectContext(ctx, cfg.Driver, dataSourceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("连接 PostgreSQL 失败,请检查连接配置、认证和网络")
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
func newCache(cfg config.CacheConfig) *redis.Client {
|
||||||
|
return redis.NewClient(&redis.Options{
|
||||||
|
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||||
|
DB: cfg.DB,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lyra/internal/cache"
|
||||||
|
"lyra/internal/config"
|
||||||
|
"lyra/internal/handler"
|
||||||
|
"lyra/internal/model"
|
||||||
|
"lyra/internal/router"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Dependencies struct {
|
||||||
|
Config *config.Config
|
||||||
|
Cache *cache.Cache
|
||||||
|
CategoryModel model.CategoryModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHandlers 集中组装各路由的处理器,入口启动时调用一次。
|
||||||
|
func buildHandlers(dpd *Dependencies) router.Handlers {
|
||||||
|
return router.Handlers{
|
||||||
|
TestHandler: handler.NewTestHandler(dpd.CategoryModel, dpd.Cache).Hello(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDependencies(cfg *config.Config, db *sqlx.DB, cacheClient *redis.Client) *Dependencies {
|
||||||
|
cache := cache.NewCache(cacheClient, time.Duration(cfg.Cache.Timeout)*time.Millisecond)
|
||||||
|
return &Dependencies{
|
||||||
|
Config: cfg,
|
||||||
|
Cache: cache,
|
||||||
|
CategoryModel: model.NewCategoryModel(db, cache),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Command lyra 是 Lyra 的程序入口,负责启动、依赖组装与退出清理。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"lyra/internal/config"
|
||||||
|
"lyra/internal/router"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := godotenv.Load(".env.local"); err != nil {
|
||||||
|
slog.Error("加载环境变量失败,请检查文件是否存在及格式是否正确")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load("etc/lyra.toml")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("加载配置失败,请检查配置文件和环境变量")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := setLogger(cfg); err != nil {
|
||||||
|
slog.Error("设置日志失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
r, err := newHTTPRouter(cfg.App.Env)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("初始化 HTTP 路由失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := newDatabase(ctx, cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("初始化数据库失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
cache := newCache(cfg.Cache)
|
||||||
|
defer cache.Close()
|
||||||
|
|
||||||
|
deps := buildDependencies(cfg, db, cache)
|
||||||
|
|
||||||
|
handlers := buildHandlers(deps)
|
||||||
|
|
||||||
|
router.RegisterRoutes(r, handlers)
|
||||||
|
|
||||||
|
if err := r.Run(net.JoinHostPort(cfg.HTTP.Host, strconv.Itoa(cfg.HTTP.Port))); err != nil {
|
||||||
|
slog.Error("启动 HTTP 服务失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,23 @@
|
|||||||
|
[app]
|
||||||
|
name = "lyra"
|
||||||
|
env = "debug" #gin支持 release | test | debug 3种mode,如果需要自定义,需要映射到这3种mode中去,gin的默认模式是debug模式
|
||||||
|
|
||||||
|
[log]
|
||||||
|
level = "info" #日志级别,支持 debug | info | warn | error |
|
||||||
|
|
||||||
|
[http]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 7777
|
||||||
|
|
||||||
|
[database]
|
||||||
|
driver = "pgx"
|
||||||
|
host = "localhost"
|
||||||
|
port = 5432
|
||||||
|
user = "admin"
|
||||||
|
name = "lab"
|
||||||
|
|
||||||
|
[cache]
|
||||||
|
host = "localhost"
|
||||||
|
port = 6379
|
||||||
|
db = 0
|
||||||
|
timeout = 200
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
module lyra
|
||||||
|
|
||||||
|
go 1.27.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
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/cespare/xxhash/v2 v2.3.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/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.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.uber.org/atomic v1.12.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 // 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.48.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
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.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||||
|
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/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/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
|
||||||
|
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
|
||||||
|
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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
|
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=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
|
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/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
|
||||||
|
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
|
||||||
|
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.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
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"`
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/json/v2"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry[T any] struct {
|
||||||
|
Key string
|
||||||
|
Value T
|
||||||
|
TTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
client *redis.Client
|
||||||
|
operationTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCache(client *redis.Client, timeout time.Duration) *Cache {
|
||||||
|
return &Cache{
|
||||||
|
client: client,
|
||||||
|
operationTimeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Get[T any](ctx context.Context, key string) (T, bool, error) {
|
||||||
|
var zero T
|
||||||
|
operationCtx, cancel := context.WithTimeout(ctx, c.operationTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
payload, err := c.client.Get(operationCtx, key).Result()
|
||||||
|
cancel()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return zero, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return zero, false, fmt.Errorf("读取缓存失败:%w", err)
|
||||||
|
}
|
||||||
|
var value T
|
||||||
|
if err := json.Unmarshal([]byte(payload), &value); err != nil {
|
||||||
|
return zero, false, fmt.Errorf("缓存解码失败:%w", err)
|
||||||
|
}
|
||||||
|
return value, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Set[T any](ctx context.Context, entry Entry[T]) error {
|
||||||
|
if entry.TTL < 0 {
|
||||||
|
return fmt.Errorf("缓存过期时间必须大于等于零: %v", entry.TTL)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return fmt.Errorf("写入缓存失败:%w", err)
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(entry.Value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("缓存编码失败:%w", err)
|
||||||
|
}
|
||||||
|
operationCtx, cancel := context.WithTimeout(ctx, c.operationTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := c.client.Set(operationCtx, entry.Key, payload, entry.TTL).Err(); err != nil {
|
||||||
|
return fmt.Errorf("写入缓存失败:%w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenCacheKey(table, sql string, args ...any) (key string, err error) {
|
||||||
|
prefix := fmt.Sprintf("lyra:cache:%s:", table)
|
||||||
|
h := md5.New()
|
||||||
|
// 写入查询语句
|
||||||
|
h.Write([]byte(sql))
|
||||||
|
// 序列化并写入每个参数
|
||||||
|
for _, arg := range args {
|
||||||
|
jsonData, err := json.Marshal(arg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("序列化参数失败:%w", err)
|
||||||
|
}
|
||||||
|
h.Write(jsonData)
|
||||||
|
}
|
||||||
|
return prefix + fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# config 包
|
||||||
|
|
||||||
|
- 负责配置定义与加载。
|
||||||
|
- 具体配置项、来源与加载方式随实际需求确定,由程序入口调用配置加载逻辑。
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
App AppConfig `mapstructure:"app"`
|
||||||
|
Log LogConfig `mapstructure:"log"`
|
||||||
|
HTTP HTTPConfig `mapstructure:"http"`
|
||||||
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppConfig struct {
|
||||||
|
Name string `mapstructure:"name"`
|
||||||
|
Env string `mapstructure:"env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
Level string `mapstructure:"level"`
|
||||||
|
}
|
||||||
|
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 CacheConfig struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
DB int `mapstructure:"db"`
|
||||||
|
Timeout int `mapstructure:"timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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,7 @@
|
|||||||
|
# handler 包
|
||||||
|
|
||||||
|
- 负责参数绑定、API DTO 与内部模型的相互转换、响应输出。
|
||||||
|
- 引用 internal/api 的 DTO,使用 Gin 完成绑定与响应。
|
||||||
|
- 响应统一使用 `httpx.OK` / `httpx.Fail`,遵循 `internal/httpx/AGENTS.md` 的状态码、业务码与错误隔离规则。
|
||||||
|
- 调用 service 时传递标准 context.Context,不下传 gin.Context。
|
||||||
|
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"lyra/internal/api"
|
||||||
|
"lyra/internal/cache"
|
||||||
|
"lyra/internal/httpx"
|
||||||
|
"lyra/internal/model"
|
||||||
|
"lyra/internal/util"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestHandler struct {
|
||||||
|
categoryModel model.CategoryModel
|
||||||
|
cache *cache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestHandler(categoryModel model.CategoryModel, cache *cache.Cache) *TestHandler {
|
||||||
|
return &TestHandler{categoryModel: categoryModel, cache: cache}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TestHandler) Hello() gin.HandlerFunc {
|
||||||
|
return func(ctx *gin.Context) {
|
||||||
|
categories, err := h.categoryModel.FindAll(ctx.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
httpx.Fail(ctx, http.StatusInternalServerError, api.CodeInternalError, "获取分类列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.cache.Set(ctx.Request.Context(), cache.Entry[[]*model.Category]{
|
||||||
|
Key: "test:categories",
|
||||||
|
Value: categories,
|
||||||
|
TTL: 5 * time.Minute,
|
||||||
|
}); err != nil {
|
||||||
|
util.Logger(ctx.Request.Context()).Error("设置缓存分类列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testResult, _, err := h.cache.Get[[]*model.Category](ctx.Request.Context(), "test:categories")
|
||||||
|
if err != nil {
|
||||||
|
util.Logger(ctx.Request.Context()).Error("获取缓存分类列表失败")
|
||||||
|
}
|
||||||
|
if len(testResult) == 0 {
|
||||||
|
util.Logger(ctx.Request.Context()).Error("缓存分类列表为空")
|
||||||
|
} else {
|
||||||
|
for _, v := range testResult {
|
||||||
|
fmt.Printf("%+v\n", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
util.Logger(ctx.Request.Context()).Info("success")
|
||||||
|
httpx.OK(ctx, 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,6 @@
|
|||||||
|
# httpx 包
|
||||||
|
|
||||||
|
- 提供公共 HTTP 响应辅助,使用现有 `OK` / `Fail` 输出统一响应。
|
||||||
|
- `Fail` 区分 HTTP 状态码与业务错误码;公开 message 为空时回退到业务码的默认文案。
|
||||||
|
- 不向公开响应写入内部错误或调试信息,避免 debug 泄漏。
|
||||||
|
- 响应结构来自 internal/api,不在这里重复定义 DTO。
|
||||||
@@ -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,30 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lyra/internal/util"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AccessLogMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
util.Logger(ctx).InfoContext(
|
||||||
|
ctx,
|
||||||
|
"http request",
|
||||||
|
"method", c.Request.Method,
|
||||||
|
"path", c.Request.URL.Path,
|
||||||
|
"status", c.Writer.Status(),
|
||||||
|
"latency_ms", time.Since(start).Milliseconds(),
|
||||||
|
"client_ip", c.ClientIP(),
|
||||||
|
"user_agent", c.Request.UserAgent(),
|
||||||
|
"response_size", c.Writer.Size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"lyra/internal/pkg/constant"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequestIDMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
requestID := c.GetHeader("X-Request-ID")
|
||||||
|
|
||||||
|
if requestID == "" {
|
||||||
|
requestID = uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("X-Request-ID", requestID)
|
||||||
|
|
||||||
|
ctx := context.WithValue(c.Request.Context(), constant.RequestIDKey, requestID)
|
||||||
|
|
||||||
|
c.Request = c.Request.WithContext(ctx)
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# model 包
|
||||||
|
|
||||||
|
- 承载数据结构定义与数据访问:使用 Squirrel/SQL 构造查询、sqlx 执行与映射结果,驱动为 pgx stdlib。
|
||||||
|
- SQL 与查询逻辑显式写在这一层,不使用 ORM 自动持久化。
|
||||||
|
- 事务边界由 service 决定;需要同一事务时由 service 传入事务执行。这是设计目标,当前没有现成的事务接口封装。
|
||||||
|
- 查询缓存采用 cache-aside:先读缓存,命中直接返回(空列表也可命中);未命中或缓存读取失败时回源数据库。
|
||||||
|
- 仅数据库查询成功后回填缓存;数据库失败不回填。缓存读写故障只记录日志并降级,不改变数据库查询结果。
|
||||||
|
- 缓存键使用 `internal/pkg/constant` 中的常量,遵循根 `AGENTS.md` 的 Redis 缓存键约定。
|
||||||
|
- 未来写路径应在事务提交后使相关缓存失效;当前 `cache` 包没有删除接口,失效策略属于后续设计约束。
|
||||||
|
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lyra/internal/cache"
|
||||||
|
"lyra/internal/pkg/constant"
|
||||||
|
"lyra/internal/pkg/query"
|
||||||
|
"lyra/internal/util"
|
||||||
|
|
||||||
|
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"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryModel interface {
|
||||||
|
FindAll(ctx context.Context) ([]*Category, error)
|
||||||
|
ListWithOptions(ctx context.Context, queryOpt query.Option, modelOpts ...ModelOption) (resp []*Link, err error)
|
||||||
|
}
|
||||||
|
type defaultCategoryModel struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
table string
|
||||||
|
cache *cache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategoryModel(db *sqlx.DB, cache *cache.Cache) CategoryModel {
|
||||||
|
return &defaultCategoryModel{db: db, table: "categories", cache: cache}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryListCacheTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
func (m *defaultCategoryModel) FindAll(ctx context.Context) ([]*Category, error) {
|
||||||
|
cached, hit, err := m.cache.Get[[]*Category](ctx, constant.CategoryListCacheKey)
|
||||||
|
if err != nil {
|
||||||
|
util.Logger(ctx).Error("读取分类列表缓存失败", "key", constant.CategoryListCacheKey, "error", err)
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if err := m.cache.Set(ctx, cache.Entry[[]*Category]{
|
||||||
|
Key: constant.CategoryListCacheKey,
|
||||||
|
Value: rows,
|
||||||
|
TTL: categoryListCacheTTL,
|
||||||
|
}); err != nil {
|
||||||
|
util.Logger(ctx).Error("写入分类列表缓存失败", "key", constant.CategoryListCacheKey, "error", err)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelOption func(*sq.SelectBuilder)
|
||||||
|
|
||||||
|
func (m *defaultCategoryModel) ListWithOptions(ctx context.Context, queryOpt query.Option, modelOpts ...ModelOption) (resp []*Link, err error) {
|
||||||
|
// 1.先构建查询语句(还没有deleted字段)
|
||||||
|
// 2.根据queryOpt的参数,设置分页、排序等
|
||||||
|
sqBuilder := sq.Select("id", "slug", "title", "sort").
|
||||||
|
From("categories").Limit(uint64(queryOpt.PageSize)).
|
||||||
|
Offset(uint64(queryOpt.Page)).
|
||||||
|
PlaceholderFormat(sq.Dollar)
|
||||||
|
for _, opt := range modelOpts {
|
||||||
|
opt(new(sqBuilder))
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args, err := sqBuilder.ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("squirrel 构造SQL出错: %w", err)
|
||||||
|
}
|
||||||
|
cacheKey, err := cache.GenCacheKey(m.table, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, hit, err := m.cache.Get[[]*Link](ctx, cacheKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if queryOpt.WithCacheWrite {
|
||||||
|
if err := m.cache.Set(ctx, cache.Entry[[]*Link]{
|
||||||
|
Key: cacheKey,
|
||||||
|
Value: result,
|
||||||
|
TTL: queryOpt.CacheTTL,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithCategorySlug(slug string) ModelOption {
|
||||||
|
return func(builder *sq.SelectBuilder) {
|
||||||
|
*builder = builder.Where(sq.Eq{"categories.slug": slug})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func WithCategoryTitle(title string) ModelOption {
|
||||||
|
return func(builder *sq.SelectBuilder) {
|
||||||
|
*builder = builder.Where(sq.Eq{"categories.title": title})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithCategorySort(sort int) ModelOption {
|
||||||
|
return func(builder *sq.SelectBuilder) {
|
||||||
|
*builder = builder.Where(sq.Eq{"categories.sort": sort})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lyra/internal/cache"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Link struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
CategoryID int64 `db:"category_id"`
|
||||||
|
Title string `db:"title"`
|
||||||
|
Description string `db:"description"`
|
||||||
|
URL string `db:"url"`
|
||||||
|
Icon *string `db:"icon"`
|
||||||
|
Keywords []string `db:"keywords"`
|
||||||
|
Sort int `db:"sort"`
|
||||||
|
Pinned bool `db:"pinned"`
|
||||||
|
PinnedSort *int `db:"pinned_sort"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkModel interface {
|
||||||
|
}
|
||||||
|
type defaultLinkModel struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
cache *cache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLinkModel(db *sqlx.DB, cache *cache.Cache) CategoryModel {
|
||||||
|
return &defaultCategoryModel{db: db, cache: cache}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package constant
|
||||||
|
|
||||||
|
const CategoryListCacheKey = "lyra:cache:category:list:v1"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package constant
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const RequestIDKey contextKey = "request_id"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package query
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPage = 1
|
||||||
|
defaultPageSize = 10
|
||||||
|
defaultIncludeDeleted = false
|
||||||
|
defaultWithCacheWrite = false
|
||||||
|
defaultCacheTTL = 5 * time.Minute
|
||||||
|
defaultNotFoundCacheTTL = 1 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type Option struct {
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
IncludeDeleted bool
|
||||||
|
WithCacheWrite bool
|
||||||
|
CacheTTL time.Duration
|
||||||
|
NotFoundCacheTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type OptionFunc func(*Option)
|
||||||
|
|
||||||
|
func NewOption(opts ...OptionFunc) *Option {
|
||||||
|
queryOpntion := &Option{
|
||||||
|
Page: defaultPage,
|
||||||
|
PageSize: defaultPageSize,
|
||||||
|
IncludeDeleted: defaultIncludeDeleted,
|
||||||
|
WithCacheWrite: defaultWithCacheWrite,
|
||||||
|
CacheTTL: defaultCacheTTL,
|
||||||
|
NotFoundCacheTTL: defaultNotFoundCacheTTL,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(queryOpntion)
|
||||||
|
}
|
||||||
|
return queryOpntion
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithPage(page int) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
if page > 0 {
|
||||||
|
o.Page = page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithPageSize(pageSize int) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
if pageSize > 0 {
|
||||||
|
o.PageSize = pageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithIncludeDeleted(includeDeleted bool) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
o.IncludeDeleted = includeDeleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithCacheWrite(withCacheWrite bool) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
o.WithCacheWrite = withCacheWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithCacheTTL(cacheTTL time.Duration) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
if cacheTTL >= 0 {
|
||||||
|
o.CacheTTL = cacheTTL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithNotFoundCacheTTL(notFoundCacheTTL time.Duration) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
if notFoundCacheTTL >= 0 {
|
||||||
|
o.NotFoundCacheTTL = notFoundCacheTTL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 @@
|
|||||||
|
# service 包
|
||||||
|
|
||||||
|
- 当前目录为占位,尚无 Go 实现;新增代码前先确认职责边界。
|
||||||
|
- 预期职责:业务规则与事务协调,通过 model 完成数据访问。
|
||||||
|
- 需要原子性时,在同一事务内协调多次写入;当前没有现成事务接口,需在实现时设计,不把各自独立提交的多次写入当成原子业务。
|
||||||
|
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
|
||||||
|
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"lyra/internal/pkg/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Logger(ctx context.Context) *slog.Logger {
|
||||||
|
logger := slog.Default()
|
||||||
|
|
||||||
|
if requestID, ok := ctx.Value(constant.RequestIDKey).(string); ok {
|
||||||
|
logger = logger.With(string(constant.RequestIDKey), requestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logger
|
||||||
|
}
|
||||||
@@ -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,服务启动时**不会**自动执行迁移。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,303 @@
|
|||||||
|
# HTTP
|
||||||
|
|
||||||
|
## 上传文件
|
||||||
|
|
||||||
|
> **https://github.com/zeromicro/zero-examples/tree/main/http/upload**
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||||
|
<title>Document</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
action="http://localhost:8888/upload"
|
||||||
|
method="post"
|
||||||
|
>
|
||||||
|
<input type="file" name="myFile" />
|
||||||
|
<input type="submit" value="upload" />
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **参数说明**
|
||||||
|
|
||||||
|
- `enctype="multipart/form-data"` 用于支持文件的二进制传输
|
||||||
|
|
||||||
|
- `<input type="file">` 让用户挑选本地文件
|
||||||
|
|
||||||
|
- `action` 和 `method`,将数据以 `POST` 方式提交到后端地址
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Name: upload-api
|
||||||
|
Host: 0.0.0.0
|
||||||
|
Port: 8888
|
||||||
|
MaxBytes: 1073741824
|
||||||
|
Timeout: 30000
|
||||||
|
```
|
||||||
|
|
||||||
|
> **yaml文件需要设置上传的最大字节,并且如果为大文件,需要考虑超时时间设置是否合理**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (l *UploadLogic) Upload(r *http.Request) (resp *types.Response, err error) {
|
||||||
|
err = r.ParseMultipartForm(10 << 27)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("错误发生:", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, handler, err := r.FormFile("myFile")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
fmt.Printf("Uploaded File: %+v\n", handler.Filename)
|
||||||
|
fmt.Printf("File Size: %+v\n", handler.Size)
|
||||||
|
fmt.Printf("MIME Header: %+v\n", handler.Header)
|
||||||
|
|
||||||
|
tempFile, err := os.Create(path.Join(".", handler.Filename))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tempFile.Close()
|
||||||
|
io.Copy(tempFile, file)
|
||||||
|
|
||||||
|
return &types.Response{
|
||||||
|
OK: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **API逻辑说明**
|
||||||
|
|
||||||
|
- 需要在handler里面将`*http.Request`传递到逻辑层
|
||||||
|
|
||||||
|
- `ParseMultipartForm`用于解析 `multipart/form-data` 类型的请求体
|
||||||
|
|
||||||
|
主要作用是:
|
||||||
|
|
||||||
|
解析表单字段(包括文件上传字段)
|
||||||
|
|
||||||
|
将请求体拆分成多个部分(parts),并填充到 `r.MultipartForm` 中
|
||||||
|
|
||||||
|
内存与磁盘分配:最多在内存中保留 `maxFileSize` 字节,超出的部分会写入临时文件
|
||||||
|
|
||||||
|
- `FormFile(key string)` 从已解析的 `r.MultipartForm` 中提取名为 `"myFile"` 的文件部分
|
||||||
|
|
||||||
|
- `tempFile, err := os.Create(path.Join(".", handler.Filename))`将文件上传到当前目录
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# DTM
|
||||||
|
|
||||||
|
## 基础概念
|
||||||
|
|
||||||
|
- **AP(Application)**:应用程序,发起全局事务请求。
|
||||||
|
- **TM(Transaction Manager)**:事务协调者,负责协调分布式事务的执行流程。
|
||||||
|
- **RM(Resource Manager)**:资源管理者,例如订单服务、库存服务等,执行具体业务与补偿逻辑
|
||||||
|
- **事务分支**:我们把每个服务管理的全局事务组成部分,称为事务分支,例如前面的转账,分为转出和转入两个事务分支
|
||||||
|
- **分支操作**:每个事务分支,在SAGA、XA、TCC等事务模式下,会有多个操作,例如转出事务分支,包括正向操作TransOut和TransOutCompensate
|
||||||
|
- **本地事务**:转出事务分支中的正向操作,通常会开启一个事务,对余额进行扣减,我们将数据操作的这个事务,称为本地事务
|
||||||
|
- **GID**:全局事务ID,用于标记全局事务,必须唯一。该ID可以采用UUID生成,也可以使用业务上的ID,例如订单ID等
|
||||||
|
|
||||||
|
| 模式 | 场景 | 典型使用 |
|
||||||
|
| ---------- | ---------------------------------------- | --------------------------------- |
|
||||||
|
| 二阶段消息 | 需要确保消息通知或缓存更新与本地事务同步 | 发短信/推送、消息投递、缓存一致性 |
|
||||||
|
| Saga | 流程复杂、长链路、不需强一致性 | 电商下单→扣库存→支付等微服务流程 |
|
||||||
|
| TCC | 强一致、高隔离资源场景 | 金融系统、库存冻结、大额支付 |
|
||||||
|
|
||||||
|
## 二阶段消息
|
||||||
|
|
||||||
|
是dtm首创的事务模式,用于替换本地事务表和事务消息这两种现有的方案。它能够保证本地事务的提交和全局事务提交是“原子的”,适合解决不需要回滚的分布式事务场景,比如发消息、更新缓存等
|
||||||
|
|
||||||
|
- **准备阶段**(Prepare) ,应用先发一个准备信号给 DTM Server,表示“我现在要做本地事务了”。DTM Server 接收到后先记录这个请求,但不触发后续通知。
|
||||||
|
- 执行本地事务, 应用开始执行本地事务(如写数据库、扣款),并在同一个数据库里,记录这次全局事务的 gid(全局事务 ID)。这保证了后续可以根据 gid 判断本地事务是否已成功。
|
||||||
|
- **提交阶段**(Submit), 本地事务成功后,应用再向 DTM 提交提交请求。DTM 执行这个操作后,才真正触发下游服务或消息投递。开始调用其他微服务或消息队列,实现完整的全局动作。
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
|
||||||
|
- 保证“本地事务 + 消息”业务要么同时成功,否则都不执行。(强触发保障)
|
||||||
|
- 对开发者透明,业务侵入小。
|
||||||
|
|
||||||
|
**缺点:**
|
||||||
|
|
||||||
|
- 需要开发一套 Prepare/Submit 调用,系统需定期监控、回查状态。
|
||||||
|
- 不适用于需要复杂补偿的场景。
|
||||||
|
|
||||||
|
## SAGA
|
||||||
|
|
||||||
|
核心思想是将长事务拆分为多个短事务,由Saga事务协调器协调,如果每个短事务都成功提交完成,那么全局事务就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。例如我们要进行一个类似于银行跨行转账的业务,将A中的30元转给B,根据Saga事务的原理,我们将整个全局事务,切分为以下服务:
|
||||||
|
|
||||||
|
- **转出**(TransOut)服务,这里转出将会进行操作A-30
|
||||||
|
- **转出补偿**(TransOutCompensate)服务,回滚上面的转出操作,即A+30
|
||||||
|
- **转入**(TransIn)服务,转入将会进行B+30
|
||||||
|
- **转入补偿**(TransInCompensate)服务,回滚上面的转入操作,即B-30
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
|
||||||
|
- 流程自然、开发简单,只要为操作实现补偿逻辑即可。
|
||||||
|
- 对资源无锁设计,支持高并发、长流程,性能佳。
|
||||||
|
|
||||||
|
**缺点:**
|
||||||
|
|
||||||
|
- 是最终一致性,存在临时中间状态(如库存已扣但未支付)。
|
||||||
|
- 无事务隔离,可能出现脏写或并发冲突。
|
||||||
|
- 补偿逻辑可能复杂易错(幂等、防重复)。
|
||||||
|
|
||||||
|
## TCC
|
||||||
|
|
||||||
|
TCC 是一种“预留-确认-取消”的事务模式,将事务分为三个阶段,高精度控制点 的场景,比如:支付系统、资金冻结、库存预扣等
|
||||||
|
|
||||||
|
- **Try** 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)
|
||||||
|
- **Confirm** 阶段:如果所有分支的Try都成功了,则走到Confirm阶段。Confirm真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源
|
||||||
|
- **Cancel** 阶段:如果所有分支的Try有一个失败了,则走到Cancel阶段。Cancel释放 Try 阶段预留的业务资源。
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
|
||||||
|
- 强一致性:通过 Try 预留资源,Confirm/Cancel 明确执行或回滚,避免中间状态。
|
||||||
|
- 高并发友好,资源锁粒度小;性能优于传统 XA/2PC。
|
||||||
|
|
||||||
|
**缺点:**
|
||||||
|
|
||||||
|
- 业务侵入性高,每个参与方需实现三个操作(Try/Confirm/Cancel)。
|
||||||
|
- 实现复杂:需处理悬挂、空回滚、幂等、补偿重试等问题。
|
||||||
|
|
||||||
|
## SAGA实践
|
||||||
|
|
||||||
|
- 准备RM和DTM数据表
|
||||||
|
|
||||||
|
[GitHub地址](https://github.com/dtm-labs/dtm/tree/main/sqls)
|
||||||
|
|
||||||
|
- DTM配置
|
||||||
|
|
||||||
|
[GitHub地址](https://github.com/dtm-labs/dtm/blob/main/conf.sample.yml)
|
||||||
|
|
||||||
|
- docker部署参考配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
LogLevel: 'info'
|
||||||
|
Server:
|
||||||
|
HttpPort: 36789 #HTTP port
|
||||||
|
GrpcPort: 36790 #Grpc port
|
||||||
|
Store:
|
||||||
|
Driver: "mysql" # 数据库类型
|
||||||
|
Host: "mysql" # 使用容器名
|
||||||
|
Port: 3306
|
||||||
|
User: "root"
|
||||||
|
Password: "root"
|
||||||
|
Db: "dtm"
|
||||||
|
MicroService:
|
||||||
|
Driver: "dtm-driver-gozero" #使用go-zero的注册服务发现驱动
|
||||||
|
Target: "etcd://etcd:2379/dtmservice" # 使用容器名,当前dtm的server直接注册到微服务所在的etcd集群中使用
|
||||||
|
EndPoint: "dtm:36790" # 使用容器名,集群中的微服务可以直接通过etcd获得此地址跟dtm交互
|
||||||
|
```
|
||||||
|
|
||||||
|
- go-zero的yaml参考配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
#AP使用etcd的配置
|
||||||
|
Etcd:
|
||||||
|
Hosts:
|
||||||
|
- etcd:2379
|
||||||
|
Key: order.api
|
||||||
|
#RM使用etcd的配置
|
||||||
|
OrderRpc:
|
||||||
|
Etcd:
|
||||||
|
Hosts:
|
||||||
|
- etcd:2379
|
||||||
|
Key: order.rpc
|
||||||
|
#RM使用etcd的配置
|
||||||
|
StockRpc:
|
||||||
|
Etcd:
|
||||||
|
Hosts:
|
||||||
|
- etcd:2379
|
||||||
|
Key: stock.rpc
|
||||||
|
```
|
||||||
|
|
||||||
|
SAGA 事务编排使用步骤
|
||||||
|
|
||||||
|
```go
|
||||||
|
// dtm服务在etcd中的调用地址
|
||||||
|
var dtmServer = "etcd://localhost:2379/dtmservice"
|
||||||
|
|
||||||
|
// 从配置中通过BuildTarget获得对应RM的etcd调用地址
|
||||||
|
orderTarget, err := l.svcCtx.Config.OrderRpcConf.BuildTarget()
|
||||||
|
stockTarget, err := l.svcCtx.Config.StockRpcConf.BuildTarget()
|
||||||
|
|
||||||
|
// 构造对应的请求结构体,用于后面的本地事务的执行或者回滚补偿
|
||||||
|
createOrderReq := &order.CreateReq{UserId: req.UserId, GoodsId: req.GoodsId, Num: req.Num}
|
||||||
|
deductReq := &stock.DecuctReq{GoodsId: req.GoodsId, Num: req.Num}
|
||||||
|
|
||||||
|
// 使用dtmgrpc注册一个全局事务ID,用于标记全局事务
|
||||||
|
gid := dtmgrpc.MustGenGid(dtmServer)
|
||||||
|
|
||||||
|
// 开启全局事务,并提交
|
||||||
|
// dtm需要从dtm服务器调用该方法,所以不走强类型,而是走动态的url: busiServer+"/trans.TransSvc/TransOut"
|
||||||
|
// "/trans.TransSvc/TransOut"对应的是grpc_pb.go文件中Invoke方法所使用的路径
|
||||||
|
saga := dtmgrpc.NewSagaGrpc(dtmServer, gid).
|
||||||
|
Add(
|
||||||
|
orderTarget+"/pb.order/create",
|
||||||
|
orderTarget+"/pb.order/createRollback",
|
||||||
|
createOrderReq,
|
||||||
|
).
|
||||||
|
Add(
|
||||||
|
stockTarget+"/pb.stock/deduct",
|
||||||
|
stockTarget+"/pb.stock/deductRollback",
|
||||||
|
deductReq,
|
||||||
|
)
|
||||||
|
err = saga.Submit()
|
||||||
|
```
|
||||||
|
|
||||||
|
服务实现(包含子事务屏障)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 从 gRPC 请求上下文中提取分布式事务信息,构建一个用于管理子事务屏障的对象
|
||||||
|
barrier, err := dtmgrpc.BarrierFromGrpc(l.ctx)
|
||||||
|
// 获取一个sqlx的数据库连接
|
||||||
|
db, err := sqlx.NewMysql(l.svcCtx.Config.DB.DataSource).RawDB()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// 一般数据库不会错误不需要dtm回滚,就让他一直重试
|
||||||
|
// 返回为status.Error(codes.Internal, err.Error()),会让dtm一直重试,不会回滚,直到
|
||||||
|
// 返回为status.Error(codes.Aborted, dtmcli.ResultFailure),会让dtm立刻调用回滚方法
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开启本地事务(这里没写,正常应该在本地事务中再开启子事务屏障进行业务处理
|
||||||
|
// 将业务逻辑包裹在子事务屏障的保护下执行
|
||||||
|
if err := barrier.CallWithDB(db, func(tx *sql.Tx) error {
|
||||||
|
order := new(model.Order)
|
||||||
|
order.GoodsId = in.GoodsId
|
||||||
|
order.Num = in.Num
|
||||||
|
order.UserId = in.UserId
|
||||||
|
|
||||||
|
_, err = l.svcCtx.OrderModel.Insert(tx, order)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建订单失败 err : %v , order:%+v \n", err, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
// 同上,让dtm一直重试,不回滚
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 补偿操作跟上面差不多,就是业务逻辑修改
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
|
||||||
|
|
||||||
|
## oh-my-openagent
|
||||||
|
|
||||||
|
### Agent角色
|
||||||
|
|
||||||
|
| **Agent 名称** | **属性** | **一句话特点** |
|
||||||
|
| ----------------- | -------- | ------------------------------------------------------------ |
|
||||||
|
| Sisyphus | 台前主控 | 项目总管、日常聊天与轻量开发入口,居中调度,维持记忆的多面手 |
|
||||||
|
| Prometheus | 台前主控 | 复杂需求的规划者、Gap(差距)分析师,只出谋划策(写 Plan),不动手写代码 |
|
||||||
|
| Atlas | 台前主控 | 流水线执行者、项目经理,盯着 TODO 督战的施工队包工头 |
|
||||||
|
| Hephaestus | 台前主控 | 火神、地狱级 Bug 终结者,单兵作战,不计成本死磕疑难杂症 |
|
||||||
|
| Oracle | 幕后工具 | 代码审查员、安全顾问,挑刺、CR、架构安全性把关 |
|
||||||
|
| Momus | 幕后工具 | Debug 专家,专门吃报错日志并给出修复建议 |
|
||||||
|
| Metis | 幕后工具 | 质检员、对齐审计,检查代码跟计划对不对齐,防止跑偏 |
|
||||||
|
| Sisyphus-junior | 幕后工具 | 基层打工人、代码搬砖牛马,真正动手改文件的“牛马码农” |
|
||||||
|
| Explore | 幕后工具 | 代码库检索员,在代码库里疯狂乱窜、扫图、找文件 |
|
||||||
|
| Librarian | 幕后工具 | 文档与知识检索员,专门翻文档、查第三方库用法 |
|
||||||
|
| Multimodal-looker | 幕后工具 | 视觉质检员,负责看截图、核对 UI 视觉问题 |
|
||||||
|
|
||||||
|
### 内置分类预设
|
||||||
|
|
||||||
|
| 分类名称(Category) | 预设心智与应用场景 |
|
||||||
|
| ------------------ | ------------------------------------------------------------ |
|
||||||
|
| visual-engineering | **像素级前端 UI 专家**。专门处理前端组件、页面样式美化、CSS 动画调整,默认偏向调度带有视觉能力的模型。 |
|
||||||
|
| deep | **目标导向的硬核攻坚战**。针对棘手难啃的底层问题,严格遵循“单次调用:一个目标 + 一个交付物”原则,死磕到底。 |
|
||||||
|
| quick | **低成本牛马杂活线**。处理单文件小改动、错别字修复、简单注释补充。默认绑定极便宜的 Mini 或 Flash 模型来帮你疯狂省钱。 |
|
||||||
|
| ultrabrain | 高浓度脑力推演 |
|
||||||
|
| writing | **散文与技术文档写手**。专门用来编写官方 Markdown 文档、技术方案白皮书或代码内说明。 |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## oh-my-opencode-slim
|
||||||
|
|
||||||
|
### 预设
|
||||||
|
|
||||||
|
`oh-my-opencode-slim.json`中的顶级字段`presets`为预设对象数组,默认设置了两套:一套是`openai`,一套是`opencode-go`。可以自行增删预设或者修改`agent`角色的模型。
|
||||||
|
|
||||||
|
```
|
||||||
|
# 在opencode会话中输入
|
||||||
|
/preset <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent角色
|
||||||
|
|
||||||
|
| **Agent 名称** | **角色** | **模型选择** |
|
||||||
|
| -------------- | ----------------------- | ------------------------------------------------------------ |
|
||||||
|
| Orchestrator | 首席委派者和战略协调员 | 选择您的默认、最强的全能型编程模型。Orchestrator 既是核心编程智能体,又是委派者,因此它需要强大的实现能力、出色的判断力和可靠的指令遵循度。 |
|
||||||
|
| Explorer | 代码库侦察 | 选择快速、低成本的模型。Explorer 处理宽泛的侦察工作,因此速度 and 效率通常比使用最强推理模型更重要。 |
|
||||||
|
| Oracle | 战略顾问和终极调试者 | 选择您最强的高推理模型,用于架构设计、疑难调试、方案权衡以及代码审查。 |
|
||||||
|
| Council | 多LLM共识与提炼 | 使用一个强大的综合提炼模型作为 Council 智能体本身,并选择多样化的模型作为议员。Council 的价值在于对比不同的模型视角,而不仅仅是在所有地方都选择同一个最强的模型。 |
|
||||||
|
| Librarian | 外部知识检索 | 选择快速、低成本的模型。Librarian 处理调研和文档查询,因此速度和效率通常比使用最强推理模型更重要。 |
|
||||||
|
| Designer | UI/UX实现和极致视觉呈现 | 选择在 UI/UX 判断、前端实现和视觉打磨方面表现强劲的模型。 |
|
||||||
|
| Fixer | 快速实现专家 | 选择一个快速、可靠的编程模型来执行常规且范围明确的开发工作。Fixer 通常从 Orchestrator 接收具体的计划或受限的指令,非常适合高效执行诸如编写测试、更新测试和直接的代码更改等任务。 |
|
||||||
|
| Observer | 多模态处理 | 如果Orchestrator模型不是多模态模型,可以启用 Observer 来处理图像、屏幕截图、PDF 以及其他视觉文件。 |
|
||||||
|
|
||||||
|
### Skill分配
|
||||||
|
|
||||||
|
## 技能分配
|
||||||
|
|
||||||
|
控制每个代理人可以使用的技能`~/.config/opencode/oh-my-opencode-slim.json`(或`.jsonc`):
|
||||||
|
|
||||||
|
| 句法 | 意义 |
|
||||||
|
| ------------------- | ------------------------------- |
|
||||||
|
| `["*"]` | 所有已安装的技能 |
|
||||||
|
| `["*", "!codemap"]` | 除以下技能外的所有技能`codemap` |
|
||||||
|
| `["simplify"]` | 仅有的`simplify` |
|
||||||
|
| `[]` | 没有技能 |
|
||||||
|
| `["!*"]` | 拒绝所有技能 |
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
### 常用命令
|
||||||
|
|
||||||
|
#### 一、分支管理
|
||||||
|
|
||||||
|
| 操作 | 命令 | 说明 |
|
||||||
|
| -------------------- | -------------------------------------- | ---------------------------- |
|
||||||
|
| 查看本地分支 | `git branch` | 显示所有本地分支 |
|
||||||
|
| 查看远程分支 | `git branch -r` | 显示远程分支 |
|
||||||
|
| 创建新分支 | `git checkout -b feature/xxx` | 基于当前分支创建并切换新分支 |
|
||||||
|
| 基于远程创建本地分支 | `git checkout -b dev origin/dev` | 跟踪远程分支创建 |
|
||||||
|
| 删除本地分支 | `git branch -d feature/xxx` | 删除(已合并)本地分支 |
|
||||||
|
| 删除远程分支 | `git push origin --delete feature/xxx` | 删除远程分支 |
|
||||||
|
| 重命名分支 | `git branch -m old new` | 修改分支名 |
|
||||||
|
|
||||||
|
#### 二、提交相关
|
||||||
|
|
||||||
|
| 操作 | 命令 | 说明 |
|
||||||
|
| ------------------ | ------------------------------- | ------------------------------------------ |
|
||||||
|
| 添加到暂存区 | `git add .` 或 `git add <file>` | 添加改动文件 |
|
||||||
|
| 提交代码 | `git commit` | 使用文本编辑器编写提交信息后提交 |
|
||||||
|
| 提交代码 | `git commit -m "描述信息"` | 提交到本地仓库,可使用多个`-m`提交多行信息 |
|
||||||
|
| 修改上一次提交信息 | `git commit --amend` | 用于补充或改描述 |
|
||||||
|
| 查看提交历史 | `git log --oneline` | 简洁模式显示历史 |
|
||||||
|
|
||||||
|
#### 三、远程操作
|
||||||
|
|
||||||
|
| 操作 | 命令 | 说明 |
|
||||||
|
| ---------------- | ----------------------------------------------------------- | ------------------- |
|
||||||
|
| 克隆仓库 | `git clone <url>` | 拉取远程项目 |
|
||||||
|
| 拉取远程代码 | `git pull` | 获取并合并远程分支 |
|
||||||
|
| 推送代码 | `git push` | 推送当前分支到远程 |
|
||||||
|
| 推送并设置上游 | `git push -u origin <branch>` | 建立跟踪关系 |
|
||||||
|
| 查看远程地址 | `git remote -v` | 显示远程仓库地址 |
|
||||||
|
| 远程查询所有标签 | `git ls-remote --tags https://github.com/xxx.git \| grep X` | 查看X相关的所有标签 |
|
||||||
|
|
||||||
|
#### 四、合并与变基
|
||||||
|
|
||||||
|
| 操作 | 命令 | 说明 |
|
||||||
|
| -------------- | ------------------------------------ | ------------------------------- |
|
||||||
|
| 合并分支 | `git merge feature/xxx` | 把目标分支合并进当前分支 |
|
||||||
|
| 取消合并 | `git merge --abort` | 终止合并,直接回退到合并前的状态 |
|
||||||
|
| 变基(rebase) | `git rebase dev` | 让你的提交在最新 `dev` 上 |
|
||||||
|
| 解决冲突后继续 | `git add . && git rebase --continue` | 继续变基或合并流程 |
|
||||||
|
|
||||||
|
#### 五、查看状态和差异
|
||||||
|
|
||||||
|
| 操作 | 命令 | 说明 |
|
||||||
|
| ---------------- | ------------------- | ---------------------------- |
|
||||||
|
| 查看状态 | `git status` | 当前分支、暂存区、工作区状态 |
|
||||||
|
| 查看修改内容 | `git diff` | 查看未暂存的更改 |
|
||||||
|
| 查看暂存内容差异 | `git diff --cached` | 查看已 add 的更改 |
|
||||||
|
|
||||||
|
#### 六、撤销操作
|
||||||
|
|
||||||
|
| 操作 | 命令 | 说明 |
|
||||||
|
| ----------------------- | ------------------------- | -------------------- |
|
||||||
|
| 撤销 add | `git reset <file>` | 把文件移出暂存区 |
|
||||||
|
| 撤销 commit(保留修改) | `git reset --soft HEAD~1` | 撤销最近一次提交 |
|
||||||
|
| 丢弃修改 | `git checkout -- <file>` | 丢弃对文件的本地更改 |
|
||||||
|
| 丢弃所有工作区修改 | `git reset --hard` | 恢复到最后一次提交 |
|
||||||
|
|
||||||
|
#### 七、其他常用
|
||||||
|
|
||||||
|
| 操作 | 命令 | 说明 |
|
||||||
|
| ---------------------- | ------------------------------------------------------------ | ---- |
|
||||||
|
| 查看当前分支 | `git branch --show-current` 或 `git rev-parse --abbrev-ref HEAD` | |
|
||||||
|
| 查看某次提交内容 | `git show <commit-id>` | |
|
||||||
|
| 切换到上一个分支 | `git checkout -` | |
|
||||||
|
| 清理无效引用 | `git remote prune origin` | |
|
||||||
|
| 从远程仓库获取所有标签 | `git fetch origin --tags` | |
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
|
||||||
|
|
||||||
|
[来源网站](https://www.conventionalcommits.org/zh-hant/v1.0.0/)
|
||||||
|
|
||||||
|
## 提交说明
|
||||||
|
|
||||||
|
### 基础结构
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>[optional scope]: <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
|
||||||
|
# 译文
|
||||||
|
|
||||||
|
<类型>[可选 范围]: <描述>
|
||||||
|
|
||||||
|
[可选 正文]
|
||||||
|
|
||||||
|
[可选 脚注]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 类型(type)
|
||||||
|
|
||||||
|
- feat: 表示在代码库中新增了一个功能;
|
||||||
|
- fix: 表示在代码库中修复了一个 bug;
|
||||||
|
|
||||||
|
- build: 用于修改项目构建系统或外部依赖,例如升级依赖版本等;
|
||||||
|
- chore: 用于其他不修改`src`或`test`文件的杂项任务,例如更新`.gitignore`、修改编辑器配置等;
|
||||||
|
- ci: 用于修改持续集成/持续部署的配置与脚本,例如`GitHub Actions`、`Jenkins`、`Travis` 等工作流;
|
||||||
|
- docs: 用于修改文档,例如修改`README`文件、API文档等;
|
||||||
|
- style: 用于修改代码格式(不影响代码逻辑),例如调整缩进、空格、分号、引号风格等;
|
||||||
|
- refactor: 用于重构代码,例如修改代码结构、变量名、函数名等但不修改功能逻辑;
|
||||||
|
- improvement: 用于对现有实现进行改进,例如优化算法、增强健壮性等(不涉及新功能或 bug 修复);
|
||||||
|
- revert: 用于回退之前的提交,描述应使用被回退提交的标题,并在脚注中用 `Refs:` 引用被回退的 commit SHA;
|
||||||
|
- perf: 用于优化性能,例如提升代码的性能、减少内存占用等;
|
||||||
|
- wip: 用于保存尚未完成的工作进度(Work In Progress);
|
||||||
|
- test: 用于修改测试用例,例如添加、删除、修改代码的测试用例等;
|
||||||
|
|
||||||
|
> 破坏性变更**必须**在提交信息中标记出来,有两种方式:
|
||||||
|
>
|
||||||
|
> 1.在 `<类型>[可选 范围]` 后、冒号前添加 `!`,例如:`feat!: ...` 或 `refactor(api)!: ...`
|
||||||
|
>
|
||||||
|
> 2.*在脚注中添加* `BREAKING CHANGE: <描述>`
|
||||||
|
>
|
||||||
|
> 这两种方式可以同时使用,也可以只用其一。
|
||||||
|
|
||||||
|
### 范围(Scope)
|
||||||
|
|
||||||
|
范围用于标识本次修改影响的模块或组件,例如:
|
||||||
|
|
||||||
|
- feat(auth): ... (auth 模块)
|
||||||
|
|
||||||
|
- fix(parser): ... (parser 模块)
|
||||||
|
|
||||||
|
- build(deps): ... (依赖管理)
|
||||||
|
|
||||||
|
范围名称建议使用小写英文,多个单词用 `-` 连接,例如 `user-profile`。
|
||||||
|
|
||||||
|
### 描述(description)
|
||||||
|
|
||||||
|
描述用于简要概括本次变更的内容,要求:
|
||||||
|
|
||||||
|
- 使用祈使语气、现在时态(例如用 "add" 而非 "added" 或 "adds");
|
||||||
|
|
||||||
|
- 小写开头,末尾不加句号;
|
||||||
|
|
||||||
|
- 长度建议控制在50个字符以内(中文约 25 个字);
|
||||||
|
|
||||||
|
### 正文(Body)
|
||||||
|
|
||||||
|
正文是可选的,用于对本次变更进行更详细的说明,包括:
|
||||||
|
|
||||||
|
- 变更的动机(为什么这样做)
|
||||||
|
|
||||||
|
- 与之前行为的对比
|
||||||
|
|
||||||
|
- 实现方案的简要说明
|
||||||
|
|
||||||
|
正文与描述之间**必须**空一行,正文内部段落之间也可以空行分隔。
|
||||||
|
|
||||||
|
### 脚注(Footer)
|
||||||
|
|
||||||
|
脚注用于记录元信息,常见用法:
|
||||||
|
|
||||||
|
- `BREAKING CHANGE: <描述>` — 标记破坏性变更;
|
||||||
|
|
||||||
|
- `Closes #123` 或 `Fixes #456` — 关联 Issue/PR;
|
||||||
|
|
||||||
|
- `Refs: abc123` — 引用相关提交;
|
||||||
|
|
||||||
|
- `Reviewed-by: <name>` — 标记审查者;
|
||||||
|
|
||||||
|
- `Co-authored-by: <name> <email>` — 标记共同作者;
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
### 基础功能提交
|
||||||
|
feat(auth): add JWT token refresh mechanism
|
||||||
|
|
||||||
|
### Bug 修复(含关联 issue)
|
||||||
|
fix(parser): handle empty string input edge case
|
||||||
|
|
||||||
|
Closes #123
|
||||||
|
|
||||||
|
### 破坏性变更(使用 ! 前缀)
|
||||||
|
feat(api)!: change user endpoint response format
|
||||||
|
|
||||||
|
BREAKING CHANGE: the `GET /api/users` endpoint now returns
|
||||||
|
a paginated response instead of a flat array.
|
||||||
|
|
||||||
|
### 回退提交
|
||||||
|
revert: remove experimental cache layer
|
||||||
|
|
||||||
|
Refs: a1b2c3d
|
||||||
@@ -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,82 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
> 私钥永不出生(生成)于服务器,且永不离开客户端
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 客户端生成密钥对
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -C "mail"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `-t`:指定密钥算法。`ed25519` 在同等安全强度下密钥更短、性能更好、是当前最佳实践;`RSA 4096` 依然安全,且兼容性更广,如果是很老的系统(OpenSSH < 6.5),可能需要`RSA`
|
||||||
|
- `-C`:添加注释,不影响加密、解密或登录认证的安全性,纯粹是一个给人看的备注字段
|
||||||
|
|
||||||
|
使用上述命令会生成一对密钥,通常位于`~/.ssh`目录下:公钥是`id_ed25519.pub`,私钥是`id_ed25519`。
|
||||||
|
|
||||||
|
### 上传公钥到目标服务器
|
||||||
|
|
||||||
|
方式1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@服务器IP
|
||||||
|
```
|
||||||
|
|
||||||
|
方式2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.ssh
|
||||||
|
echo "公钥内容" >> ~/.ssh/authorized_keys
|
||||||
|
chmod 600 ~/.ssh/authorized_keys
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
> 手动 echo 是通用方法,但更推荐 `ssh-copy-id`,它会自动处理目录创建、权限设置和追加
|
||||||
|
|
||||||
|
### 测试密钥登录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@服务器IP
|
||||||
|
```
|
||||||
|
|
||||||
|
- 如果直接登录成功,没有询问密码,说明密钥配置完全正确,继续下一步。
|
||||||
|
- 如果仍然要求输密码,需要检查上一步的公钥是否粘贴完整,或 `authorized_keys` 权限是否为 `600`。
|
||||||
|
|
||||||
|
### 修改`sshd_config`
|
||||||
|
|
||||||
|
修改服务器的`/etc/ssh/sshd_config`文件,将下方这些行取消注释或者添加:
|
||||||
|
|
||||||
|
```
|
||||||
|
PubkeyAuthentication yes # 开启密钥认证
|
||||||
|
PasswordAuthentication no # 关闭密码认证
|
||||||
|
PermitRootLogin prohibit-password # root账户禁用密码登录
|
||||||
|
```
|
||||||
|
|
||||||
|
保存退出后,使用`sshd -t`检查配置文件语法是否正确
|
||||||
|
|
||||||
|
重启SSH服务
|
||||||
|
|
||||||
|
> SSH服务名因发行版而异:
|
||||||
|
>
|
||||||
|
> `Debian`/`Ubuntu`使用`systemctl restart ssh`
|
||||||
|
>
|
||||||
|
> ``RHEL`/`CentOS`/`Fedora` 上使用`systemctl restart sshd`
|
||||||
|
|
||||||
|
### 最终验证
|
||||||
|
|
||||||
|
1. 使用SSH发起连接后没有输入密码的提示,能直接连接服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@服务器IP
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 临时删除私钥后再次登录会报错,大概如下
|
||||||
|
|
||||||
|
```
|
||||||
|
root@服务器IP: Permission denied (publickey,gssapi-keyex,gssapi-with-mic).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
### SSH
|
||||||
|
|
||||||
|
#### 快速连接服务器
|
||||||
|
|
||||||
|
在ssh的配置文件`~/.ssh/config`中可以设置常用的服务配置,这样就可以直接使用`ssh myserver`快速连接服务器了
|
||||||
|
|
||||||
|
```
|
||||||
|
Host myserver
|
||||||
|
HostName your-server.com
|
||||||
|
Port 2222
|
||||||
|
User ubuntu
|
||||||
|
IdentityFile ~/.ssh/mykey
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 本地转发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用示例
|
||||||
|
ssh -L 25432:localhost:5432 ubuntu@your-server.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**作用**:以本地端口25432请求远程服务器,在远程服务器看来,这是以远程`localhost:5432`发起的请求
|
||||||
|
|
||||||
|
**使用场景**:当远程服务器某服务限制了只接收远程服务器的127.0.0.1连接,这时候又需要远程连接这个服务,这样的话就能和访问本地服务一样访问了
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
|
||||||
|
|
||||||
|
## 程序、进程与线程
|
||||||
|
|
||||||
|
### 程序
|
||||||
|
|
||||||
|
#### 定义
|
||||||
|
|
||||||
|
程序是存储在硬盘的一组指令集和,它是对计算任务的完整描述,是一个被动实体。类似于一本菜谱,记录了完整的烹饪步骤。
|
||||||
|
|
||||||
|
#### 核心特征
|
||||||
|
|
||||||
|
| 属性 | 说明 |
|
||||||
|
| :------------- | :-------------------------------------------------------- |
|
||||||
|
| **静态性** | 程序是一个文件(如 .exe、.go),不占用 CPU 或内存执行资源 |
|
||||||
|
| **持久性** | 可长期存在于存储介质上 |
|
||||||
|
| **无生命周期** | 没有创建、运行、消亡的状态变迁 |
|
||||||
|
|
||||||
|
### 进程
|
||||||
|
|
||||||
|
#### 定义
|
||||||
|
|
||||||
|
进程是程序的一次动态执行过程,是操作系统进行资源分配的基本单位。它就像按照菜谱进行烹饪的过程,需要占用资源,有始有终。它由以下三部分构成。
|
||||||
|
|
||||||
|
| 组成部分 | 作用 |
|
||||||
|
| :-------------------- | :----------------------------------------------------- |
|
||||||
|
| **程序段** | 描述进程要完成的功能,是控制进程执行的指令集 |
|
||||||
|
| **数据段** | 程序执行时所需的数据和工作区(全局变量、堆等) |
|
||||||
|
| **进程控制块(PCB)** | 包含进程的描述信息和控制信息,是**进程存在的唯一标志** |
|
||||||
|
|
||||||
|
#### 核心特征
|
||||||
|
|
||||||
|
- **动态性**:进程有生命周期(创建 → 就绪 → 运行 → 阻塞 → 终止)
|
||||||
|
- **独立性**:每个进程拥有独立的地址空间(虚拟内存空间、代码、数据、系统资源的集合)
|
||||||
|
- **并发性**:多个进程可在宏观上同时运行
|
||||||
|
- **资源拥有者**:进程拥有独立的文件描述符、环境变量、用户ID等资源
|
||||||
|
|
||||||
|
### 线程
|
||||||
|
|
||||||
|
#### 定义
|
||||||
|
|
||||||
|
线程是CPU进行运算调度的最小单位,是进程中的一条执行路径,一个进程至少有一个线程(也就是主线程)。它就像多位厨师共用厨房的资源(水、灶台、食材等),但是每人也都有自己的独有的刀具、围裙等,他们需要协调使用厨房做菜。
|
||||||
|
|
||||||
|
#### 核心特征
|
||||||
|
|
||||||
|
线程虽然共享进程的资源,但每个线程拥有自己的**私有数据**:
|
||||||
|
|
||||||
|
| **独有内容** | **说明** |
|
||||||
|
| :------------------ | :--------------------------------- |
|
||||||
|
| 线程 ID | 唯一标识 |
|
||||||
|
| 寄存器状态 | 包括程序计数器(PC)和栈指针 |
|
||||||
|
| 栈 | 独立运行栈,用于函数调用和局部变量 |
|
||||||
|
| 信号掩码 | 线程级别的信号处理设置 |
|
||||||
|
| 优先级 | 调度优先级 |
|
||||||
|
| 线程专用存储(TLS) | 线程私有的全局变量 |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 用户态与内核态
|
||||||
|
|
||||||
|
### 内核态
|
||||||
|
|
||||||
|
运行操作系统内核代码,拥有对硬件的完全控制权,负责管理内存、进程、文件系统、网络等底层资源。
|
||||||
|
|
||||||
|
### 用户态
|
||||||
|
|
||||||
|
运行用户应用程序,无法直接访问硬件或内核数据结构,必须通过 系统调用(System Call)请求内核代为执行特权操作。
|
||||||
|
|
||||||
|
### 切换过程
|
||||||
|
|
||||||
|
为了保护系统安全与稳定,操作系统会对CPU的执行权限进行分级,不同的CPU架构有不同的等级,但是通常都遵循应用在最低特权级(用户态)运行,内核在次高特权级(内核态)运行,通过CPU硬件强制隔离特权操作。当在X86架构的CPU运行时,一个应用程序需要执行特权操作(比如读写文件),过程大致是这样的:
|
||||||
|
|
||||||
|
1. **触发**
|
||||||
|
程序调用 `read()` 等库函数,最终执行 CPU 的 `syscall` 指令。这条指令告诉 CPU:“我要切换到内核态干正事”。
|
||||||
|
2. **CPU 硬件自动切换**
|
||||||
|
CPU 在硬件层面做三件关键事:
|
||||||
|
- 把特权级从 **Ring 3(用户态)** 切换为 **Ring 0(内核态)**。
|
||||||
|
- 将栈指针从用户栈切换到**内核栈**(从 MSR 中读取预设的内核栈指针)。
|
||||||
|
- 把返回地址(下一条指令)和标志寄存器保存到特定寄存器(如 `rcx`、`r11`),然后跳转到内核预先设置好的**入口函数**。
|
||||||
|
3. **内核软处理**
|
||||||
|
入口函数将刚才没来得及保存的全部寄存器压入内核栈(形成 `pt_regs` 结构),然后根据**系统调用号**(放在 `rax` 通用寄存器中)从系统调用表里找到对应的内核函数(如 `ksys_read`)并执行。
|
||||||
|
4. **内核执行 & 返回**
|
||||||
|
内核函数完成实际工作(检查权限、读取数据等),把结果放在 `rax` 通用寄存器中。返回前,内核检查是否有其他任务需要处理(如信号、调度),然后通过 `sysret` 指令恢复之前保存的寄存器、用户栈和返回地址,特权级自动切回 **Ring 3**,程序从当初的下一条指令继续运行。
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## Nginx 反代未透传客户端 IP 导致服务端日志全量变为 127.0.0.1
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
|
||||||
|
后端解析IP后全量变成 `127.0.0.1`。
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
|
||||||
|
后端解析IP的顺序是
|
||||||
|
```
|
||||||
|
X-Forwarded-For → X-Real-IP → RemoteAddr
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nginx**转发至后端服务的 location 块缺少`proxy_set_header`指令:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /olive3 {
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:55430;
|
||||||
|
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
在缺失的 location 块中补充 header 透传:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api {
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_pass http://127.0.0.1:55430;
|
||||||
|
# ... 其他配置不变
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `$proxy_add_x_forwarded_for`:将客户端真实 IP 追加到已有 `X-Forwarded-For` 尾部,而非覆盖
|
||||||
|
- `$remote_addr`:Nginx 直接看到的对端地址,作为 `X-Real-IP` 传递
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
## 快速复制文件路径
|
||||||
|
|
||||||
|
### Shift + 右键菜单(通用)
|
||||||
|
|
||||||
|
1. 在文件资源管理器中,**按住 `Shift` 键不放**
|
||||||
|
2. 右键点击目标文件
|
||||||
|
3. 选择 **「复制为路径(A)」**
|
||||||
|
|
||||||
|
### 功能区按钮
|
||||||
|
|
||||||
|
1. 选中文件
|
||||||
|
2. 点击资源管理器顶部的 **「主页」** 选项卡
|
||||||
|
3. 点击 **「复制路径」** 按钮(剪贴板图标)
|
||||||
|
|
||||||
|
### 拖拽到终端
|
||||||
|
|
||||||
|
- 直接把文件**拖入** PowerShell 或 CMD 窗口,会自动粘贴完整路径。
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
配置文件位置
|
||||||
|
|
||||||
|
```
|
||||||
|
C:\Users\admin\AppData\Roaming\Code\User\keybindings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
该文件保存的是用户级快捷键列表,每一个快捷键配置都是包含`key`、`command`、`when`(可选)字段
|
||||||
|
|
||||||
|
| 字段 | 作用 |
|
||||||
|
| :------------ | :------------------------------- |
|
||||||
|
| **`key`** | 你按下的键盘组合 |
|
||||||
|
| **`command`** | 要执行的命令 ID |
|
||||||
|
| **`when`** | 规定这条快捷键在什么“语境”下生效 |
|
||||||
|
|
||||||
|
完整示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// markdown 代码块
|
||||||
|
{
|
||||||
|
"key": "ctrl+shift+`",
|
||||||
|
"command": "markdown.extension.editing.toggleCodeBlock",
|
||||||
|
"when": "editorTextFocus && editorLangId == 'markdown'"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 按键的书写规则
|
||||||
|
|
||||||
|
### 基本格式
|
||||||
|
|
||||||
|
- **全小写**:修饰键和字母键都要小写,写 `ctrl` 而不是 `Ctrl`,写 `a` 而不是 `A`。
|
||||||
|
- **用 `+` 连接**:按键之间用加号连接,不带空格。例如 `ctrl+shift+a`。
|
||||||
|
- **修饰键顺序任意**:`ctrl+alt+a` 和 `alt+ctrl+a` 效果相同,但习惯按 `ctrl -> shift -> alt -> 字母` 的顺序写
|
||||||
|
|
||||||
|
### 支持的修饰键
|
||||||
|
|
||||||
|
| 修饰键 | 写法 | 说明 |
|
||||||
|
| :------------ | :------ | :---------------------------------------------- |
|
||||||
|
| Ctrl | `ctrl` | Windows / Linux 上常用 `ctrl`,macOS 对应 `cmd` |
|
||||||
|
| Shift | `shift` | |
|
||||||
|
| Alt | `alt` | macOS 上是 `opt`(或者也支持 `alt`) |
|
||||||
|
| Cmd (Mac) | `cmd` | 在 Windows 上无效,建议用 `ctrl` 兼顾跨平台 |
|
||||||
|
| Win (Windows) | `win` | 很少用 |
|
||||||
|
| Meta (macOS) | `meta` | 通常等同 `cmd` |
|
||||||
|
|
||||||
|
### 特殊按键名称
|
||||||
|
|
||||||
|
| 按键 | 写法 |
|
||||||
|
| :----------- | :------------------------------------- |
|
||||||
|
| 方向键 | `up` `down` `left` `right` |
|
||||||
|
| 功能键 | `f1` ~ `f19` |
|
||||||
|
| 回车 | `enter` |
|
||||||
|
| 空格 | `space` |
|
||||||
|
| 退格 | `backspace` |
|
||||||
|
| Tab | `tab` |
|
||||||
|
| 反引号 | ``` (就是 backtick) |
|
||||||
|
| 逗号、句号 | `,` `.` 等直接写符号本身 |
|
||||||
|
| 数字键 | `0`~`9` |
|
||||||
|
| 计算器小键盘 | `numpad0` ~ `numpad9`,`numpad_add` 等 |
|
||||||
|
|
||||||
|
### 按键组合
|
||||||
|
|
||||||
|
VS Code 支持**二段按键**(一次按键序列),写法是:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"key": "ctrl+k ctrl+b"
|
||||||
|
```
|
||||||
|
|
||||||
|
意思是先按 `Ctrl+K`,松开后再按 `Ctrl+B`。
|
||||||
|
|
||||||
|
## 命令ID
|
||||||
|
|
||||||
|
`command` 是一个**全局唯一的字符串**,由扩展或 VS Code 自己注册。不能自己编造,必须使用确切的 ID。
|
||||||
|
|
||||||
|
通过`ctrl+k ctrl+b`按键组合或者在设置中点击“键盘快捷方式”即可查看当前能设置的所有的命令。虽然显示的是命令,但是实际上是命令的名字,右键可以获取命令的ID。也可以不通过修改配置文件,在图形化界面设置快捷键。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 上下文条件表达式
|
||||||
|
|
||||||
|
`when` 是一个**布尔表达式**,只有计算结果为 `true` 时,快捷键才会触发,可以组合变量使用逻辑运算符。
|
||||||
|
|
||||||
|
### 常用上下文变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 常见值 |
|
||||||
|
| :------------------------------ | :------------------------------------------------- | :------------------------------------------ |
|
||||||
|
| `editorTextFocus` | 焦点是否在编辑器的**文本区域**(光标可以输入文字) | `true` / `false` |
|
||||||
|
| `editorLangId` | 当前编辑器的语言 ID(不是文件后缀) | `"markdown"`, `"javascript"`, `"python"` 等 |
|
||||||
|
| `editorHasSelection` | 编辑器里是否有文本被选中 | `true` / `false` |
|
||||||
|
| `isLinux`, `isWindows`, `isMac` | 当前操作系统 | `true` / `false` |
|
||||||
|
| `resourceExtname` | 当前文件的后缀名(带`.`) | `".md"`, `".js"` 等 |
|
||||||
|
| `activeEditor` | 是否有激活的编辑器 | `true` / `false` |
|
||||||
|
| `terminalFocus` | 焦点是否在集成终端 | `true` / `false` |
|
||||||
|
| `listFocus` | 焦点在列表/树视图吗 | `true` / `false` |
|
||||||
|
|
||||||
|
### 逻辑运算符
|
||||||
|
|
||||||
|
| 运算符 | 含义 |
|
||||||
|
| :--------- | :----------------------------------------------------------- |
|
||||||
|
| `&&` | 与 |
|
||||||
|
| ` | |
|
||||||
|
| `!` | 非 |
|
||||||
|
| `==`, `!=` | 等于 / 不等于(字符串) |
|
||||||
|
| `=~` | 正则匹配(例如:`editorLangId =~ /^markdown$ |
|
||||||
|
| `in` | 测试左侧值是否在给定的右值数组里,如 `editorLangId in ['markdown', 'rmd']` |
|
||||||
|
|
||||||
|
### 例子分析
|
||||||
|
|
||||||
|
```json
|
||||||
|
"when": "editorTextFocus && editorLangId == 'markdown'"
|
||||||
|
```
|
||||||
|
|
||||||
|
表达式含义:确保你正在编辑文本,而不是在侧边栏、终端或其他地方**并且**当前文件被 VS Code 识别为 Markdown 语言
|
||||||
|
|
||||||
|
## 常见问题排查
|
||||||
|
|
||||||
|
| 现象 | 可能原因 |
|
||||||
|
| :------------------------------- | :------------------------------------------------------ |
|
||||||
|
| 按键完全没反应 | `command` 拼写错误;`when` 条件一直为 false;扩展未激活 |
|
||||||
|
| 在其他语言文件里也生效了 | 缺少 `editorLangId == 'xxx'` 限制,考虑加上 |
|
||||||
|
| 一次性按了没反应,但按键序列可以 | 可能你定义成了 chord,但习惯是单键 |
|
||||||
|
| 提示 “command 'xxx' not found” | 命令 ID 不存在,检查扩展是否已安装并启用 |
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 正则表达式
|
||||||
|
|
||||||
|
正则表达式是一种用于匹配和操作文本的强大工具,用于描述要匹配的文本模式。
|
||||||
|
|
||||||
|
## 匹配字符
|
||||||
|
|
||||||
|
### 普通匹配字符
|
||||||
|
|
||||||
|
| 字符 | 描述 |
|
||||||
|
| :------- | :----------------------------------------------------------- |
|
||||||
|
| `[xyz]` | 匹配 `[xyz]` 中列出的所有字符。例如 `[xyz]`,能够匹配目标的所有 x、y、z 字符。 |
|
||||||
|
| `[^xyz]` | 匹配 `[^xyz]` 中列出的字符外的所有字符。例如 `[^xyz]`,能够匹配目标的除去 x、y、z 外的所有字符。 |
|
||||||
|
| `[A-Z]` | `[A-Z]` 表示一个区间,匹配所有大写字母;`[a-z]` 匹配所有小写字母;`[0-9]` 匹配所有数字。 |
|
||||||
|
| `[\s\S]` | 匹配所有字符(包括换行符)。`\s` 匹配所有空白符(包括换行),`\S` 匹配所有非空白符(不包括换行),两者合并即可匹配任意字符。 |
|
||||||
|
| `.` | 匹配除换行符 `\n`、`\r` 之外的任何单个字符,等价于 `[^\n\r]`。 |
|
||||||
|
| `\w` | 匹配字母、数字、下划线,等价于 `[A-Za-z0-9_]`。 |
|
||||||
|
| `\d` | 匹配任意一个阿拉伯数字(0 到 9),等价于 `[0-9]`。 |
|
||||||
|
|
||||||
|
### 非打印字符
|
||||||
|
|
||||||
|
| 字符 | 描述 |
|
||||||
|
| :---- | :----------------------------------------------------------- |
|
||||||
|
| `\cx` | 匹配由 x 指明的控制字符。例如,`\cM` 匹配一个 Control-M 或回车符。x 的值必须为 A-Z 或 a-z 之一,否则将 c 视为一个原义的 'c' 字符。 |
|
||||||
|
| `\f` | 匹配一个换页符,等价于 `\x0c` 和 `\cL`。 |
|
||||||
|
| `\n` | 匹配一个换行符,等价于 `\x0a` 和 `\cJ`。 |
|
||||||
|
| `\r` | 匹配一个回车符,等价于 `\x0d` 和 `\cM`。 |
|
||||||
|
| `\s` | 匹配任何空白字符,包括空格、制表符、换页符等,等价于 `[ \f\n\r\t\v]`。注意 Unicode 正则表达式会匹配全角空格符。 |
|
||||||
|
| `\S` | 匹配任何非空白字符,等价于 `[^ \f\n\r\t\v]`。 |
|
||||||
|
| `\t` | 匹配一个制表符,等价于 `\x09` 和 `\cI`。 |
|
||||||
|
| `\v` | 匹配一个垂直制表符,等价于 `\x0b` 和 `\cK`。 |
|
||||||
|
|
||||||
|
### 特殊字符
|
||||||
|
|
||||||
|
| 特殊字符 | 描述 |
|
||||||
|
| :------- | :----------------------------------------------------------- |
|
||||||
|
| `$` | 匹配输入字符串的结尾位置。如果设置了 RegExp 对象的 Multiline 属性,则 `$` 也匹配 `\n` 或 `\r`。要匹配 `$` 字符本身,请使用 `\$`。 |
|
||||||
|
| `( )` | 标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用。要匹配这些字符,请使用 `\(` 和 `\)`。 |
|
||||||
|
| `*` | 匹配前面的子表达式零次或多次。要匹配 `*` 字符本身,请使用 `\*`。 |
|
||||||
|
| `+` | 匹配前面的子表达式一次或多次。要匹配 `+` 字符本身,请使用 `\+`。 |
|
||||||
|
| `.` | 匹配除换行符 `\n` 之外的任何单字符。要匹配 `.` 本身,请使用 `\.`。 |
|
||||||
|
| `[` | 标记一个中括号表达式的开始。要匹配 `[`,请使用 `\[`。 |
|
||||||
|
| `?` | 匹配前面的子表达式零次或一次,等价于 `{0,1}`。要匹配 `?` 字符本身,请使用 `\?`。例如,`do(es)?` 可以匹配 "do" 和 "does",但不能匹配 "dog"。 |
|
||||||
|
| `\` | 将下一个字符标记为特殊字符、原义字符、向后引用或八进制转义符。例如,`\\` 匹配 `\`。 |
|
||||||
|
| `^` | 匹配输入字符串的开始位置。在方括号表达式中使用时,表示不接受方括号内的字符集合(取反)。要匹配 `^` 字符本身,请使用 `\^`。 |
|
||||||
|
| `{` | 标记限定符表达式的开始。要匹配 `{`,请使用 `\{`。 |
|
||||||
|
| `|` | 指明两项之间的一个选择。要匹配 `|`,请使用 `\|`。 |
|
||||||
|
|
||||||
|
### 限定符
|
||||||
|
|
||||||
|
| 字符 | 描述 |
|
||||||
|
| :------ | :----------------------------------------------------------- |
|
||||||
|
| `{n}` | n 是一个非负整数,匹配确定的 n 次。例如,`o{2}` 不能匹配 "Bob" 中的 o,但是能匹配 "food" 中的两个 o。 |
|
||||||
|
| `{n,}` | n 是一个非负整数,至少匹配 n 次。例如,`o{2,}` 不能匹配 "Bob" 中的 o,但能匹配 "foooood" 中的所有 o。`o{1,}` 等价于 `o+`,`o{0,}` 等价于 `o*`。 |
|
||||||
|
| `{n,m}` | m 和 n 均为非负整数,其中 n <= m。最少匹配 n 次且最多匹配 m 次。例如,`o{1,3}` 将匹配 "fooooood" 中的前三个 o。`o{0,1}` 等价于 `o?`。 |
|
||||||
|
|
||||||
|
## 常用正则表达式
|
||||||
|
|
||||||
|
| 正则表达式 | 匹配内容 |
|
||||||
|
| ------------------------------------------------ | ------------ |
|
||||||
|
| `[\u4e00-\u9fa5]` | 中文字符 |
|
||||||
|
| `^1[3-9]\d{9}$` | 国内手机号 |
|
||||||
|
| `[1-9]\d{5}(?!\d)` | 国内邮政编码 |
|
||||||
|
| `^(\d{6})(\d{4})(\d{2})(\d{2})(\d{3})([0-9]|X)$` | 18位身份证号 |
|
||||||
|
|
||||||
+1661
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
|||||||
|
### **小六壬时课 · 知识库**
|
||||||
|
|
||||||
|
#### **一、 简介:什么是小六壬?**
|
||||||
|
|
||||||
|
小六壬,相传为三国时期诸葛亮、唐代李淳风所创,是一种简便、快速的预测方法,因其起课方便、便于携带(仅需掐指一算),故有“袖珍式预测学”或“马前课”之称。
|
||||||
|
|
||||||
|
其核心思想是,根据事发或问事时的“时间”或“心念”所触发的即时信息,来推演事物的状态和发展趋势。它主要用于对**单一、具体、短期**的事情进行快速占断,属于“一事一占”的动念占法。
|
||||||
|
|
||||||
|
**核心特点**:
|
||||||
|
|
||||||
|
- **简便快捷**:无需复杂工具,仅凭掌诀即可推算。
|
||||||
|
- **随机性强**:动念即可成课,捕捉瞬间天机。
|
||||||
|
- **应用广泛**:适用于寻物、问事、决疑、出行等日常生活的方方面面。
|
||||||
|
|
||||||
|
#### **二、 核心概念:六神宫位与掌诀**
|
||||||
|
|
||||||
|
小六壬共有六个基本“角色”,称为“六神”(或六宫),它们分布在食指、中指和无名指的指节上,形成一个固定的“掌上地盘”,即“掌诀”。
|
||||||
|
|
||||||
|
**掌诀位置图示**: 您可以伸出左手,按照下图在手指上定位六神:
|
||||||
|
|
||||||
|
- **大安**:食指根部
|
||||||
|
- **留连**:食指尖部
|
||||||
|
- **速喜**:中指尖部
|
||||||
|
- **赤口**:无名指尖部
|
||||||
|
- **小吉**:无名指根部
|
||||||
|
- **空亡**:中指根部
|
||||||
|
|
||||||
|
(其顺序为顺时针循环)
|
||||||
|
|
||||||
|
**六神基础信息表**:
|
||||||
|
|
||||||
|
| 宫位/六神 | 五行 | 吉凶 | 核心意象与关键词 |
|
||||||
|
| :------------------ | :--- | :--- | :--------------------------------- |
|
||||||
|
| **大安 (Dà'ān)** | 木 | 吉 | 安泰、平稳、静止、官方、身体、龙 |
|
||||||
|
| **留连 (Liúlián)** | 水 | 凶 | 延迟、纠缠、牵绊、缓慢、暧昧、暗昧 |
|
||||||
|
| **速喜 (Sùxǐ)** | 火 | 吉 | 快速、喜讯、文书、宴饮、光明、朱雀 |
|
||||||
|
| **赤口 (Chìkǒu)** | 金 | 凶 | 口舌、官非、争斗、惊恐、凶险、白虎 |
|
||||||
|
| **小吉 (Xiǎojí)** | 木 | 吉 | 合作、财利、和合、顺畅、六合 |
|
||||||
|
| **空亡 (Kōngwáng)** | 土 | 凶 | 落空、无果、忧虑、阴私、迷茫、玄武 |
|
||||||
|
|
||||||
|
#### **三、 主要起课方法**
|
||||||
|
|
||||||
|
起课,就是根据参数确定最终落在哪一个宫位上。
|
||||||
|
|
||||||
|
**1. 传统时间起课法(月、日、时)** 这是最正统的方法,以农历时间为准。
|
||||||
|
|
||||||
|
- 第一步:月上起日
|
||||||
|
- 从**大安**宫开始,顺时针将月份数代入。例如,一月为大安,二月为留连,三月为速喜……以此类推。
|
||||||
|
- 第二步:日上起时
|
||||||
|
- 从上一步月份的落宫开始,顺时针将日期数(初一、初二……)代入。
|
||||||
|
- 第三步:时上定宫
|
||||||
|
- 从上一步日期的落宫开始,顺时针将时辰数(子时为1,丑时为2……)代入,最终落下的宫位即为结果。
|
||||||
|
|
||||||
|
**【示例】**:占测农历八月初十下午4点(申时)。
|
||||||
|
|
||||||
|
1. **定月**:从大安宫数8个月,落点在**赤口**。(1大安→2留连→3速喜→4赤口→5小吉→6空亡→7大安→8赤口)
|
||||||
|
|
||||||
|
2. **定日**:从赤口宫数10日,落点在**小吉**。(1小吉→2空亡→...→6赤口→...→10小吉)
|
||||||
|
|
||||||
|
3. 定时
|
||||||
|
|
||||||
|
:申时是第9个地支。从上一步的小吉宫数9个时辰,最终落点在
|
||||||
|
|
||||||
|
速喜
|
||||||
|
|
||||||
|
。
|
||||||
|
|
||||||
|
- **结果**:速喜。
|
||||||
|
|
||||||
|
**2. 报数起课法** 当不知晓具体时辰或为求心念感应时使用。
|
||||||
|
|
||||||
|
- 让问事者随意报出**三个数字**(1位数到3位数均可)。
|
||||||
|
- 将每个数字分别**除以6,取其余数**(若余数为0,则按6计算)。
|
||||||
|
- **第一步**:从“大安”宫起,按第一个余数顺时针数,定下基点。
|
||||||
|
- **第二步**:从基点宫位起,按第二个余数顺时针数,定下过程点。
|
||||||
|
- **第三步**:从过程点起,按第三个余数顺时针数,定下最终结果。
|
||||||
|
|
||||||
|
**【示例】**:报数为 7, 11, 32。
|
||||||
|
|
||||||
|
1. **第一数**:`7 ÷ 6 = 1 ... 余 1`。从大安数1,落在**大安**。
|
||||||
|
|
||||||
|
2. **第二数**:`11 ÷ 6 = 1 ... 余 5`。从大安数5,落在**小吉**。
|
||||||
|
|
||||||
|
3. 第三数
|
||||||
|
|
||||||
|
:
|
||||||
|
|
||||||
|
```
|
||||||
|
32 ÷ 6 = 5 ... 余 2
|
||||||
|
```
|
||||||
|
|
||||||
|
。从小吉数2,落在
|
||||||
|
|
||||||
|
空亡
|
||||||
|
|
||||||
|
。
|
||||||
|
|
||||||
|
- **结果**:空亡。
|
||||||
|
|
||||||
|
#### **四、 六神象义详解与分类占断**
|
||||||
|
|
||||||
|
这是解卦的核心,需要结合所问之事灵活判断。
|
||||||
|
|
||||||
|
**1. 大安**
|
||||||
|
|
||||||
|
- **核心**:身不动,心安宁。静待为吉,妄动有失。
|
||||||
|
- **问事**:事可成,但需时日,不可急躁。过程平顺。
|
||||||
|
- **寻物/寻人**:人或物未走远,在家中或原地,或在东方、东北方,多在静态、稳定的环境中(如办公室、卧室)。
|
||||||
|
- **感情**:关系稳定,感情安泰,但可能缺乏激情。
|
||||||
|
- **财运**:财运稳定,宜守成,不宜投机。
|
||||||
|
- **健康**:身体安康,即使有病也易于恢复。
|
||||||
|
|
||||||
|
**2. 留连**
|
||||||
|
|
||||||
|
- **核心**:事未了,心纠结。拖延、纠缠、暧昧不清。
|
||||||
|
- **问事**:事情进展缓慢,有阻碍,难以立刻解决,容易节外生枝。
|
||||||
|
- **寻物/寻人**:难寻,或被某事/某人牵绊住。物品可能在潮湿、脏乱或纠缠之地。
|
||||||
|
- **感情**:关系纠缠不清,或处于冷战、延迟状态。有藕断丝连之象。
|
||||||
|
- **财运**:财运不畅,资金周转困难,或有债务纠纷。
|
||||||
|
- **健康**:病情迁延,难以速愈,多为慢性病或旧病复发。
|
||||||
|
|
||||||
|
**3. 速喜**
|
||||||
|
|
||||||
|
- **核心**:火光之象,喜信速至。宜速战速决,不宜拖延。
|
||||||
|
- **问事**:事能速成,有喜讯或好结果。尤其利于文书、考试、信息类事宜。
|
||||||
|
- **寻物/寻人**:能快速找到。人或物在南方,或在热闹、明亮之处。
|
||||||
|
- **感情**:进展神速,热情洋溢,或有喜事临门(如订婚、结婚)。
|
||||||
|
- **财运**:偏财运旺,有意外之财,或短期投资获利。
|
||||||
|
- **健康**:多为急症、热症(如发烧、炎症),但来得快去得也快。
|
||||||
|
|
||||||
|
**4. 赤口**
|
||||||
|
|
||||||
|
- **核心**:白虎开口,是非官讼。主凶险、争斗、惊扰。
|
||||||
|
- **问事**:事多纷争,易有口舌是非,甚至官司诉讼。过程不顺,需防小人。
|
||||||
|
- **寻物/寻人**:难寻。多有争吵或处于危险境地。物品可能已损坏。
|
||||||
|
- **感情**:关系紧张,争吵频繁,易有分离之象。
|
||||||
|
- **财运**:破财之象,谨防因纠纷、官非而损财。
|
||||||
|
- **健康**:需防意外伤害、手术、或急性恶疾。有血光之灾的警示。
|
||||||
|
|
||||||
|
**5. 小吉**
|
||||||
|
|
||||||
|
- **核心**:六合之象,诸事和合。代表合作、财利、转机。
|
||||||
|
- **问事**:事情顺利,有贵人相助,适宜合作、商议。
|
||||||
|
- **寻物/寻人**:易寻。人或物在附近,或与友人、同伴在一起。
|
||||||
|
- **感情**:关系和谐,琴瑟和鸣,是发展的良好时机。
|
||||||
|
- **财运**:正财、合伙之财运佳,交易顺利。
|
||||||
|
- **健康**:病情好转,遇到良医,康复有望。
|
||||||
|
|
||||||
|
**6. 空亡**
|
||||||
|
|
||||||
|
- **核心**:万事成空,虚无之象。信息不实,劳而无功。
|
||||||
|
- **问事**:事情没有头绪,心中没底,最终可能不了了之或毫无结果。有虚假、欺骗的成分。
|
||||||
|
- **寻物/寻人**:人已远去,物已丢失,找回希望渺茫。
|
||||||
|
- **感情**:关系空虚,前景不明,或对方心意不诚。
|
||||||
|
- **财运**:财运空空,投资无望,谨防被骗。
|
||||||
|
- **健康**:病情诊断不明,或查无此症,也可能指精神、心理层面的问题。
|
||||||
|
|
||||||
|
#### **五、 断辞内容**
|
||||||
|
|
||||||
|
1、大安:身不动时,属木,为青龙,凡谋事主一、五、七。
|
||||||
|
|
||||||
|
断辞曰:
|
||||||
|
|
||||||
|
大安事事昌,求谋在东方,失物去不远,
|
||||||
|
|
||||||
|
宅舍保安康,行人身未动,病者主无妨,
|
||||||
|
|
||||||
|
将军回田野,仔细与推详。
|
||||||
|
|
||||||
|
2、留连:卒未归时,属水,为玄武,凡谋事主二、八、十。
|
||||||
|
|
||||||
|
断辞曰:
|
||||||
|
|
||||||
|
留连事难成,求谋曰未明,官事只宜缓,
|
||||||
|
|
||||||
|
去者未回程,失物南方见,急讨方称心,
|
||||||
|
|
||||||
|
更须防口舌,人口且平平。
|
||||||
|
|
||||||
|
3、速喜:人便至时,属火,为朱雀,凡谋事主三、六、九。
|
||||||
|
|
||||||
|
断辞曰:
|
||||||
|
|
||||||
|
速喜喜来临,求财向南行,失物申午未,
|
||||||
|
|
||||||
|
逢人路上寻,官事有福德,病者无祸侵,
|
||||||
|
|
||||||
|
田家六畜吉,行人有信音。
|
||||||
|
|
||||||
|
4、赤口:官事凶时,属金,为白虎,凡谋事主四、七、十。
|
||||||
|
|
||||||
|
断辞曰:
|
||||||
|
|
||||||
|
赤口主口舌,官非切要防,失物急去寻,
|
||||||
|
|
||||||
|
行人有惊慌,鸡犬多作怪,病者出西方,
|
||||||
|
|
||||||
|
更须防咒诅,恐怕染蕴疾。
|
||||||
|
|
||||||
|
5、小吉:人来喜时,属木,为六合,凡谋事主一、五、七。
|
||||||
|
|
||||||
|
断辞曰:
|
||||||
|
|
||||||
|
小吉最吉昌,路上好商良,阳人来报喜,
|
||||||
|
|
||||||
|
失物在坤方,行人立便至,交关真是强,
|
||||||
|
|
||||||
|
凡事皆和合,病者事无仿。
|
||||||
|
|
||||||
|
6、空亡:音信稀时,属土,为勾陈,凡谋事主一、五、七。
|
||||||
|
|
||||||
|
断辞曰:
|
||||||
|
|
||||||
|
空亡事不长,阴人小乘张,求财无有利,
|
||||||
|
|
||||||
|
行人有灾殃,失物寻不见,官事主刑伤,
|
||||||
|
|
||||||
|
病人逢暗鬼,乞解保安康。
|
||||||
|
|
||||||
|
#### **六、 使用原则与心法**
|
||||||
|
|
||||||
|
- **心诚意专**:占测时心态必须虔诚、专注,意念集中于所问之事。
|
||||||
|
- **一事一占**:同一件事在短期内不应反复占问,以第一次的结果为准。所谓“再三渎,渎则不告”。
|
||||||
|
- **动念即占**:当某个问题或念头强烈地出现在脑海中时,是最佳的占测时机。
|
||||||
|
- **趋吉避凶**:小六壬提供的是一种预警和参考。得到吉卦,应顺势而为;得到凶卦,则应反思、谨慎、规避,而非坐以待毙。它是一种积极的“行为指导”,而非消极的“宿命论”。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
## CONCAT函数
|
||||||
|
|
||||||
|
`CONCAT` 是 SQL 中用于**字符串拼接**的函数,它可以将两个或多个字符串合并成一个新的字符串。
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
### 基本语法
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CONCAT(string1, string2, ..., stringN)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 不同数据库的 CONCAT 行为
|
||||||
|
|
||||||
|
| 数据库 | 行为 |
|
||||||
|
| :--------------------- | :----------------------------------------------- |
|
||||||
|
| **MySQL** | 支持多参数;**任一参数为 NULL,整个结果为 NULL** |
|
||||||
|
| **SQL Server** (2012+) | 支持多参数;**NULL 会被自动转为空字符串** `''` |
|
||||||
|
| **Oracle** | 只支持 **2 个参数**;NULL 视为空字符串 |
|
||||||
|
| **PostgreSQL** | 支持多参数;NULL 视为空字符串 |
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
|
||||||
|
## B-Tree
|
||||||
|
|
||||||
|
**适用场景**
|
||||||
|
|
||||||
|
| 场景 | 示例 |
|
||||||
|
| :------- | :-------------------------- |
|
||||||
|
| 等值查询 | WHERE user_id = 100 |
|
||||||
|
| 范围查询 | WHERE age BETWEEN 20 AND 30 |
|
||||||
|
| 排序优化 | ORDER BY created_at DESC |
|
||||||
|
| 前缀匹配 | WHERE name LIKE '张%' |
|
||||||
|
|
||||||
|
**不适用场景**
|
||||||
|
|
||||||
|
- LIKE '%关键词%'(后缀/中间模糊匹配)
|
||||||
|
|
||||||
|
- 全文搜索
|
||||||
|
|
||||||
|
- 数组包含查询
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## GIN
|
||||||
|
|
||||||
|
**结构特点**
|
||||||
|
|
||||||
|
- 倒排索引(Inverted Index) 机制:从"内容"映射到"行"
|
||||||
|
|
||||||
|
- 索引的是复合值内部的独立元素
|
||||||
|
|
||||||
|
- 索引构建较慢,但查询性能极佳
|
||||||
|
|
||||||
|
**适用场景**
|
||||||
|
|
||||||
|
> `@>`表示包含、`<@`表示被包含、`&&`表示重叠、`@@`表示全文搜索匹配、`?`表示JSONB 键存在
|
||||||
|
|
||||||
|
| 场景 | 示例 |
|
||||||
|
| :----------- | :----------------------------------------------------- |
|
||||||
|
| 全文搜索 | WHERE to_tsvector(content) @@ to_tsquery('postgresql') |
|
||||||
|
| 数组包含 | WHERE tags @> ARRAY['java', 'sql'] |
|
||||||
|
| JSONB 查询 | WHERE data @> '{"status": "active"}' |
|
||||||
|
| 多值元素搜索 | WHERE arr && ARRAY[1, 2] |
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# MySQL 字符集的终极巨坑 —— `utf8` vs `utf8mb4`
|
||||||
|
|
||||||
|
### 一句话总结
|
||||||
|
|
||||||
|
MySQL 中的 `utf8` 是个“假货”(最多只支持 3 字节),`utf8mb4` 才是真正的 UTF-8(支持 4 字节)。
|
||||||
|
|
||||||
|
### 为什么会有这个坑
|
||||||
|
|
||||||
|
- **历史遗留**:早期 MySQL 开发时,Unicode 字符确实只用 3 个字节就能存下。
|
||||||
|
- **现象**:一旦用户输入 **Emoji 表情**(如 😀)或部分**生僻字**(如 𰻝),数据库会直接抛出错误:`Incorrect string value`。
|
||||||
|
|
||||||
|
### 📊 核心对比
|
||||||
|
|
||||||
|
| 字符集 | 真实身份 | 最大字节数 | 能否存 Emoji | 状态 |
|
||||||
|
| :------------ | :----------------- | :--------- | :----------- | :--------- |
|
||||||
|
| **`utf8`** | `utf8mb3` (阉割版) | 3 字节 | ❌ 报错 | ⚠️ 已废弃 |
|
||||||
|
| **`utf8mb4`** | 真正的 UTF-8 | 4 字节 | ✅ 随便存 | ✅ 推荐使用 |
|
||||||
|
|
||||||
|
### 🛠️ 各版本现状与未来
|
||||||
|
|
||||||
|
- **MySQL 5.7 及以前**:默认字符集是 `latin1`,`utf8` 是重灾区。
|
||||||
|
- **MySQL 8.0 及以后**:**默认字符集已改为 `utf8mb4`**(新项目安全系数提升)。
|
||||||
|
- **官方态度**:目前 `utf8`(即 `utf8mb3`)已被标记为 Deprecated(废弃)。官方计划在**未来的某个大版本中彻底删除 `utf8mb3`**,并让 `utf8` 这个名字正式指向 `utf8mb4`。
|
||||||
@@ -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;
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## 权重抽奖
|
||||||
|
|
||||||
|
### 轮盘赌
|
||||||
|
|
||||||
|
适合奖品数量较少,一次性抽奖的场景
|
||||||
|
|
||||||
|
核心顺序遍历,逐个减权重:是用一个随机数 `r`,依次减去每个奖品的权重,当 `r` 变成负数的那一刻,说明它“落入了”当前奖品的权重区间。时间复杂度 **O(n)**,空间复杂度**O(1)**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Draw(rewards []*Reward) *Reward {
|
||||||
|
totalWeight := 0
|
||||||
|
for _, r := range rewards {
|
||||||
|
totalWeight += r.Weight
|
||||||
|
}
|
||||||
|
r := rand.Intn(totalWeight) // [0, totalWeight)
|
||||||
|
for _, reward := range rewards {
|
||||||
|
r -= reward.Weight
|
||||||
|
if r < 0 {
|
||||||
|
return reward
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rewards[len(rewards)-1] // 兜底,理论上不会走到
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 前缀和 + 二分查找
|
||||||
|
|
||||||
|
适合奖品数量较多,需要频繁抽奖的通用场景
|
||||||
|
|
||||||
|
核心是构建前缀和数组,二分查找:把权重想象成一条线段,每个奖品占据一段,生成 `[0, 100)` 的随机数,落在哪个区间就中哪个奖品。二分查找快速定位区间,时间复杂度 **O(log n)**,空间复杂度**O(n)**
|
||||||
|
|
||||||
|
```
|
||||||
|
权重: 50 25 15 7 2 1
|
||||||
|
├─────────┼────────┼──────┼────┼──┼──┤
|
||||||
|
0 50 75 90 97 99 100
|
||||||
|
← 谢谢参与 →←5元券→←10元券→←月卡→←年卡→←大奖→
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Reward struct {
|
||||||
|
Name string
|
||||||
|
Weight int
|
||||||
|
}
|
||||||
|
|
||||||
|
func Draw(rewards []*Reward) *Reward {
|
||||||
|
prefixSumArray := make([]int, len(rewards)) // 前缀和数组
|
||||||
|
totalWeight := 0 // 总权重
|
||||||
|
for i, p := range rewards {
|
||||||
|
totalWeight += p.Weight
|
||||||
|
prefixSumArray[i] = totalWeight
|
||||||
|
fmt.Println(totalWeight)
|
||||||
|
}
|
||||||
|
r := rand.Intn(totalWeight) // r ∈ [0, totalWeight-1]
|
||||||
|
idx := sort.SearchInts(prefixSumArray, r+1) // 查找目标 ∈ [1, totalWeight]
|
||||||
|
return rewards[idx]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## 轮询算法
|
||||||
|
|
||||||
|
### 平滑加权轮询
|
||||||
|
|
||||||
|
#### 原理
|
||||||
|
|
||||||
|
每台服务器维护一个 `当前权重`(currentWeight),每次选择 `currentWeight` 最大的服务器,选中后将其 `currentWeight` 减去总权重,然后**所有服务器都加上自己的固定权重**。如此循环,高权重节点被选中的频率更高,但不会连续出现。
|
||||||
|
|
||||||
|
#### 实际应用
|
||||||
|
|
||||||
|
- **Nginx** 的 `upstream` 默认负载均衡算法
|
||||||
|
- **LVS**(Linux Virtual Server)
|
||||||
|
- 几乎所有需要「按比例分配且平滑」的场景
|
||||||
|
|
||||||
|
#### 轮询示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
type server struct {
|
||||||
|
name string
|
||||||
|
weight int
|
||||||
|
curWeight int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NextServer(servers []*server) *server {
|
||||||
|
var totalWeight int
|
||||||
|
// 1.先给所有服务器加上各自的固定权重
|
||||||
|
for i := range servers {
|
||||||
|
servers[i].curWeight += servers[i].weight
|
||||||
|
totalWeight += servers[i].weight
|
||||||
|
}
|
||||||
|
// 2.再找出 curWeight 最大的服务器
|
||||||
|
maxIdx := 0
|
||||||
|
for i := 1; i < len(servers); i++ {
|
||||||
|
if servers[i].curWeight > servers[maxIdx].curWeight {
|
||||||
|
maxIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3.选中的服务器减去总权重
|
||||||
|
servers[maxIdx].curWeight -= totalWeight
|
||||||
|
return servers[maxIdx]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 轮次 | 操作前 (currentWeight) | 选中 | 操作后 |
|
||||||
|
| ---- | ---------------------- | ----- | --------------- |
|
||||||
|
| 初始 | A=0, B=0, C=0 | — | — |
|
||||||
|
| 1 | A=5, B=1, C=1 | **A** | A=−2, B=1, C=1 |
|
||||||
|
| 2 | A=3, B=2, C=2 | **A** | A=−4, B=2, C=2 |
|
||||||
|
| 3 | A=1, B=3, C=3 | **B** | A=1, B=−4, C=3 |
|
||||||
|
| 4 | A=6, B=−3, C=4 | **A** | A=−1, B=−3, C=4 |
|
||||||
|
| 5 | A=4, B=−2, C=5 | **C** | A=4, B=−2, C=−2 |
|
||||||
|
| 6 | A=9, B=−1, C=−1 | **A** | A=2, B=−1, C=−1 |
|
||||||
|
| 7 | A=7, B=0, C=0 | **A** | A=0, B=0, C=0 |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
## API
|
||||||
|
|
||||||
|
### goctl代码生成
|
||||||
|
|
||||||
|
可以在doc/genCode.md查看
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 生成多个types类型的话需要关闭这个实验性功能
|
||||||
|
goctl env -w GOCTL_EXPERIMENTAL=off
|
||||||
|
|
||||||
|
# 代码生成,会自动处理引入的子api文件
|
||||||
|
goctl api go -api background.api -dir . --home D:/zhangyuxin/template/1.8.3
|
||||||
|
goctl api go -api login.api -dir . --home ../../../deploy/goctl
|
||||||
|
goctl api go -api main.api -dir . --home ../../deploy/goctl
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数校验
|
||||||
|
|
||||||
|
| 接收规则 | 说明 | 示例 |
|
||||||
|
| -------- | ------------------------------------------ | ------------------------------- |
|
||||||
|
| optional | 当前字段是可选参数,允许为零值(zero value) | `json:"foo,optional"` |
|
||||||
|
| options | 当前参数仅可接收的枚举值 | `json:"gender,options=foo|bar"` |
|
||||||
|
| default | 当前参数默认值 | `json:"gender,default=male"` |
|
||||||
|
| range | 当前参数数值有效范围,支持开/闭区间 | `json:"age,range=[0:120]"` |
|
||||||
|
|
||||||
|
#### **`optional`** **与** **`omitempty`** **的区别**
|
||||||
|
|
||||||
|
两者都写在 json/form tag 的 options 里,但作用域完全不同,不可互相替代
|
||||||
|
|
||||||
|
##### `optional` —— goctl 专用,控制 Swagger 与请求校验
|
||||||
|
|
||||||
|
- **Swagger 生成**:标了 `optional` 的字段不进 `required` 数组,Apifox/Swagger UI 显示为可选;未标的字段进 `required` 数组,显示为必填
|
||||||
|
- **请求校验**:goctl 生成的 logic 不会对标了 `optional` 的字段做必填校验
|
||||||
|
- **判定逻辑**:goctl 遍历 tag options,找到 `optional` 关键字即判定为可选
|
||||||
|
|
||||||
|
##### `omitempty` —— Go 标准库指令,控制 JSON 序列化
|
||||||
|
|
||||||
|
- **JSON 序列化**:字段为零值(0、""、nil、false)时,响应 JSON 中**不输出**该字段
|
||||||
|
- **Swagger 生成**:**goctl 完全不识别**,字段仍按未标 `optional` 处理,即判定为 required
|
||||||
|
- **请求校验**:无影响
|
||||||
|
|
||||||
|
## RPC
|
||||||
|
|
||||||
|
### goctl代码生成
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 主proto文件代码生成
|
||||||
|
goctl rpc protoc user.proto --go_out=. --go-grpc_out=. --zrpc_out=. --proto_path=. -m --home ../../../deploy/goctl
|
||||||
|
|
||||||
|
# 主proto文件中引入的proto代码生成
|
||||||
|
protoc.exe -I ./ --go_out=. --go-grpc_out=. ./user_model.proto
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
当`proto`文件里面定义了多个 service的时候,需要额外注意,还要再main函数中手动注册一下
|
||||||
|
|
||||||
|
> 每个 proto 文件应该聚焦于一个核心概念/实体。一个文件里定义太多 service 通常是设计需要重新考虑的信号
|
||||||
|
|
||||||
|
注册示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
ebookServer "olive/services/product/rpc/internal/server/ebookservice"
|
||||||
|
)
|
||||||
|
|
||||||
|
var configFile = flag.String("f", "etc/product.yaml", "the config file")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
var c config.Config
|
||||||
|
conf.MustLoad(*configFile, &c)
|
||||||
|
ctx := svc.NewServiceContext(c)
|
||||||
|
|
||||||
|
s := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
|
||||||
|
// 新服务
|
||||||
|
productrpcmodel.RegisterEbookServiceServer(grpcServer, ebookServer.NewEbookServiceServer(ctx))
|
||||||
|
})
|
||||||
|
defer s.Stop()
|
||||||
|
|
||||||
|
fmt.Printf("Starting product RPC server at %s...\n", c.ListenOn)
|
||||||
|
s.Start()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### proto代码生成错误:File not found错误
|
||||||
|
|
||||||
|
- 错误描述
|
||||||
|
|
||||||
|
执行 protoc 报 google/protobuf/timestamp.proto: File not found. 的错提示File not found
|
||||||
|
|
||||||
|
- 解决方式
|
||||||
|
|
||||||
|
重新下载[protoc](https://github.com/protocolbuffers/protobuf/releases),解压后,将整个include目录复制到protoc执行文件同一级目录下
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## model
|
||||||
|
|
||||||
|
可以在doc/genCode.md查看
|
||||||
|
|
||||||
|
**Model 层**:只做 **“业务循环”** + **“SQL 查询”** + **“结果封装”**,不关心字符串到时间的转换和 Protobuf 类型。
|
||||||
|
|
||||||
|
### goctl代码生成
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# mysql根据远程数据库表生成
|
||||||
|
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生成
|
||||||
|
goctl model mysql ddl --src h5.sql --dir . -c --home D:/template/1.8.3
|
||||||
|
|
||||||
|
# pgsql根据远程数据库表生成
|
||||||
|
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)
|
||||||
|
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 为映射对象 |
|
||||||
|
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
|
||||||
|
|
||||||
|
## 正确关闭`HTTP`响应
|
||||||
|
|
||||||
|
> **注意事项**
|
||||||
|
>
|
||||||
|
> - 先检查 `err`,再 `defer close`
|
||||||
|
>
|
||||||
|
> - 必须消费完响应体,否则连接无法复用
|
||||||
|
>
|
||||||
|
> - `defer` 会延迟到函数结束,如果需要在循环中处理大量响应,建议手动关闭(避免 `defer` 累积)
|
||||||
|
|
||||||
|
```go
|
||||||
|
resp, err := http.Get("https://example.com")
|
||||||
|
if err != nil {
|
||||||
|
// 处理请求错误(此时 resp 可能为 nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 确保在函数退出时关闭响应体
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 手动关闭,不用 defer
|
||||||
|
// io.Copy(io.Discard, resp.Body)
|
||||||
|
// resp.Body.Close()
|
||||||
|
|
||||||
|
// 读取或处理响应体(即使不需要内容也建议消费完)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 使用 body ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 切片内存泄漏
|
||||||
|
|
||||||
|
> 即使原切片变量本身被置为 `nil` 或超出作用域,只要**还存在任何一个指向其底层数组的子切片**,整个底层数组就不会被回收
|
||||||
|
|
||||||
|
如果新切片需要长期保存,而原切片很大且即将被丢弃,那么**直接使用原切片[:n] 会导致新切片底层仍然引用原切片的整个底层数组**,使得那个大数组无法被 GC 回收,造成内存泄漏。此时应该用 `copy` 创建一份独立的、只包含所需元素的新切片。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func getFirstLines() []string {
|
||||||
|
hugeLines := readHugeFile() // 假设这个切片有 100 万行,占用 100MB 内存
|
||||||
|
// 错误做法:返回子切片
|
||||||
|
return hugeLines[:3] // 虽然只返回3行,但底层数组的 100MB 内存无法被回收!
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正确做法
|
||||||
|
func getFirstLines() []string {
|
||||||
|
hugeLines := readHugeFile()
|
||||||
|
// 只拷贝需要的3行
|
||||||
|
result := make([]string, 3)
|
||||||
|
copy(result, hugeLines[:3])
|
||||||
|
return result // 原来的 hugeLines 可以被GC回收,只占用很小的内存
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 堆栈
|
||||||
|
|
||||||
|
### 核心区别
|
||||||
|
|
||||||
|
| 特性 | 栈 | 堆 |
|
||||||
|
| --------- | -------------- | ------------------ |
|
||||||
|
| 分配/释放 | 移动指针,极快 | 需要查找 + GC 回收 |
|
||||||
|
| 生命周期 | 函数返回即销毁 | GC 决定何时回收 |
|
||||||
|
| 内存布局 | 连续,无碎片 | 分散,有碎片 |
|
||||||
|
| 大小限制 | 较小(MB级) | 较大(GB级) |
|
||||||
|
|
||||||
|
### 逃逸判断规则
|
||||||
|
|
||||||
|
```
|
||||||
|
变量在以下情况逃逸到堆:
|
||||||
|
|
||||||
|
1. 返回指针 → func f() *int { x := 42; return &x }
|
||||||
|
2. 存入全局变量 → globalPtr = &x
|
||||||
|
3. 闭包捕获 → return func() { return x }
|
||||||
|
4. 发送到 channel → ch <- &x
|
||||||
|
5. 存入外部结构体 → c.Value = &x
|
||||||
|
6. 接口装箱逃逸 → return interface{}(x)
|
||||||
|
7. 反射调用 → fmt.Println(x)
|
||||||
|
8. 大对象 → make([]int, 10000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见误区澄清
|
||||||
|
|
||||||
|
| 误区 | 正解 |
|
||||||
|
| ------------------ | -------------------------------- |
|
||||||
|
| 指针一定在堆上 | ❌ 函数内创建和使用的指针可在栈上 |
|
||||||
|
| 接口一定在堆上 | ❌ 不逃逸的接口内部值可在栈上 |
|
||||||
|
| 结构体指针一定逃逸 | ❌ 小结构体 + 内联优化可能栈分配 |
|
||||||
|
|
||||||
|
### 验证方法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看逃逸分析
|
||||||
|
go build -gcflags="-m" main.go
|
||||||
|
|
||||||
|
# 更详细信息
|
||||||
|
go build -gcflags="-m -m" main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### 优化建议
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ✅ 推荐:小对象返回值
|
||||||
|
func getUser() User {
|
||||||
|
return User{
|
||||||
|
ID: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 避免:小对象返回指针(增加 GC 压力)
|
||||||
|
func getUser() *User {
|
||||||
|
return &User{
|
||||||
|
ID: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 推荐:传指针给只读函数
|
||||||
|
func read(p *Data) int {
|
||||||
|
return p.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ 避免:在热路径使用 fmt.Print
|
||||||
|
// 用 strconv 或 log 代替
|
||||||
|
```
|
||||||
|
|
||||||
|
### 记忆口诀
|
||||||
|
|
||||||
|
```
|
||||||
|
生命周期越界 → 堆
|
||||||
|
外部有引用 → 堆
|
||||||
|
闭包捕获 → 堆
|
||||||
|
反射调用 → 堆
|
||||||
|
|
||||||
|
函数内使用 → 栈
|
||||||
|
返回值拷贝 → 栈
|
||||||
|
只读指针 → 栈
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本质理解
|
||||||
|
|
||||||
|
**栈** = 函数的私有工作区,函数结束自动清理
|
||||||
|
|
||||||
|
**堆** = 共享仓库,需要 GC 定期打扫
|
||||||
|
|
||||||
|
逃逸分析 = 编译器判断"这个变量能不能安全地放在栈上"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 类型转换/序列化
|
||||||
|
|
||||||
|
```
|
||||||
|
//int64转换string
|
||||||
|
strconv.FormatInt(num,10)
|
||||||
|
|
||||||
|
//序列化为string
|
||||||
|
jsoniter.ConfigCompatibleWithStandardLibrary.MarshalToString(input)
|
||||||
|
```
|
||||||
|
|
||||||
|
在 encode/decode JSON 数据并且数据类型是interface时,Go 默认会将数值当做 float64 处理
|
||||||
|
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Age int `json:"age"`
|
||||||
|
Height float64 `json:"height"`
|
||||||
|
}
|
||||||
|
type UserInter struct {
|
||||||
|
Name interface{} `json:"name"`
|
||||||
|
Age interface{} `json:"age"`
|
||||||
|
Height interface{} `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJson(t *testing.T) {
|
||||||
|
one := &User{
|
||||||
|
Name: "tom",
|
||||||
|
Age: 18,
|
||||||
|
}
|
||||||
|
oneJson, err := jsoniter.Marshal(one)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var two UserInter
|
||||||
|
var three map[string]interface{}
|
||||||
|
err = jsoniter.Unmarshal(oneJson, &two)
|
||||||
|
err = jsoniter.Unmarshal(oneJson, &three)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(reflect.TypeOf(two.Name))
|
||||||
|
fmt.Println(reflect.TypeOf(two.Age)) //会打印float64
|
||||||
|
fmt.Println(reflect.TypeOf(two.Height)) //会打印float64
|
||||||
|
fmt.Println(reflect.TypeOf(three["name"]))
|
||||||
|
fmt.Println(reflect.TypeOf(three["age"])) //会打印float64
|
||||||
|
fmt.Println(reflect.TypeOf(three["height"])) //会打印float64
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## `HTTP`阅后即焚
|
||||||
|
|
||||||
|
处理 HTTP Body 只能读一次的问题时,根据你的**数据量大小**选择武器:
|
||||||
|
|
||||||
|
1. **小请求(配置、JSON)**:使用 `io.ReadAll` 存入内存,再通过 `io.NopCloser` 塞回 `r.Body` 或设置 `r.GetBody`。
|
||||||
|
2. **中等请求(签名校验)**:使用 `io.TeeReader` 边读边处理,节省一次全量扫描。
|
||||||
|
3. **大请求(文件上传)**:**禁止 ReadAll**。直接流式 `io.Copy` 到目的地,或使用临时文件做中转。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## map的坑
|
||||||
|
|
||||||
|
go的map内部是哈希表,元素会因 rehash(扩容)而移动到新的内存位置,如果允许 `&m[key]` 拿到一个指针,map 一旦扩容,这个指针就变成悬空指针(指向已失效的内存),为了避免这种内存安全问题,Go 直接禁止对 map 值取地址,下面是示例代码:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
Name string
|
||||||
|
Age int
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
m := map[int]User{1: {Name: "张三", Age: 20}}
|
||||||
|
|
||||||
|
// ❌ 编译错误:cannot assign to struct field m[1].Name in map
|
||||||
|
m[1].Name = "李四"
|
||||||
|
|
||||||
|
// ✅ 正确的替代写法
|
||||||
|
temp:=m[1]
|
||||||
|
temp.Name = "李四"
|
||||||
|
m[1]=temp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **可寻址(addressable)**:能通过 `&` 拿到它内存地址的操作数。
|
||||||
|
>
|
||||||
|
> **不可寻址(not addressable)**:不能通过 `&` 拿到地址的操作数。
|
||||||
|
|
||||||
|
## 句点引入
|
||||||
|
|
||||||
|
一个完整引入声明语句形式的引入名`importname`可以是一个句点(`.`)。 这样的引入称为句点引入。使用被句点引入的包中的导出代码要素时,限定标识符的前缀必须省略。
|
||||||
|
|
||||||
|
比如在下面这个例子中,`Println`和`Now`函数调用不需要带任何前缀。
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
. "fmt"
|
||||||
|
. "time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
Println("Current time:", Now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 一般来说,句点引入不推荐使用,因为它们会导致较低的代码可读性
|
||||||
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
## 依赖与模块管理
|
||||||
|
|
||||||
|
| 命令 | 作用 | 示例 |
|
||||||
|
| ----------------- | ---------------------------------------- | ---------------------------------------- |
|
||||||
|
| `go mod init` | 初始化一个新模块(生成 go.mod) | `go mod init myapp` |
|
||||||
|
| `go mod tidy` | 自动添加缺失的依赖、删除未使用的依赖 | `go mod tidy` |
|
||||||
|
| `go get` | 为当前模块添加/升级/降级依赖 | `go get github.com/gin-gonic/gin@v1.9.0` |
|
||||||
|
| `go mod download` | 预下载 go.mod 中声明的所有依赖到本地缓存 | `go mod download` |
|
||||||
|
| `go mod vendor` | 将依赖复制到 vendor 目录(离线构建用) | `go mod vendor` |
|
||||||
|
| `go list` | 列出包或模块信息 | `go list -m all`(查看所有依赖版本) |
|
||||||
|
|
||||||
|
## 运行与安装
|
||||||
|
|
||||||
|
| 命令 | 作用 | 示例 |
|
||||||
|
| :----------- | :---------------------------------- | :----------------------------------- |
|
||||||
|
| `go run` | 编译并直接运行程序(适合调试) | `go run .` |
|
||||||
|
| `go install` | 编译并安装到 GOBIN 目录(全局可用) | `go install example.com/tool@latest` |
|
||||||
|
|
||||||
|
## 代码质量与格式化
|
||||||
|
|
||||||
|
| 命令 | 作用 | 示例 |
|
||||||
|
| :------- | :--------------------------- | :------------- |
|
||||||
|
| `go fmt` | 自动格式化代码,符合官方风格 | `go fmt ./...` |
|
||||||
|
| `go vet` | 静态检查代码中潜在的错误 | `go vet ./...` |
|
||||||
|
| `go fix` | 自动把旧版 API 更新为新 API | `go fix ./...` |
|
||||||
|
|
||||||
|
## 查看信息
|
||||||
|
|
||||||
|
| 命令 | 作用 | 示例 |
|
||||||
|
| :----------- | :--------------------- | :------------------------------------- |
|
||||||
|
| `go doc` | 查看包或函数的文档 | `go doc fmt.Println` |
|
||||||
|
| `go env` | 查看/修改 Go 环境配置 | `go env -w GOPROXY=https://goproxy.cn` |
|
||||||
|
| `go version` | 查看当前 Go 版本 | `go version` |
|
||||||
|
| `go help` | 查看某个命令的详细帮助 | `go help build` |
|
||||||
|
|
||||||
|
## 其他进阶命令
|
||||||
|
|
||||||
|
| 命令 | 作用 | 说明 |
|
||||||
|
| :------------ | :---------------------------------------------- | :------------------------------------------------------- |
|
||||||
|
| `go clean` | 清除缓存和编译产物 | `go clean -cache`、`go clean -modcache` |
|
||||||
|
| `go generate` | 运行源代码中的 `//go:generate` 指令自动生成代码 | 常用于生成代码模板 |
|
||||||
|
| `go tool` | 调用 Go 自带工具 | `go tool pprof`(性能分析)、`go tool trace`(协程跟踪) |
|
||||||
|
| `go work` | 多模块工作区管理 | `go work init`、`go work use ./xxx` |
|
||||||
|
| `go bug` | 打开浏览器提交 bug 报告 | 会自动附带系统信息 |
|
||||||
|
|
||||||
|
## 交叉编译
|
||||||
|
|
||||||
|
### 基本用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
GOOS=目标系统 GOARCH=目标架构 go build -o 输出文件名 源文件
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
$env:GOOS="目标系统"; $env:GOARCH="目标架构"; go build -o 输出文件名 源文件
|
||||||
|
|
||||||
|
# Windows (CMD)
|
||||||
|
set GOOS=目标系统 && set GOARCH=目标架构 && go build -o 输出文件名 源文件
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
查看所有支持的平台
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go tool dist list
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用组合
|
||||||
|
|
||||||
|
| 目标平台 | GOOS | GOARCH |
|
||||||
|
| ------------ | ------- | ------ |
|
||||||
|
| Linux 64位 | linux | amd64 |
|
||||||
|
| Linux ARM64 | linux | arm64 |
|
||||||
|
| Windows 64位 | windows | amd64 |
|
||||||
|
| macOS Intel | darwin | amd64 |
|
||||||
|
| macOS M1/M2 | darwin | arm64 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux/Mac (Bash)
|
||||||
|
# 编译 Linux 64位
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o app ./app/api/main.go
|
||||||
|
# 编译 Windows 64位
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o app.exe ./app/api/main.go
|
||||||
|
# 编译 macOS ARM64 (M1/M2)
|
||||||
|
GOOS=darwin GOARCH=arm64 go build -o app ./app/api/main.go
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
# 编译 Linux 64位
|
||||||
|
$env:GOOS="linux"; $env:GOARCH="amd64"; go build -o app ./app/api/main.go
|
||||||
|
# 编译 Windows 64位
|
||||||
|
$env:GOOS="windows"; $env:GOARCH="amd64"; go build -o app.exe ./app/api/main.go
|
||||||
|
# 编译 macOS ARM64 (M1/M2)
|
||||||
|
$env:GOOS="darwin"; $env:GOARCH="arm64"; go build -o app ./app/api/main.go
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user