Compare commits

...
59 Commits
Author SHA1 Message Date
labmem c812cfb663 wip(lyra): 保存带选项列表查询的未完成设计
- 添加通用查询选项、分类过滤选项及动态缓存键生成
- 新增分类创建与更新时间字段

当前实现尚未完成,留待后续继续设计:
- 明确通用查询选项、模型过滤条件与缓存策略的边界
- 补齐数据库回源,修正分类返回类型及过滤条件生效问题
- 修正分页偏移计算,增加参数边界校验和稳定排序
- 完善缓存故障降级、空结果缓存及写后失效策略
- 修正缓存键参数编码歧义,补齐查询名和版本前缀
- 补充测试并验证编译与查询行为
2026-10-21 00:11:37 +08:00
labmem 97b26635fa feat(lyra): 为分类查询添加缓存并同步项目约定
- 实现分类列表缓存查询与故障降级
- 统一缓存键命名
- 同步日志、响应、依赖组装及测试约定
- 移除 postgres 占位目录
2026-10-20 17:42:41 +08:00
labmem bef30be3fe wip(lyra): 补充链接模型并注入分类缓存依赖 2026-10-20 16:47:22 +08:00
labmem cd0e13325f feat(lyra)!: 添加结构化日志与请求追踪
- 配置 slog JSON 输出和日志级别
- 添加请求 ID、访问日志及上下文日志工具
- 拆分启动初始化并统一启动失败处理

BREAKING CHANGE: 配置文件必须提供 log.level,缺失或非法将导致启动失败
2026-10-20 15:16:35 +08:00
labmem 759bc3fc35 feat(lyra)!: 接入 Redis 并添加缓存连通性实验
- 封装缓存读写、JSON 编解码、过期时间与操作超时
- 集中组装 Redis 依赖,在测试接口验证分类缓存读写
- 更新依赖并移除原 handler 测试

BREAKING CHANGE: 配置节由 redis 改为 cache,新增 db 和 timeout 配置项
2026-10-19 23:12:49 +08:00
labmem b72b14a818 feat(polaris): 新增三个导航网站并更新游戏资料图标
Polaris CI / verify (push) Successful in 3m40s
Polaris CI / publish (push) Successful in 5m7s
2026-10-19 11:35:53 +08:00
labmem 306107af83 docs(goose): 添加数据库迁移使用笔记 2026-10-17 17:47:40 +08:00
labmem ab83ad96b4 feat(lyra)!: 接入分类查询并集中组装依赖
- 使用 Squirrel 与 sqlx 实现分类查询
- 集中组装依赖并向 handler 注入 model 接口
- 显式转换分类响应 DTO,统一空列表输出
- 补充路由与 handler 测试,同步架构文档

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

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

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

BREAKING CHANGE: 不再生成 out/ 静态产物,部署需运行 Node 服务容器。
2026-10-12 17:42:26 +08:00
labmem 09e5735162 ci(polaris): 添加ci测试
Polaris CI / verify (push) Successful in 1m42s
2026-10-12 15:28:36 +08:00
labmem abe4dec0b1 ci(polaris): 增加gitea工作流测试
Polaris runner smoke test / smoke (push) Successful in 1m15s
2026-10-12 14:51:38 +08:00
labmem 6e839019f5 feat(polaris): 细分游戏资料并完善站点导航
新增游戏资料分类及九个网站链接,清理三条旧链接。
调整侧边栏提示交互,并为阿里云控制台使用本地图标。
同步更新分类、链接和交互测试。
2026-10-12 00:08:34 +08:00
labmem 95e2799721 feat(polaris): 优化深色玻璃主题与分类页布局
让背景透过分类页内容区域,并移除标题背景框。
优化移动端样式,更新背景图和品牌标志,保留旧标志备份。
2026-10-10 22:00:54 +08:00
labmem 35aaa0e17e fix(category): improve count badge contrast 2026-10-08 19:49:30 +08:00
labmem e37cf9936e feat(polaris): import bookmarks and refine category scrolling
- 导入 29 条精选公开书签,排除重复与敏感入口
- 将滚动限制在分类页内层卡片区域,并适配窄屏
- 更新数据与页面测试
2026-10-08 19:49:30 +08:00
labmem 6641d181ab fix(sidebar): place collapsed toggle inside sidebar 2026-10-08 19:49:30 +08:00
labmem 39bde32376 feat(polaris): 更新导航链接并修复图标加载
调整链接数据、图标映射和相关测试,新增 go-zero 本地图标。
修复图片在事件绑定前已加载或失败时的显示问题。
2026-10-08 19:49:30 +08:00
labmem 1a48960022 feat: 添加 Go 内存分配示例并整理笔记
拆分 Go 第三方库笔记并更新索引。
新增 Gitleaks 使用说明,清理过时笔记。
2026-10-08 16:18:54 +08:00
labmem e0676bfc03 docs: document secret checks and drop old plan 2026-09-24 16:46:05 +08:00
labmem 691a169fe9 restore: re-add sanitized go-zero/goctl.md after path-history rewrite
git-filter-repo --invert-paths removed note/资源/编程/Golang/go-zero/goctl.md from all history in this isolated clone. This commit re-adds only the sanitized file so the final tree matches merge candidate 58ba5eb7676480211dc6a4c2d8f3a08402d8a080.
2026-09-24 14:43:17 +08:00
labmem 7817e19783 Merge branch 'main' of gitea: 汇合本地与 Gitea 双方提交 2026-09-24 14:31:01 +08:00
labmem babd8f2793 wip: 保存 Go 练习和技术笔记 2026-09-24 14:16:17 +08:00
labmemandClaude Opus 5 4a032ea742 feat(polaris): 实现静态分类页
新增七个预生成分类路由、工具卡片网格与空状态。启用侧栏分类导航和当前分类选中态,修复 Windows 静态导出 RSC 路径并补充产物验证,保持静态导出与安全外链约束。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:24:57 +08:00
labmem 64cc3ec381 chore: 添加 .gitattributes 统一行尾为 LF
仓库从 Windows 迁移至 WSL,通过 text=auto eol=lf 强制两端
检出/提交均使用 LF,消除跨平台行尾伪差异;二进制文件显式声明不转换
2026-09-08 09:23:31 +08:00
labmem e9f3bf6934 Merge branch 'main' of https://git.labmem.icu/labmem/lab 2026-09-07 22:46:45 +08:00
labmemandClaude Opus 4.8 5a46186bde feat(polaris): 完善首页背景与搜索入口视觉
使用本地背景和官方 Provider 图标,闭合状态仅展示当前图标。
调整冷蓝磨砂层次,并移除快捷卡片外部阴影以消除底部色带。
补充搜索 Provider 展示行为测试。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-07 22:25:30 +08:00
labmemandClaude Opus 4.8 9754d61ffe feat(ts): 实现 Polaris 可交互首页
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 23:53:28 +08:00
张育新 20fa8f4d5a docs(note): 重组笔记目录结构并新增 MySQL JOIN 实验笔记
- 随手记笔记按主题迁移至 note/资源/(DevOps、数据库、编程)
- Windows使用、go errors 等笔记迁移时同步更名为规范标题
- 新增 MySQL 空表 JOIN 导致排序索引失效实验笔记
- 移除 go操作excel 笔记
2026-08-28 16:48:16 +08:00
张育新 9714bd4ab5 Merge branch 'main' of https://git.labmem.icu/labmem/lab 2026-08-22 18:19:16 +08:00
张育新 b23bd2d5ec docs(note): 新增 SSH 密钥登录配置笔记 2026-08-22 18:17:13 +08:00
labmem 7eaa9e6f80 docs(ts): 重组 Polaris 设计文档体系并迁移至 nextjs 目录
- 拆分为项目说明/首页设计/分类页设计三份文档,职责分离
- 新增项目说明文档:14 条决策记录、技术选型(Next.js 静态导出)、
  数据结构、Go API 草案、通用语法表结构、五阶段规划
- 融入决策:JSON 数据载体、Ctrl+K 仅首页生效、统一图标方案
  (si:/local:/url: 前缀 + 四级 fallback)、搜索按模式严格分离、
  首页补足 8 个常用链接(跨分类归属)
- 目录 ts/web/astro/polaris → ts/web/nextjs/polaris,
  删除过时 README,更新 ts/README.md 框架描述
2026-08-21 22:08:14 +08:00
labmem 0913235382 docs(ts): 新增 Polaris 个人导航站说明文档与设计图
- 新增 Polaris 个人导航站项目说明文档,涵盖项目定位、视觉风格、
  首页布局、搜索方案、技术选型、数据结构与分阶段开发规划
- 新增 Polaris 首页页面设计图
2026-08-20 23:23:53 +08:00
labmem 0686bd0d87 Merge branch 'main' of https://git.labmem.icu/labmem/lab 2026-08-17 23:44:23 +08:00
labmem a3272858ad docs(ts): 新增 TypeScript 实验目录及 Astro 项目说明
- 添加 ts/README.md 约定文档(每个实验独立 npm 项目)
- 新增 ts/web/astro/polaris 实验项目说明与 logo
- .gitignore 增加 Node/TypeScript 忽略规则
- 根 README 目录说明补充 ts/ 条目
2026-08-17 23:29:26 +08:00
张育新 2b442221b2 docs(note): 补充 go 命令/excelize/MySQL 字符集笔记 2026-08-08 18:20:19 +08:00
张育新 817344fc66 test(go): 添加 errors.AsType、net 及手机号正则匹配测试 2026-08-08 18:20:19 +08:00
张育新 f175b3a66d Merge branch 'main' of https://github.com/OSHMKUFA5100/lab
# Conflicts:
#	note/随手记/go.md
2026-08-06 09:15:15 +08:00
张育新 123b607dc4 docs(note): 新增 excel/正则/Nginx 笔记并整理目录
- 新增 AGENTS.md 仓库说明与 excelize 依赖
- 新增正则表达式、go操作excel、Nginx常见错误、Golang底层架构笔记
- 布尔变量命名迁移至资源/编程/规范,删除随手记旧笔记
2026-08-05 18:32:17 +08:00
张育新 3902c6996c test(regularMatch): 添加中文正则匹配测试 2026-08-05 18:31:59 +08:00
labmem ef6d737d91 docs(go): 添加内存逃逸学习笔记与示例代码
- 新增 go/basics/MemoryEscape/main.go,演示返回局部变量指针和闭包引用两种典型逃逸场景
- 在 note/随手记/go.md 中补充内存逃逸的概念、影响、常见原因及逃逸分析命令
- 清理 note/随手记/网址.md 中的多余空行
2026-07-23 00:07:16 +08:00
张育新 d4cad276e3 docs(note): 补充技术笔记和主题配置
- Go map Swiss Table 实现说明
- MarkText 仿 onelight 暗色主题
- 布尔变量命名规范
- 更新网址收藏及跟踪 assets 资源
2026-07-21 17:53:15 +08:00
张育新 a1156d6977 Merge branch 'main' of https://github.com/OSHMKUFA5100/lab 2026-07-15 19:14:20 +08:00
张育新 f5f2314589 docs(note): reorganize notes structure and migrate content
- Move Windows and 算法 notes from 随手记 to 资源 directory
- Migrate Golang map pitfalls and dot import notes to Golang/使用注意
- Delete omos配置 note
- Add Typora custom theme reference link
2026-07-15 19:12:07 +08:00
labmem 0ae0be2f87 Merge branch 'main' of https://github.com/OSHMKUFA5100/lab 2026-07-13 23:12:38 +08:00
张育新 88861a6da1 docs(note): 补充 git merge --abort 取消合并命令 2026-07-13 22:46:30 +08:00
张育新 a52408862d test(testing): 添加中位数计算和通道通信测试用例
- 新增 TestNormal: 测试 GetMiddNumber 中位数计算逻辑
- 新增 TestChannel: 模拟通道传球 goroutine 交互
2026-07-13 22:46:07 +08:00
张育新 6befa47926 docs(note): 添加笔记目录及 gitignore 规则
- 新增 note 目录包含 AI、SQL、Golang、网络等多领域学习笔记
- 更新 .gitignore 添加 note/assets/ 和 note/项目/ 忽略规则
2026-07-10 09:24:40 +08:00
217 changed files with 31636 additions and 25 deletions
+8
View File
@@ -0,0 +1,8 @@
# 统一行尾为 LF:Windows / WSL 共享同一仓库时防止行尾伪差异
* text=auto eol=lf
# 二进制文件不做任何转换
*.png binary
*.jpg binary
*.gif binary
*.ico binary
+173
View File
@@ -0,0 +1,173 @@
name: Polaris CI
on:
push:
tags:
- 'polaris-v*'
jobs:
verify:
runs-on: polaris-ci
container: node:22.23.2-bookworm
timeout-minutes: 30
defaults:
run:
working-directory: ts/web/nextjs/polaris
steps:
- name: 检出仓库
uses: https://gitea.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: 安装依赖
run: corepack pnpm install --frozen-lockfile
- name: 代码检查
run: corepack pnpm lint
- name: 类型检查
run: corepack pnpm typecheck
- name: 运行测试
run: corepack pnpm test
- name: 构建独立服务器
run: corepack pnpm build
- name: 准备独立部署文件
run: |
test -f .next/standalone/server.js
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
- name: 独立服务器冒烟测试
shell: bash
run: |
set -euo pipefail
base_url="http://127.0.0.1:3100"
PORT=3100 HOSTNAME=127.0.0.1 node .next/standalone/server.js &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
ready=0
for _ in $(seq 1 30); do
if curl -fsS -o /dev/null "$base_url/"; then
ready=1
break
fi
kill -0 "$server_pid" 2>/dev/null || { echo "服务器进程提前退出" >&2; exit 1; }
sleep 1
done
test "$ready" -eq 1 || { echo "服务器在 30 秒内未就绪" >&2; exit 1; }
curl -fsS -o /dev/null "$base_url/categories/dev/"
curl -fsS -o /dev/null "$base_url/brand/logo.svg"
static_asset=$(curl -fsS "$base_url/" | grep -o '/_next/static/[^"]*' | sed -n '1p')
test -n "$static_asset" || { echo "首页未引用 /_next/static 静态资源" >&2; exit 1; }
curl -fsS -o /dev/null "$base_url$static_asset"
publish:
needs: verify
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/polaris-v')
runs-on: polaris-ci
container: node:22.23.2-bookworm
timeout-minutes: 30
steps:
- name: 检出仓库
uses: https://gitea.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
# Daemon comes from the runner (gitea/runner:3-dind-rootless) via the
# job container's /var/run/docker.sock. Only the CLI is installed here;
# DOCKER_HOST stays unset and no host socket is mounted.
- name: 安装 Docker 命令行工具
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends docker.io
docker version
- name: 确定镜像引用
shell: bash
run: |
set -euo pipefail
repository="${GITHUB_REPOSITORY:?未设置 GITHUB_REPOSITORY}"
owner="${repository%%/*}"
owner="${owner,,}"
if ! [[ "$owner" =~ ^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$ ]]; then
echo "::error::所有者名称 '$owner' 不是有效的镜像仓库路径组成部分" >&2
exit 1
fi
image="git.labmem.icu/${owner}/polaris:${GITHUB_SHA}"
echo "镜像引用:$image"
echo "IMAGE=$image" >> "$GITHUB_ENV"
- name: 构建镜像
run: docker build -t "$IMAGE" ts/web/nextjs/polaris
- name: 镜像冒烟测试
shell: bash
run: |
set -euo pipefail
# Use the unique container ID instead of a fixed name so
# concurrent runs on a shared runner daemon cannot collide.
container="$(docker run -d "$IMAGE")"
cleanup() { docker rm -f "$container" >/dev/null 2>&1 || true; }
trap cleanup EXIT
probe() {
docker exec "$container" node -e "fetch('http://127.0.0.1:3000$1').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" >/dev/null 2>&1
}
ready=0
for _ in $(seq 1 30); do
if probe /; then
ready=1
break
fi
if [[ "$(docker inspect -f '{{.State.Running}}' "$container")" != "true" ]]; then
echo "容器提前退出,日志:" >&2
docker logs "$container" >&2 || true
exit 1
fi
sleep 1
done
test "$ready" -eq 1 || { echo "服务器在 30 秒内未就绪" >&2; docker logs "$container" >&2 || true; exit 1; }
probe /categories/dev/ || { echo "冒烟测试失败:/categories/dev/" >&2; exit 1; }
probe /brand/logo.svg || { echo "冒烟测试失败:/brand/logo.svg" >&2; exit 1; }
echo "镜像 $IMAGE 冒烟测试通过"
- name: 登录并推送镜像
shell: bash
env:
POLARIS_REGISTRY_USER: ${{ secrets.POLARIS_REGISTRY_USER }}
POLARIS_REGISTRY_TOKEN: ${{ secrets.POLARIS_REGISTRY_TOKEN }}
run: |
set -euo pipefail
if [[ -z "${POLARIS_REGISTRY_USER}" || -z "${POLARIS_REGISTRY_TOKEN}" ]]; then
echo "::error::缺少 secrets.POLARIS_REGISTRY_USER 或 secrets.POLARIS_REGISTRY_TOKEN,无法登录 git.labmem.icu;请在仓库 Settings -> Actions -> Secrets 配置后重试" >&2
exit 1
fi
# Ephemeral docker config: credentials live only inside this step
# and are deleted when the step (and with it the job) exits.
docker_config_dir="$(mktemp -d)"
push_log_file="$(mktemp)"
trap 'rm -rf "$docker_config_dir" "$push_log_file"' EXIT
printf '%s' "$POLARIS_REGISTRY_TOKEN" | docker --config "$docker_config_dir" login git.labmem.icu --username "$POLARIS_REGISTRY_USER" --password-stdin >/dev/null
echo "正在推送 $IMAGE"
# tee keeps push output visible in the log; pipefail (already set
# via `set -euo pipefail`) fails the step when push itself fails.
docker --config "$docker_config_dir" push "$IMAGE" 2>&1 | tee "$push_log_file"
digest="$(sed -n 's/.*digest: \(sha256:[0-9a-f]\{64\}\).*/\1/p' "$push_log_file" | tail -n 1)"
if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::push 日志中未解析到合法 digest(期望 sha256:<64 位十六进制>),不输出部署引用;请在 registry 界面确认该镜像的实际 digest 后再手动部署" >&2
exit 1
fi
echo "已发布镜像:$IMAGE"
echo "镜像摘要:$digest"
echo "手动部署引用:docker pull ${IMAGE%%:*}@$digest"
+19
View File
@@ -19,6 +19,20 @@ vendor/
.pub/
build/
# ---- Node / TypeScript ----
node_modules/
dist/
.astro/
*.tsbuildinfo
.next/
out/
coverage/
.env*
!.env.example
# ---- Claude Code ----
.claude/worktrees/
# ---- IDE / 编辑器 ----
.idea/
.vscode/
@@ -28,3 +42,8 @@ build/
# ---- 操作系统 ----
.DS_Store
Thumbs.db
# ---- Note 目录 ----
note/项目/
docs/plans
go/projects/lyra/data
+44
View File
@@ -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 配置、操作系统文件
- 编辑、暂存或提交代码、笔记、配置时留意真实数据库连接、用户名密码、令牌密钥、私钥、个人路径及意外二进制/生成物
- 发现疑似真实敏感信息先暂停,只报告路径、行号和类型(不复述值),建议脱敏或移除、必要时轮换;演示占位值不算真实凭据,经用户确认无敏感风险后可继续
+2 -1
View File
@@ -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
- `ts/` — TypeScript / JavaScript(每个实验独立 npm 项目,约定见 [ts/README.md](ts/README.md))
## 约定
+10 -20
View File
@@ -1,24 +1,14 @@
# Go 练习区
# Go
本目录是 Go 的学习 / 练手代码,共用一个 module(`go.mod` 在本目录)。
本目录按用途分两个区,`go/` 本身不是 module:
## 约定
- **每个叶子目录 = 一个独立小程序**:`package main` + `main.go`。
- 多个小程序并存互不冲突(不同目录、各自的 `main`)。
- 某个程序依赖太特殊时,可在它自己的目录单独 `go mod init`,脱离本 module。
## 主题目录
| 目录 | 放什么 |
| 目录 | 定位 |
|---|---|
| `basics/` | 语法基础、内置类型(slice / map / chan 行为等) |
| `concurrency/` | 并发:goroutine / channel / select / context |
| `stdlib/` | 标准库练习(net/http、encoding/json、io…) |
| `patterns/` | 设计模式 |
| `algorithms/` | 数据结构与算法 |
| `web/` | net/http、Gin 等框架 |
| `testing/` | 练习 Go 测试机制(`*_test.go`、`go test`、table-driven、testify) |
| `project/` | 完整小项目(多文件) |
| [`practice/`](practice/README.md) | 练习区:共用一个 module(`go.mod` 在 `practice/` 内),语法 / 算法 / 测试等日常练手代码 |
| [`projects/`](projects/README.md) | 项目区:每个子目录是独立 module 的完整小应用,自带 `go.mod`、自行管理依赖 |
> 主题目录可随时增删。写新程序时挑最贴的主题,在里面建一个新子目录放 `main.go` 即可。
## 边界
- 写小段代码验证语言行为、练语法和标准库 → 放 `practice/` 对应主题目录。
- 程序长成完整应用,或依赖需要独立于练习区管理 → 在 `projects/` 下新建目录,单独 `go mod init`。
- 两个区互不包含:`practice/` 的 module 不覆盖 `projects/`,每个 projects 项目也是独立 module。
-3
View File
@@ -1,3 +0,0 @@
module lab
go 1.26.2
+35
View File
@@ -0,0 +1,35 @@
# practice — 练习区
共用一个 module 的练手代码:`go.mod` 在本目录(`module lab`),所有日常练习都放这里。两区定位见 [../README.md](../README.md)。
## 约定
- 每个小程序 = 一个子目录:`package main` + `main.go`,多个小程序并存互不冲突(不同目录、各自的 `main`)。
- 库包与测试包同样是合法形态:算法实现可以只提供库(如 `algorithms/linkedlist/`),`testing/` 下多为只有 `*_test.go` 的测试包。
- 测试文件用 `*_test.go`,与被测代码同目录或独立成子包均可。
- 程序长成完整应用、依赖需要独立管理时,移到 [`../projects/`](../projects/README.md) 单独 `go mod init`。
## 当前主题
| 目录 | 放什么 |
|---|---|
| `basics/` | 语法基础、内置类型行为(slice/map、runtime、pprof、goroutine 泄漏等) |
| `algorithms/` | 数据结构与算法(含可复用的库包,如 `linkedlist/`) |
| `testing/` | 练习 Go 测试机制,按点分子包:table-driven、regexp、net、crypto、json、error 等 |
主题目录可随时增删:想练并发、标准库、设计模式、Web 时,直接新建 `concurrency/`、`stdlib/`、`patterns/`、`web/` 等目录即可。
## 常用命令
以下示例从仓库根目录开始执行:
```bash
cd go/practice
go run ./basics/runtime/ # 运行单个程序
go test ./... # 运行全部测试(不保证全过,见下方说明)
go test ./testing/... -v # testing 包及其子包的全部测试
go test ./testing/slice -run TestSliceAppend -v # 运行单个测试
go mod tidy # 整理依赖
```
> 注意:练习代码中含阻塞(如 channel 永久等待)或依赖环境(如反向 DNS)的测试示例,`go test ./...` 不保证全部通过;验证单个练习可用 `go test ./testing/slice -run TestSliceAppend`。
@@ -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()
}
+29
View File
@@ -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())
}
+50
View File
@@ -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)
}
+25
View File
@@ -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())
}
+56
View File
@@ -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)
}
}
+34
View File
@@ -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 后")
}
+116
View File
@@ -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()
}
}
+44
View File
@@ -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
)
+102
View File
@@ -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=
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
})
http.ListenAndServe(":30000", r)
}
+149
View File
@@ -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)
}
+34
View File
@@ -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())
}
}
+68
View File
@@ -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)
}
+18
View File
@@ -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)
}
+109
View File
@@ -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])
}
+27
View File
@@ -0,0 +1,27 @@
# projects — 独立 module 项目区
每个子目录是一个**完全独立的 Go module**,用于完整的小应用:自带 `go.mod`、自行管理依赖,与 [`../practice/`](../practice/README.md) 的共用 module 互不影响。是否该放这里见 [../README.md](../README.md)。
## 新建项目
以下命令从仓库根目录开始执行:
```bash
mkdir go/projects/myapp && cd go/projects/myapp
go mod init example.com/myapp
```
`example.com/myapp` 仅为示例,需替换:本地练手可任意取名;打算对应真实仓库时按实际路径选择(如 `github.com/<用户名>/lab/go/projects/myapp`)。
之后按需创建 `main.go`(或 `cmd/<名>/main.go` 等布局)即可,不需要额外脚手架。
## 运行与维护
Go 命令一律在**该项目自己的目录**(module 根)下执行;以下示例从仓库根目录开始:
```bash
cd go/projects/myapp
go run . # 运行(按项目实际入口调整)
go test ./... # 运行测试
go mod tidy # 整理依赖
```
+33
View File
@@ -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 和真实端口。
- 需要真实服务的联调测试单独说明和执行,不混入默认测试。
+66
View File
@@ -0,0 +1,66 @@
# Lyra
链接、分类、设置等资源的后端服务(实现中)。目标结构与技术选型见
[docs/architecture.md](docs/architecture.md)。
## 当前状态
HTTP 通路已延伸到数据库:启动时加载本地 `.env.local` 与 `etc/lyra.toml`,经
sqlx 连接 PostgreSQL(注册 pgx stdlib 驱动,驱动名取自配置),组装依赖后注册
`GET /test`——由 `handler.TestHandler` 调用 `model.CategoryModel.FindAll` 查询
`public.categories`(当前固定 `sort >= 1`、按 sort/id 排序、limit 10),并经
显式转换为本轮新增的 `api.CategoryResponse` 响应 DTO 后输出。该接口用于验证
handler→model→数据库链路,不是完整业务 API。
数据库连接代码当前在入口 `main` 中,`internal/postgres` 与 `internal/service`
仍是占位(仅 `AGENTS.md`),业务 service 未实现;连接与查询未设置超时,连接
真实数据库的端到端验证本轮也未做。
`internal/model` 已落地 `Category` 结构、`CategoryModel` 接口与 Squirrel +
sqlx 实现。路由与 handler 均有基于替身的 httptest 回归测试(不监听实际端口、
不连接数据库),不证明真实 SQL 可用。各内部目录的职责与依赖约束记录在各目录
`AGENTS.md` 中。数据库迁移 SQL 已就位:`migrations/00001_init.sql`(初始函数
与三张表),通过手工 goose CLI 执行,操作手册见
[docs/goose.md](docs/goose.md),迁移目录说明见
[migrations/README.md](migrations/README.md);Goose 的 Go 依赖仍未引入。
架构文档中标注“待新增”的文件(links 等)尚未创建。
## 目录职责
| 路径 | 职责 |
|---|---|
| `cmd/lyra/` | 程序入口:启动、依赖组装、退出清理(数据库连接当前建在此处) |
| `internal/api/` | HTTP 请求/响应 DTO,按资源分文件(平铺) |
| `internal/config/` | 配置定义与加载 |
| `internal/postgres/` | PostgreSQL 连接的目标位置(占位:连接暂在入口 main) |
| `internal/router/` | 路由注册、项目路径前缀(router.go,平铺) |
| `internal/handler/` | 参数绑定、DTO 转换、响应输出;可按业务分包,服务级简单接口平铺在根包 |
| `internal/service/` | 业务规则、事务协调;可按业务分包,服务级简单接口平铺在根包(占位) |
| `internal/model/` | 数据结构定义与数据访问(SQL,平铺) |
| `internal/httpx/` | 公共响应辅助 |
| `migrations/` | Goose SQL 迁移文件(00001 初始迁移已就位) |
| `docs/` | 架构与计划文档 |
## 运行
Go 命令在本目录(module 根)下执行;`.env.local` 与配置路径 `etc/lyra.toml`
均按当前工作目录解析,换目录运行会找不到它们:
```bash
go run ./cmd/lyra # 启动 HTTP 服务,监听地址与端口见 etc/lyra.toml
go test ./... # 回归测试(不监听实际端口、不连接数据库)
```
启动前提:本地存在 `.env.local`(提供数据库口令等环境变量,已被 gitignore、
不入库,内容不在文档展开)、`etc/lyra.toml` 配置完整,且其指向的 PostgreSQL
可达;连接失败时启动即退出。
配置中的 `app.env` 直接传给 `gin.SetMode`,仅支持 `debug`/`release`/`test`。
环境变量方面当前仅 `database.password` 绑定了 `PG_PASSWORD`,其余配置项不
支持环境变量覆盖,也没有校验与默认值。
## 技术栈
已引入并使用:Gin(HTTP 路由)、Viper(读取 TOML 配置)、godotenv(加载
`.env.local`)、sqlx + Squirrel(查询构造与执行)、pgx stdlib(PostgreSQL
驱动)。Goose 仅以独立 CLI(v3.29.0)手工执行迁移,Go 依赖尚未引入。
+75
View File
@@ -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,
})
}
+35
View File
@@ -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),
}
}
+59
View File
@@ -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)
}
}
+36
View File
@@ -0,0 +1,36 @@
version: "3.8"
services:
postgres:
image: abcfy2/zhparser:16-alpine
container_name: lab-postgres
restart: unless-stopped
environment:
POSTGRES_DB: lab
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${PG_PASSWORD}
volumes:
- ./data/postgres:/var/lib/postgresql/data
# 测试环境不用刻意限制内存,让 Docker 自然调度即可
networks:
- net-db # 只加入数据库专用网络
# 如果外部需要直连调试,可临时 expose 端口
ports:
- "127.0.0.1:5432:5432"
redis:
image: redis:7-alpine
container_name: lab-redis
restart: unless-stopped
# 测试环境保留持久化更省心,不用关闭
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- ./data/redis:/data
networks:
- net-db
ports:
- "127.0.0.1:6379:6379"
networks:
net-db:
external: true # 使用预先创建的外部网络
+105
View File
@@ -0,0 +1,105 @@
# Lyra 目标结构
日期:2026-10-17
本文记录已确认的目标目录结构与职责边界,并与当前真实实现同步。目录树是**目标结构**,并非全部已存在:标注“待新增”的文件尚未创建;标注“占位”的目录(`internal/postgres`、`internal/service`)仅含 `AGENTS.md`(记录职责与依赖约束),暂不构成 Go 包。当前已有 Go 代码的是 `cmd/lyra` 与 `internal/{api,config,handler,httpx,model,router}`。仓库内另有本地 `.env.local`(已被 gitignore、不入库)等文件,不属于目标结构,不在此展开。选型与讨论过程见 [plans/2026-10-16-lyra-architecture.md](plans/2026-10-16-lyra-architecture.md);handler/service 业务分包的调整记录见 [plans/2026-10-17-business-package-layout.md](plans/2026-10-17-business-package-layout.md)。
## 已确认技术栈
- 已引入并使用:Gin v1.12.0(HTTP 路由)、Viper v1.21.0(配置读取,TOML)、godotenv v1.5.1(启动时加载本地 `.env.local`)、sqlx v1.4.0 + Squirrel v1.5.4(查询构造与执行)、pgx v5.11.0 stdlib(PostgreSQL 驱动,由 sqlx 以 "pgx" 驱动名使用)。`go.sum` 已由 Go 工具生成。
- Goose:迁移 SQL 已就位,以独立 goose CLI(v3.29.0)执行,Go 依赖尚未引入。
- PostgreSQL 已在启动时连接;连接代码当前在入口 `main`,收拢到 `internal/postgres` 是目标结构。
## 当前进度
- 程序入口在 module 根目录以 `go run ./cmd/lyra` 启动,流程为:`godotenv.Load` 读取本地 `.env.local`(不覆盖已存在的进程环境变量)→ `config.Load` 读取相对当前工作目录的 `etc/lyra.toml`(Viper,`mapstructure` 标签)→ `gin.SetMode` → 以 `gin.Default()` 创建引擎并 `SetTrustedProxies(nil)` 关闭代理信任 → `sqlx.ConnectContext` 建立 PostgreSQL 连接(失败即退出,`defer db.Close()`)→ `buildDependencies`/`buildHandlers` 组装依赖 → `RegisterRoutes` → 监听配置的地址与端口。`.env.local` 与 `etc/lyra.toml` 均按当前工作目录解析,换目录执行会找不到。连接与查询当前未设置超时。
- 已注册 `GET /test`:`internal/router/router.go` 的 `RegisterRoutes` 将其指向入口注入的 `router.Handlers.TestHandler`(`gin.HandlerFunc`,由 `handler.NewTestHandler(...).Hello()` 构造)。处理器调用 `model.CategoryModel.FindAll` 查询 `public.categories`——当前固定 `sort >= 1`、按 `sort`/`id` 排序、`limit 10`——经显式转换为本轮新增的 `api.CategoryResponse` 后由 `httpx.OK` 输出;查询出错经 `httpx.Fail` 返回 500 与 `api.CodeInternalError`。该接口用于验证 handler→model→数据库链路,不是完整业务 API。
- `internal/model` 已落地:`Category` 结构(id/slug/title/sort)、`CategoryModel` 接口与 `defaultCategoryModel` 实现——Squirrel 构造 SQL(Dollar 占位符),sqlx `SelectContext` 执行与映射。
- 已定义公共响应契约:`api.Response`(success/data/error)、`api.ErrorInfo`(code/message/debug)与错误码类型 `api.ErrorCode` 及其默认文案 `Message()`;响应输出辅助 `httpx.OK`、`httpx.Fail` 已实现并被 handler 使用。
- 配置:`database.password` 绑定了环境变量 `PG_PASSWORD`(可经 `.env.local` 或进程环境提供),其余配置项暂不支持环境变量覆盖;没有校验与默认值。`Redis` 配置项已定义但暂无消费方。
- 测试:路由注册有 httptest 回归测试,覆盖 `GET /test` 注入替身处理器的调用,以及未知路径/不支持方法返回 404;handler 有基于假 `CategoryModel` 的替身测试,覆盖 DTO 转换、空结果输出 `data:[]`、模型出错返回 500 且不泄漏内部错误信息、请求 context 透传。两类测试均不监听实际端口、不连接数据库,因此不证明真实 SQL 可用——连接真实数据库的端到端验证本轮未做。
- 数据库迁移已就位:`migrations/00001_init.sql`(`set_updated_at()` 函数、users/categories/links 三张表、约束、注释、索引与触发器,建库内容与 [demo.sql](demo.sql) 一致),由手工 goose CLI(v3.29.0)执行,见 [../migrations/README.md](../migrations/README.md)。
- 尚未实现:业务 service 层与完整业务 API;连接收拢到 `internal/postgres`(当前连接代码在入口 `main`);Goose Go 依赖;配置校验与默认值;`ErrorInfo.Debug` 的填充与按环境开关;连接/查询超时。日志方案未定。
## 目录结构
```text
lyra/
├── cmd/lyra/
│ ├── main.go # 启动流程:加载 .env.local 与配置、连接数据库、组装依赖
│ └── dependency.go # Dependencies 与 handler 组装
├── internal/
│ ├── api/ # HTTP 请求/响应 DTO、错误码与响应契约(平铺,按资源分文件)
│ │ ├── response.go # Response/ErrorInfo 结构
│ │ ├── code.go # ErrorCode 与错误码常量、默认文案
│ │ ├── category.go # CategoryResponse 分类响应 DTO
│ │ ├── links.go # links 资源 DTO 示例(待新增)
│ │ └── settings.go # 待新增
│ ├── config/
│ │ └── config.go # 配置定义与加载
│ ├── postgres/ # 数据库连接的目标位置(占位:仅 AGENTS.md,连接暂在 cmd/lyra/main.go)
│ ├── router/
│ │ ├── router.go # 路由注册、项目路径前缀
│ │ └── router_test.go # 路由 httptest 回归测试
│ ├── handler/
│ │ ├── test.go # GET /test 处理:调用 model、显式 DTO 转换、响应输出
│ │ ├── test_test.go # handler 替身测试
│ │ ├── links.go # 参数绑定、DTO 转换、响应输出(待新增)
│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包)
│ ├── service/ # 业务规则、事务协调(占位:仅 AGENTS.md)
│ │ ├── links.go # 业务规则、事务协调(待新增)
│ │ ├── links_test.go # 测试与源文件同目录(待新增)
│ │ └── polaris/ # 按明确业务分包的示例(待新增,不预建空包)
│ ├── model/
│ │ ├── category.go # Category 结构、CategoryModel 接口与 Squirrel+sqlx 实现
│ │ ├── link.go # Link 数据结构定义(待新增)
│ │ ├── link_model.go # 数据访问对象:CRUD 与 SQL(待新增)
│ │ └── link_model_test.go # 查询测试(待新增)
│ └── httpx/
│ └── response.go # 公共响应输出辅助(OK/Fail)
├── etc/
│ └── lyra.toml # 运行配置(App/HTTP/Database/Redis)
├── migrations/ # Goose SQL 迁移文件(00001_init.sql 初始迁移 + README)
├── docs/
│ ├── plans/
│ └── architecture.md # 本文
├── README.md
├── go.mod
└── go.sum # 已生成
```
- 标注“待新增”的文件只是职责分布示例,随实现按需新增,不预建空文件或空目录;`internal/` 各目录均以 `AGENTS.md` 记录职责与依赖约束(树中未逐一画出)。`internal/postgres`、`internal/service` 目前仅有 `AGENTS.md`,为占位目录,不构成 Go 包。
- model 下的 `link*.go` 展示一个资源在各层的文件分布;分类资源已按此方式在 api/model 落地(`api/category.go`、`model/category.go`,handler 为链路验证用的 `test.go`),设置等资源按同样方式新增。首个迁移已落地为 `migrations/00001_init.sql`。
- `handler/polaris/`、`service/polaris/` 仅示意业务分包的形态:实际分包与否随业务边界明确时决定,路由、api、model 不照搬同样的分包。
## 组织原则
- handler、service 可以按明确业务分包(如 `polaris/`),仅在业务边界清晰时新增;服务级简单接口(如 `hello`)保留在各自根包内平铺,不强制分包。
- router、api、model 等其他层暂时平铺,不按业务项目建代码子目录;项目通过 `/api/polaris` 等路由前缀区分,包内文件按资源划分。
- 公共基础能力全局唯一:配置在 internal/config,连接在 internal/postgres,HTTP 辅助在 internal/httpx,不随业务项目复制或按项目再拆。
- 不保留 platform/modules 分类层;简单接口不强制走完整三层,也不按每张表机械复制层级。
- 不预建空业务包,分包随实际业务代码一起落地。
## 层间边界
- 真实业务的主调用链为 handler→service→model→PostgreSQL;简单接口可以不经过 service——当前 `GET /test` 即由 handler 直接调用 model(service 层尚未实现)。service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。
- 请求/响应 DTO 集中在 internal/api,与内部模型分开;即使初期字段相近,也不默认把数据库模型直接暴露为响应,接受必要的显式转换代码(分类列表已经 handler 的 `toCategoryResponses` 转为 `api.CategoryResponse`)。
- handler 引用 API DTO 完成绑定与转换;service/model 不依赖 Gin,也不依赖 internal/api DTO;api 包不反向引用 handler/service/model,避免传输契约与实现耦合或循环引用。
- internal/api 拥有响应契约与业务错误码(`Response`/`ErrorInfo`/`ErrorCode`);不依赖 Gin。
- internal/httpx 负责响应输出:`OK` 固定 HTTP 200;`Fail` 接收 HTTP 状态码与业务错误码,`message` 为空时回退错误码默认文案。HTTP 状态码表示传输/结果类别,`ErrorCode` 表示自定义业务错误,两者分开。
- internal/router 负责把 handler 挂到路由上:当前注册 `GET /test`,指向入口组装注入的 `Handlers.TestHandler`,后续按业务项目路径前缀(如 `/api/polaris`)扩展。
- 配置由入口 `main` 加载后组装并传递:`internal/config` 只负责配置定义与加载,运行配置来自 `etc/lyra.toml`;当前 `app.env` 直接传给 `gin.SetMode`(仅 `debug`/`release`/`test`),HTTP 地址端口供启动使用,数据库相关配置由入口 `main` 组装 DSN 并用于连接(`database.password` 可来自 `PG_PASSWORD`),`Redis` 配置暂无消费方。
- model 承载数据结构定义与数据访问:Squirrel/SQL 构造、sqlx 执行与结果映射,驱动为 pgx stdlib(已按此落地 `internal/model/category.go`);不是 ORM 自动持久化,也不要求每种类型机械建立一套结构。
- service/model 使用标准 context.Context,不接收 gin.Context。
## 尚未确定 / 待实现
以下均为计划中的推荐或待确认项,尚未实现,也未定为已决:
- 配置校验与默认值:尚未实现。环境变量覆盖当前仅 `database.password` 绑定 `PG_PASSWORD`,其余配置项不支持。
- 连接与查询超时:尚未设置,也未确定超时策略。
- `ErrorInfo.Debug` 的填充与按环境(dev)开关:尚未实现,目前仅有字段。
- 分页响应约定(如 data/meta 结构)与统一错误处理中间件:尚未引入,仅为历史讨论。
- Goose 迁移的部署执行入口已确定为手工 CLI(见 [../migrations/README.md](../migrations/README.md)),并发控制以“单操作者串行执行”为约定,数据库锁未引入,服务启动不自动迁移。
- PostgreSQL 按项目划分 schema 的做法(组织边界,不等于权限隔离)。
- 日志方案(候选标准库 slog)与中间件集成方式。
+126
View File
@@ -0,0 +1,126 @@
-- Polaris 导航站 PostgreSQL 新库初始化示例(非幂等,仅执行一次)。
BEGIN;
-- updated_at 维护函数
CREATE FUNCTION public.set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
-- 用户表
CREATE TABLE public.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account text NOT NULL UNIQUE,
password_hash text NOT NULL,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_account_not_empty CHECK (btrim(account) <> ''),
CONSTRAINT users_display_name_not_empty CHECK (btrim(display_name) <> '')
);
COMMENT ON TABLE public.users IS '后台管理用户表';
COMMENT ON COLUMN public.users.id IS '用户 ID';
COMMENT ON COLUMN public.users.account IS '登录账号,当前大小写敏感';
COMMENT ON COLUMN public.users.password_hash IS '密码哈希';
COMMENT ON COLUMN public.users.display_name IS '展示昵称';
COMMENT ON COLUMN public.users.created_at IS '创建时间';
COMMENT ON COLUMN public.users.updated_at IS '更新时间,触发器自动刷新';
-- 分类表
CREATE TABLE public.categories (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text NOT NULL UNIQUE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
icon text NOT NULL,
sort integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT categories_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
CONSTRAINT categories_title_not_empty CHECK (btrim(title) <> '')
);
COMMENT ON TABLE public.categories IS '链接分类表';
COMMENT ON COLUMN public.categories.id IS '分类 ID,内部关联用';
COMMENT ON COLUMN public.categories.slug IS '页面地址(路由)标识';
COMMENT ON COLUMN public.categories.title IS '分类标题';
COMMENT ON COLUMN public.categories.description IS '分类描述';
COMMENT ON COLUMN public.categories.icon IS '分类图标标识';
COMMENT ON COLUMN public.categories.sort IS '分类排序,越小越靠前';
COMMENT ON COLUMN public.categories.created_at IS '创建时间';
COMMENT ON COLUMN public.categories.updated_at IS '更新时间,触发器自动刷新';
-- 链接表
CREATE TABLE public.links (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category_id bigint NOT NULL,
title text NOT NULL,
description text NOT NULL DEFAULT '',
url text NOT NULL,
icon text,
keywords text[] NOT NULL DEFAULT ARRAY[]::text[],
sort integer NOT NULL DEFAULT 0,
pinned boolean NOT NULL DEFAULT false,
pinned_sort integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT links_category_fk FOREIGN KEY (category_id) REFERENCES public.categories (id) ON DELETE RESTRICT,
CONSTRAINT links_title_not_empty CHECK (btrim(title) <> ''),
CONSTRAINT links_url_not_empty CHECK (btrim(url) <> ''),
CONSTRAINT links_pinned_sort_consistent CHECK (pinned = (pinned_sort IS NOT NULL)),
CONSTRAINT links_keywords_one_dimension CHECK (cardinality(keywords) = 0 OR array_ndims(keywords) = 1),
CONSTRAINT links_keywords_no_null_elements CHECK (array_position(keywords, NULL) IS NULL)
);
COMMENT ON TABLE public.links IS '链接表,归属分类';
COMMENT ON COLUMN public.links.id IS '链接 ID';
COMMENT ON COLUMN public.links.category_id IS '所属分类 ID,删分类前需先处理链接';
COMMENT ON COLUMN public.links.title IS '链接标题';
COMMENT ON COLUMN public.links.description IS '链接描述';
COMMENT ON COLUMN public.links.url IS '链接地址,应用层校验,去尾斜杠后全局唯一';
COMMENT ON COLUMN public.links.icon IS '链接图标,可空,前端回退';
COMMENT ON COLUMN public.links.keywords IS '搜索关键词数组,供本地搜索匹配';
COMMENT ON COLUMN public.links.sort IS '分类内排序,越小越靠前';
COMMENT ON COLUMN public.links.pinned IS '首页快捷入口(手动固定),非点击排行';
COMMENT ON COLUMN public.links.pinned_sort IS '首页快捷入口顺序,越小越靠前';
COMMENT ON COLUMN public.links.created_at IS '创建时间';
COMMENT ON COLUMN public.links.updated_at IS '更新时间,触发器自动刷新';
-- 索引
CREATE INDEX categories_order_idx
ON public.categories (sort, id);
CREATE INDEX links_category_order_idx
ON public.links (category_id, sort, id);
CREATE INDEX links_pinned_order_idx
ON public.links (pinned_sort, id)
WHERE pinned;
CREATE UNIQUE INDEX links_normalized_url_unique
ON public.links (rtrim(url, '/'));
-- 触发器
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON public.users
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER categories_set_updated_at
BEFORE UPDATE ON public.categories
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER links_set_updated_at
BEFORE UPDATE ON public.links
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
COMMIT;
+179
View File
@@ -0,0 +1,179 @@
# Goose 迁移操作手册
Lyra 数据库迁移的**唯一完整操作手册**。`migrations/` 目录是数据库结构的
唯一正式执行来源;[demo.sql](demo.sql) 仅保留作参考,不要再手工执行它。
迁移目录本身的说明见 [migrations/README.md](../migrations/README.md)。
## 目录与前提
- 所有 goose 命令都在 Lyra module 根目录(`migrations/` 的上一级)执行:
```bash
cd go/projects/lyra # 从仓库根出发
```
- 目标数据库**已创建**:迁移不负责建库,`lab` 库需先存在。
- 首次迁移要求目标对象**尚不存在**:如果之前手工执行过 demo.sql 建过
同名对象,`up` 会报 `already exists`,先按故障表评估处理。
## 安装 goose CLI(固定 v3.29.0)
goose 只作为独立命令行工具使用(Go 应用尚未引入 goose 依赖):
```bash
go install -tags='no_clickhouse no_libsql no_mssql no_mysql no_sqlite3 no_vertica no_ydb' github.com/pressly/goose/v3/cmd/goose@v3.29.0
goose -version # 应输出 v3.29.0
```
- 固定 `@v3.29.0`,保证各机器行为一致。
- `-tags=...` 是可选的 PostgreSQL 专用精简构建(`no_*` 标签已在 goose
v3.29.0 源码中核实);不追求精简可省略。
- 二进制装到 `$(go env GOPATH)/bin`(设了 `GOBIN` 则装到 `GOBIN`)。
`goose -version` 提示找不到命令时,把该目录加入 `PATH`:
```bash
export PATH="$PATH:$(go env GOPATH)/bin" # 可写入 ~/.bashrc
```
## 配置连接:.env.local
goose **不读取**应用的 `etc/lyra.toml`,也不会只凭 `PG_PASSWORD` 之类的
应用环境变量自动拼出连接串——它只认自己环境变量里的 `GOOSE_DBSTRING`。
在 module 根创建 `.env.local` 并追加以下内容(**全部是占位值**,换成实际
信息;`lab` 库需已创建):
```bash
GOOSE_DRIVER=postgres
GOOSE_MIGRATION_DIR=./migrations
GOOSE_DBSTRING="postgres://example_user:URL_ENCODED_PASSWORD@127.0.0.1:5432/lab?sslmode=disable"
```
要点:
- `GOOSE_DRIVER=postgres` 是 **goose 的驱动名**;Go 应用内用的是 pgx
(驱动名 `pgx`),两边名字不同但都连 PostgreSQL,不要混填。
- 本仓库 `.gitignore` 已忽略 `.env*`,`.env.local` 不会提交;建议再执行
`chmod 600 .env.local` 收紧文件权限。
- `127.0.0.1` 与端口必须换成**实际可达**的地址和端口(远程/容器按实际
映射填写)。
- `sslmode=disable` 仅适用于本地确认无 TLS 要求的场景;远程环境按部署的
TLS 要求调整该参数。
- 本手册所有 goose 命令统一用 `-env=.env.local` 显式加载该文件:goose
默认读的是 `.env`,`-env=.env.local` 既明确来源、又避免误用其他本地
配置。连接信息配置一次即可,无需每次交互输入密码。
- 系统环境里已有的同名变量(如曾 `export` 过 `GOOSE_DBSTRING`)可能优先
于 `.env.local` 生效:改了 `.env.local` 仍行为异常时,先查是否存在同名
环境变量——只列变量名、不打印值,避免泄露连接串:
```bash
env | cut -d= -f1 | grep -i '^GOOSE'
```
## 连接串特殊字符:URL 编码
`GOOSE_DBSTRING` 是 URL 格式:**用户名、密码**中的特殊字符必须逐个做
百分号编码,否则主机名/路径解析会错位。
- 对用户名和密码分别编码,**不要把整串 URL 一起编码**,也不要为迁就 DSN
去修改数据库里的实际密码。避免对已经编码的内容重复编码。
- 不要把真实密码交给在线网站转换;按下表手工替换即可。
| 字符 | 编码 | 字符 | 编码 |
|---|---|---|---|
| `@` | `%40` | `:` | `%3A` |
| `#` | `%23` | `/` | `%2F` |
| `%` | `%25` | `?` | `%3F` |
示例:密码 `demo#pass`(演示值)在 DSN 中写成 `demo%23pass`。
## 常用命令
```bash
# 离线检查迁移文件能否被 goose 解析(注解与语句拆分);
# 不连接数据库,也不验证 SQL 在 PostgreSQL 上的语义
goose -env=.env.local validate
# goose 工具自身版本(与数据库里的迁移版本是两回事)
goose -version
# 连接数据库:查看迁移状态 / 当前版本
goose -env=.env.local status
goose -env=.env.local version
# 执行迁移(连接数据库)
goose -env=.env.local up
```
注意:v3.29.0 的 `validate` **不接受位置路径参数**,迁移目录只由
`GOOSE_MIGRATION_DIR`(`.env.local` 已设 `./migrations`)或 `-dir` 标志
决定。`validate` 通过只证明文件能解析,不证明连接正常或 SQL 语义正确。
## 首次初始化
前提:`lab` 库已创建,且 00001 中的对象(函数、三张表、索引、触发器)
都不存在——**不要**先手工执行 demo.sql 再 `up`。
```bash
goose -env=.env.local validate # 离线确认文件可解析
goose -env=.env.local status # 显示 00001 Pending:连接成功,该迁移尚未应用
goose -env=.env.local up # 执行 00001,并在 goose_db_version 中记录版本:
# set_updated_at() 函数、users/categories/links 三张表、
# 约束、注释、索引与触发器
goose -env=.env.local status # 00001 显示应用时间
goose -env=.env.local version # 显示 version 1
```
- `up` 可重复执行:已应用版本按 `goose_db_version` 自动跳过。
- **已执行过的迁移文件不再修改**;要变更结构就新增迁移文件(见下节)。
## 日常新增迁移
```bash
goose -env=.env.local -s create add_example_column sql
```
`-s` 表示顺序编号:在现有 00001 基础上生成
`migrations/00002_add_example_column.sql`(实际编号按目录中现有最大版本
延续)。例如给 users 表加一列,编辑生成的文件:
```sql
-- +goose Up
ALTER TABLE public.users
ADD COLUMN avatar_url text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE public.users
DROP COLUMN avatar_url;
```
然后按顺序执行:
```bash
goose -env=.env.local validate
goose -env=.env.local status
goose -env=.env.local up
goose -env=.env.local status
```
Up/Down 成对编写,保证迁移可回滚。
## 回滚警告
```bash
goose -env=.env.local down # ⚠ 危险操作,见下
```
- `down` 只回滚**最近一个**已应用迁移的 Down 部分,**不是**清库或重建的
通用命令。
- **回滚 00001 会删除 `links`/`categories`/`users` 三张表及表中全部数据。**
- 执行任何 `down` 之前:阅读对应迁移 SQL、评估数据损失、备份数据库。
不建议随手“试一下” `down`,也不要把它列入日常维护流程。
## 故障速查
| 报错特征 | 常见原因与处理 |
|---|---|
| `hostname resolving error`(主机名里混入非法字符) | DBSTRING 格式或 URL 编码不对:检查用户名/密码特殊字符是否已按上表编码、`://` 与 `@` `:` 分隔符是否完整 |
| `password authentication failed` | 用户名或密码不对:核对 `.env.local`(编码后)的值;再排查系统环境同名变量是否优先生效(只查变量名,不打印值) |
| `connection refused` / `i/o timeout` | 主机端口不可达:确认 PostgreSQL 实际监听地址、端口、容器端口映射与防火墙 |
| `already exists`(对象已存在) | 目标对象曾被手工创建(如执行过 demo.sql):先评估现有对象与迁移的差异再决定处理方式;**不要直接删数据**,也不要手工往 `goose_db_version` 插版本记录伪造状态 |
@@ -0,0 +1,123 @@
# Lyra 技术选型与架构讨论记录
日期:2026-10-16
状态:HTTP 路由、数据访问、数据库迁移工具与目标目录结构已确认(结构见 [architecture.md](../architecture.md));本文档方案已获文档层面批准,代码实施尚未批准。
## 已确认
- Lyra 是个人 Go 后端,先承接 Polaris,未来承接其他项目接口。
- Polaris 采用一人管理、匿名公开浏览的模式。
- 已有服务器和 PostgreSQL;具体部署与连接配置尚未核实。
- 数据访问选择 Squirrel + sqlx,PostgreSQL 驱动使用 pgx stdlib。
- HTTP 路由选择 Gin;用户无明显接口风格偏好,结合文档与开发便利性作出选择。
- 数据库迁移工具选择 Goose。
- 本次确认仅涉及选型,不代表批准搭建代码或接入真实数据库。
## 数据访问约定
- 固定查询直接写 SQL,动态查询使用 Squirrel,统一由 sqlx 执行。
- Squirrel 使用 Dollar 占位符,普通值使用参数绑定,动态标识符采用服务端白名单。
- 使用带 Context 的执行方法;需要原子性的业务操作使用事务。
- 显式声明查询字段,处理可空值和 JOIN 列名冲突。
- 不同时引入 sqlc 或 golang-migrate。
- 已讨论的取舍:无代码生成步骤,但查询与结果映射主要在运行时校验;上游维护低频。
## 当前实现状态
- Lyra 仅有 go.mod 与 docs/ 下的文档,尚无接口、数据库或业务代码。
- Polaris 的 services 仍同步读取本地 JSON,未接入 Lyra。
- 未进行数据库连接或数据库结构修改。
## 架构建议(尚未定案)
- 一个 Go 服务;根据用户不希望按 Polaris 建立独立项目目录的偏好,调整为按技术职责组织代码,项目在 HTTP 路由上使用不同前缀。
- 各项目使用独立 HTTP 前缀,共享配置、日志、连接池等基础设施。
- PostgreSQL 可按项目划分 schema;这是组织边界,不等于权限隔离。
- 不提前建设微服务、统一用户中心或通用 CRUD 框架。
- 日志候选为标准库 slog,具体中间件与日志集成方式另行确认。
## 目录结构与边界(已确认)
2026-10-16 用户确认以下目标组织方式;同日确认将数据访问职责并入 model,不再设独立 internal/store 目录。完整结构记录见 [architecture.md](../architecture.md)。确认的是结构与职责划分,不代表立即创建所有文件或占位目录,代码实施仍需另行批准。
```text
lyra/
├── cmd/lyra/main.go
├── internal/
│ ├── api/
│ │ ├── links.go
│ │ ├── categories.go
│ │ └── settings.go
│ ├── config/
│ │ └── config.go
│ ├── postgres/
│ │ └── postgres.go
│ ├── router/
│ │ └── router.go
│ ├── handler/
│ │ └── links.go
│ ├── service/
│ │ ├── links.go
│ │ └── links_test.go
│ ├── model/
│ │ ├── link.go
│ │ ├── link_model.go
│ │ └── link_model_test.go
│ └── httpx/
│ └── response.go
├── migrations/
│ └── 00001_polaris_create_tables.sql
├── docs/plans/
├── README.md
├── go.mod
└── go.sum
```
- cmd/lyra 负责启动、依赖组装、调用路由注册和退出清理,不放业务规则。
- 用户提出统一管理接口请求与响应;推荐 internal/api,只放 HTTP 请求与响应 DTO 及其字段标签,按资源组织文件,不放 handler、业务逻辑或数据库查询。
- internal/router 注册路由与项目路径前缀;handler 处理参数绑定、DTO 转换与响应输出;service 管理业务规则;model 承载数据结构定义与数据访问(Squirrel/SQL 构造、sqlx 执行与结果映射),驱动为 pgx stdlib。测试与被测文件同目录。
- 树中 handler/service/model 仅列出链接资源示例;model/link.go 定义 Link 数据结构,model/link_model.go 定义数据访问与 SQL,model/link_model_test.go 测试查询,均为示例,实际文件随实现新增,不创建空占位目录或文件。
- handler 引用 API DTO 并转换为业务输入或响应;service/model 不依赖 API DTO 包,API DTO 包不引用 handler/service/model,避免传输契约和实现相互耦合或循环引用。
- 请求、响应与数据库模型职责分开,即使初期字段相近,也不默认直接把数据库模型暴露为响应;接受必要的显式转换代码。
- 主调用链为 handler→service→model→PostgreSQL;service 负责业务规则及需要的事务协调,model 支持同一事务内的数据库操作,不将跨多次写入各自独立提交当成原子业务。
- 上述文件是职责划分建议,不要求简单接口机械地走完整三层,也不按每张表或每种类型机械复制一套层级与文件。
- HTTP 层可以依赖 Gin;service 和 model 使用标准 context.Context,不接收 gin.Context。
- 移除 platform 和 modules 分类层;配置、连接和 HTTP 辅助直接位于 internal/config、internal/postgres、internal/httpx,减少无必要的路径深度。httpx 只在需要统一响应输出时实现,不提前抽象用户、设置、通用 CRUD 等业务。
- go.sum 在引入依赖后由 Go 工具生成;测试文件随实际功能编写,不创建空占位文件。
- 新项目可以在路由上增加独立前缀并复用上述技术层目录,不强制增加项目目录。不同领域的同名类型需要明确命名;文件名本身不提供 Go 包级符号隔离。
- 路由前缀只区分 HTTP 入口,不自动隔离代码依赖、数据库或权限;业务所有权与鉴权仍需显式设计,不能因路径不同就假定隔离成立。
- PostgreSQL 按项目划分 schema 仍为建议,尚未确认;schema 不自动提供权限隔离。
### 外部示例参考
- 参考 https://github.com/vespeng/gin-pathway ,读取 main 分支 commit 67582d1。
- 借鉴其薄启动入口、显式依赖组装与统一错误处理思路;曾考虑其按业务分组,后按用户偏好改为技术分层,不直接复制具体代码或全部依赖。
- 该示例没有独立 API DTO 目录,部分数据库模型直接作为响应,本方案不沿用这一点。
- 不沿用其启动时同步表结构的机制;Lyra 仍使用已确认的 Goose 管理迁移,具体执行方式待确认。
- 不预建空 pkg、scripts、tests 等目录,不引入当前无需求的 Redis、ORM 或全局数据库单例。
- internal/api 与根 api 都是可行布局,小写 api 是命名习惯;推荐 internal/api 是因为当前 DTO 仅供 Lyra 内部使用,而非 Go 语言强制要求。
- 另参考 https://github.com/llmons/go-dianping ,读取 main 分支 commit cc7fbcf9:借鉴其独立 API 响应定义、技术分层与构造函数注入依赖;不照搬 ORM 生成层及缓存设施。
- go-dianping 的部分请求与响应复用数据库模型,Lyra 的独立请求/响应 DTO 方案不沿用这一做法。
## 推荐迁移执行方式(待用户确认)
- 起步采用 Goose CLI,在独立部署步骤中显式执行 SQL 迁移;Web 服务启动时不自动迁移。
- 迁移成功后再启动或更新服务;迁移失败则停止后续发布,检查实际状态。
- 同一个 Lyra 数据库初期使用一个 migrations 目录和统一版本序列,文件名标注所属模块;不让不同模块共用版本表却各自维护重复版本号。
- 部署流程保证同一数据库的迁移串行执行;如以后存在多个迁移入口或并发发布,再选择带数据库锁的迁移执行入口,不假定事务本身能防止并发迁移。
- 优先 SQL 迁移,非事务 DDL 单独设计;生产回退需要评估数据影响,不自动执行 Down。
- 表结构迁移与 Polaris JSON 初次导入分开设计,不能把 Goose 等同于业务数据导入工具。
## 后续讨论顺序
1. 确认 Goose 的执行入口、串行执行或数据库锁方案(技术分层与路由前缀组织已确认,见 [architecture.md](../architecture.md))。
2. 确认首阶段范围:建议先做 JSON 导入、只读 API 和 Polaris 接入;管理写入另行确定认证方式。
3. 编写实施与验证方案,获得明确批准后再实施。
## 后续实施方案必须覆盖的验证
- 使用隔离的 PostgreSQL 测试环境验证迁移、核心查询与事务,不默认操作现有真实数据。
- 核对导入前后的分类、链接、排序和置顶语义。
- 接入 Polaris 时验证 service 异步化、动态分类路由、缓存与后端故障处理。
- 管理写接口上线前验证鉴权,不能以隐藏入口代替权限控制。
@@ -0,0 +1,42 @@
# 架构审查与提交前收尾
日期:2026-10-17
状态:审查及已批准修正完成,父会话已复查代码与文档;等待更新暂存区及确认提交信息。
## 当前结论
- 当前实际调用链为 main → buildDependencies → buildHandlers → router 的 GET /test → TestHandler.Hello → CategoryModel.FindAll → PostgreSQL。
- 显式注入、入口容器边界、model 接口和路由依赖方向成立;当前单条只读查询不需要额外 service 或事务框架。
- 审查发现的 DTO 边界与当前状态文档问题已修正:handler 显式转换为 api.CategoryResponse,README 与架构文档已同步。
- 超时、DSN 编码、优雅关闭和查询语义命名列为后续增强,不扩入本轮修正。
## 已批准的最小方案
1. 增加分类响应 DTO,以明确 JSON 字段,由现有 TestHandler 显式转换;空列表保持 [],不改变 SQL。
2. 用假的 CategoryModel 补 handler 测试,覆盖成功、空列表、失败和 context 传递,不连接数据库。
3. 更新 README.md 和 docs/architecture.md 的当前实现说明,历史计划保留为历史。
4. 最终执行格式检查、go test ./... -count=1、go build ./...、go vet ./...,父会话审阅最终差异。
5. 依 git-commit 流程确认暂存范围;任何重新暂存后重新检查全部索引文件的完整内容,再展示提交信息并等待明确确认。
## 实施分工与范围
- 代码 lane(复用 fixer):仅写 internal/api/category.go、internal/handler/test.go、internal/handler/test_test.go,负责 DTO 转换与成功/空结果/失败/context 测试及 Go 验证。
- 文档 lane(独立 fixer):仅写 README.md、docs/architecture.md,按当前磁盘源代码更新现状;不得声称真实数据库查询已验证。
- 父会话维护本计划、整合终态并检查实际差异;不修改 SQL、路由名称、连接逻辑、依赖版本或其他历史计划。
- 两个写入范围互不重叠;全部终态后确认验证证据仍适用,再进入重新暂存与完整索引安全检查。
## 已有验证及边界
- 当前未修正版本的 go test ./... -count=1、go build ./...、go vet ./...、gofmt -l . 全部通过。
- 当前路由测试不连接数据库,仅验证路由分发;真实 handler 与 SQL 查询尚未验证。
- 当前暂存的十个非删除文件完整内容已检查,未发现新增疑似真实敏感值或意外生成物;未暂存新的修正内容。
- 审查所有者:oracle 静态审查、fixer 独立验证、父会话负责取舍及提交安全检查。
## 修正后的证据
- 新增分类响应 DTO 和 handler 替身测试;成功映射、空切片与 nil 结果输出 []、失败返回通用 500、请求 context 透传均已验证。
- 代码 lane 在最终 Go 代码上执行 go test ./internal/handler -v -count=1、go test ./... -count=1、go build ./...、go vet ./... 均通过,新增及修改 Go 文件的格式检查通过。
- 文档 lane 仅修改 README.md 与 docs/architecture.md;父会话已读取 DTO、handler、测试与两份文档并复查,未发现新的阻断问题。
- 不重复运行未发生变化的 Go 代码验证;真实 SQL 与数据库端到端路径仍未验证。
- 更新暂存区后必须重新检查全部暂存文件的完整版本,之前索引安全检查不自动适用于新内容。
@@ -0,0 +1,30 @@
# Handler 与 Service 的业务分包调整
## 已确认范围
- handler、service 可以按明确业务分包;服务级简单接口保留在根包。
- 将 `/hello` 从 `internal/handler/lyra` 移至 `internal/handler`,同步路由引用;入口路由文件使用 `router.go`。
- router、api、model 等其他层暂时保持平铺,公共基础能力不随业务重复拆分。
- 不预建空业务包,不改变 `/hello` 路径、响应契约或配置环境语义,不顺带改变启动错误处理。
- 同步架构文档、README、相关目录约定与过时的入口注释;历史方案保留为历史记录。
## 工作分工与顺序
1. 代码执行 lane:负责 handler 移动、router 引用和文件名、入口过时注释,以及路由回归测试。
2. 文档执行 lane:并行负责 architecture.md、README.md、必要的目录 AGENTS.md,解释业务分包与当前实现状态。
3. 父代理:整合两条 lane 的结果,审查最终差异并登记验证结论。
## 验证路径与归属
- 代码 lane:新增或利用 `httptest` 路由测试,不监听实际端口、不连接数据库,确认 `GET /hello` 返回 HTTP 200 且维持现有响应契约。
- 父代理:在 Lyra module 根执行 `go test ./...`、`go vet ./...`,确认移动后的包引用与编译正确;若执行 lane 已完成同等检查且最终代码未变则复用证据。
- 文档 lane:核对当前配置文件名、HTTP 启动状态与目录组织说明,不把未来规划写成已实现能力。
- 父代理:检查最终 diff、旧包路径残留和敏感信息风险,保留用户既有未提交改动。
## 状态
- 代码与文档 lane 均已完成,最终差异已审查。
- `/hello` 已移至 handler 根包,路由引用已更新;未创建空业务包。
- 架构文档、README 与 handler/service/router 的目录约定已同步。
- 验证通过:`go test ./...`、`go vet ./...`、`git diff --check`;路由测试确认 HTTP 200 与原有 JSON 响应一致。
- 验证未监听实际端口、未连接数据库;保留工作区既有配置与其他用户改动。
@@ -0,0 +1,71 @@
# Goose 初始迁移
日期:2026-10-17
## 目标与范围
用户已同意创建首个 Goose 迁移并提供后续自行管理的说明。以当前工作区的
`docs/demo.sql` 为来源,保留已有未提交改动,不修改其表结构设计。
本次不连接或修改用户数据库,不把迁移嵌入服务启动,也不修改 Go 应用依赖。
## 实施步骤
1. 核对 Goose 官方 SQL 注解、事务行为及 CLI 命令。
2. 新建 `migrations/00001_init.sql`,Up 保留示例中的函数、三张表、约束、
注释、索引与触发器;去掉外层事务,以 StatementBegin/StatementEnd
包裹 PL/pgSQL 函数。Down 按依赖逆序删除表,最后删除函数,不使用 CASCADE。
3. 更新 `migrations/README.md`,说明 CLI 使用、连接信息的安全提供、首次
初始化、状态检查、后续迁移及回滚风险。采用固定 CLI 版本 v3.29.0,命令在 Lyra
module 根执行;数据库必须已创建,首个迁移要求目标对象尚不存在。
4. 同步项目 README 与架构文档的迁移进度。`docs/demo.sql` 保留为参考,
正式迁移以 migrations 为准,避免双重执行。
## 工作分工与验证
- librarian:只读核对官方规则与 CLI 能力。
- fixer:仅负责首个 SQL 迁移及上述三个说明文档;不修改其他代码或配置,
不运行任何连接数据库的命令。
- 父会话:维护计划、检查最终 diff 与文案,确认无意外改动及敏感信息。
- fixer 验证:静态对比 Up 与当前 demo 的 SQL 内容,检查 Down 依赖顺序;
使用 v3.29.0 的 `goose -env=none -dir ./migrations validate` 检查文件解析,
此命令不连接数据库。校验用工具仅构建到 `/tmp/opencode`,不修改应用依赖。
- 不为仅 SQL/文档变更重复运行无关 Go HTTP 测试。不宣称数据库集成验证通过;
未在 PostgreSQL 实际执行的限制在交付时明确说明。
## 已核对的规则
- 官方 v3.29.0:Up/Down 默认使用事务;函数体需 StatementBegin/StatementEnd。
- 后续新建迁移使用 `goose -s create <名称> sql`,延续顺序编号。
- CLI 默认读取 `.env`;说明中的命令统一加 `-env=none`,显式使用环境变量,
避免误用本地配置。`-version` 为工具版本,`version` 为数据库版本。
- 官方来源:https://pressly.github.io/goose/documentation/annotations/
与 https://github.com/pressly/goose/releases/tag/v3.29.0。
## 实施与验收记录
- 已完成初始迁移与使用文档;父会话已检查 SQL 与相关文档差异。
- 实测修正:v3.29.0 的 validate 不使用位置参数,必须通过 `-dir` 或
`GOOSE_MIGRATION_DIR` 选择目录;已按实际行为编写说明。
- fixer 已构建 v3.29.0 PostgreSQL 专用 CLI 至 `/tmp/opencode/lyra-goose-bin`,
离线解析得到 Up=40、Down=4;与 demo 的 Up 内容逐行对比完全一致。
- 后续编号 00002 已在临时目录验证;没有修改应用依赖或连接数据库。
- 最终文案校正:目标库为空是执行前提,不当作已验证事实;普通 down 是否
丢数据取决于迁移内容,00001 的 Down 则确定会删除三张表及其数据。
- `git diff --check` 通过。PostgreSQL 上的实际执行尚未验证。
## 后续:整理日常操作手册
用户已要求将实际操作步骤写入 Lyra docs。以下为新增文档任务,不改已有迁移。
1. 新建 `docs/goose.md`,作为唯一完整操作手册:固定版本安装、项目根目录、
`.env.local` 配置、密码 URL 编码、validate/status/up/version、后续新增迁移、
回滚警告与常见故障。所有数据库操作命令使用 `-env=.env.local`。
2. 将 `migrations/README.md` 收敛为迁移目录说明和手册链接,移除旧的交互输入
密码流程,避免两份操作步骤不一致;项目 README 增加手册入口。
3. 仅使用演示占位值,不读取 .env.local、真实连接配置或既有错误中的疑似凭据。
不运行服务、不连接数据库、不更改 SQL、依赖和配置,也不暂存用户改动。
4. fixer 负责上述三个文档和静态链接/命令一致性检查;父会话负责最终差异与
文案审查。已有 CLI 验证证据复用,不为文档改动重复构建或执行数据库命令。
说明:用户已确认 status 成功并显示 00001 Pending;这是连接及迁移发现成功的
证据,不足以宣称 Up 或 Down 已完成数据库集成验证。
@@ -0,0 +1,32 @@
# PostgreSQL 启动连接检查
日期:2026-10-17
## 已批准目标
用户要求在 main.go 中通过 sqlx 查询数据库信息,检查尚无业务表的数据库连接。
查询只读取 current_database()、current_user、version(),不依赖业务表,不执行 DDL。
## 实施范围
1. 保留当前工作区所有用户改动,仅在 cmd/lyra/main.go 中注册项目已选定的
pgx stdlib 驱动,使用带超时的连接及 GetContext 查询,输出数据库信息。
2. 通过 go.mod/go.sum 加入 pgx/v5/stdlib 所需依赖;etc/lyra.toml 仅将
driver 的 postgres 改为 pgx,与驱动注册名保持一致,不读出或改动敏感配置。
3. 补充数据库连接关闭;不重构配置加载、不实现 dotenv、不涉及 Goose 或业务表。
## 分工与证据路径
- fixer:写入范围限定 main.go、go.mod、go.sum、TOML 的 driver 一行;
首次修改检查缩进和换行,保留已有修改。负责 gofmt、go test ./... 与构建验证。
- 父会话:检查新增查询、超时、驱动名与范围,检查无明文凭据及意外文件改动。
- 不打印配置文件完整内容或完整 DSN;不读取 .env.local 或 compose 中的敏感值。
- 不运行服务、不连接真实数据库;编译/测试只证明查询代码可编译和现有测试通过,
实际连接是否成功由用户本地启动后观察查询输出确认。
## 验收
- 已加入 pgx/v5 v5.11.0 驱动、五秒超时连接与系统信息查询,保留 HTTP 通路。
- fixer 执行 `go build ./...`、`go test ./...` 均通过,未连接真实数据库。
- 最终安全校正:连接与查询失败时只记录固定的阶段提示,不直接输出驱动原始
错误,避免连接串解析错误意外携带敏感配置。
@@ -0,0 +1,19 @@
# 重写当前路由回归测试
日期:2026-10-17
## 已批准范围
用户要求重写 router_test。当前路由为 GET /test,注册入口接收 router.Handlers,其中 TestHandler 为 gin.HandlerFunc。
- 仅新增或重写 internal/router/router_test.go,不修改生产代码、依赖或用户已调整的模型字段。
- 测试使用 package router_test、gin.New、httptest 和注入的替身 handler,不启动端口、不访问数据库。
- 覆盖 GET /test 调用注入处理器,并保留处理器自定义的状态码和响应体。
- 覆盖未知路径和不支持的方法均不调用处理器;使用 gin.New 默认设置,两者预期 404。
- 每个用例创建独立引擎;不使用 t.Parallel,避免 Gin 全局模式设置竞争。
## 验证所有者与证据
- fixer:只写测试文件,gofmt,执行 go test ./internal/router -v -count=1 与 go test ./...。
- 父会话:检查最终差异、替身调用次数断言、当前路由名以及无数据库依赖。
- 本轮证据仅覆盖路由注册与分发,不覆盖真实 TestHandler 或数据库查询。
@@ -0,0 +1,66 @@
# Squirrel + sqlx 最小查询练习
日期:2026-10-17
## 范围与状态
- 前期由用户自行实现;2026-10-17 用户明确批准代改依赖组装示例。本轮仍不启动服务、不连接数据库。
- 使用隔离的本地 PostgreSQL 测试库,不复用真实或共享环境。
- 当前范围调整:用户先在已有 /hello handler 中手动验证查询;下方 categories 集成测试步骤保留为后续练习。
- 临时在 handler 中只读查询 current_database(),不依赖业务表;正式业务查询再迁至 model。
## 当前 Hello 实验
### 本轮已批准实施:集中依赖组装
- 以用户现有代码为准:Hello 已通过 CategoryModel.FindAll 查询分类,保留该 SQL、过滤条件和结果映射,不改成系统信息查询。
- 新增 cmd/lyra/dependencies.go,buildHandlers(db) 返回 router.Handlers,当前只装配 Hello(db)。
- router.go 定义仅包含 Hello gin.HandlerFunc 的 Handlers,RegisterRoutes 接收它,不再导入 handler 或 sqlx。
- main.go 仅把路由注册改为先 buildHandlers(db),再 RegisterRoutes;保留配置、连接和其他用户改动。
- Hello 保持 Hello(db) 闭包接口,在返回闭包前创建 CategoryModel,避免每个请求重新构造;修复先返回问候再返回列表的重复响应,错误分支返回 HTTP 500 后立即 return。
- 路由测试注入固定响应的替身 handler,验证 /hello 确实调用传入的处理器并维持现有响应契约,不触碰数据库。
- 写入范围:cmd/lyra/main.go、cmd/lyra/dependencies.go、internal/router/router.go、internal/router/router_test.go、internal/handler/hello.go;不改 model、依赖文件或敏感配置。
- fixer 负责首次编辑前确认缩进与换行、gofmt、go test ./... 和 go build ./...;父会话负责审阅最终差异、依赖方向和用户改动保留情况。
- 验证边界:httptest 证明路由注入生效,构建证明组装链编译通过;不宣称真实数据库查询已验证。
实施结果:上述五个 Go 文件已完成调整。fixer 执行 gofmt、go test ./...、go build ./...、go vet ./... 均通过;父会话已审阅实际差异与新增 dependencies.go,git diff --check 通过。未启动服务或连接数据库,真实查询及 Hello 成功/失败分支尚无运行时验证。用户已有 model/category.go 和依赖文件改动保持不变。
### 历史练习建议(由上方现状覆盖)
1. 沿用闭包:Hello(db *sqlx.DB) gin.HandlerFunc,捕获 main 已创建的连接池。
2. router.RegisterRoutes 接收 gin.HandlerFunc,不依赖数据库;main 调用 handler.Hello(db) 完成组装。
3. 每个请求从 Request.Context 派生五秒超时,使用 Squirrel 构造 SELECT current_database(),sqlx.GetContext 执行并映射字符串。
4. 失败使用现有 httpx.Fail 与通用错误码,不返回数据库原始错误;成功使用 httpx.OK 返回当前数据库名。
5. 路由测试注入返回固定响应的替身 handler,不连接数据库;保留现有路径、状态码和响应体断言。该测试不覆盖真实 Hello 查询逻辑。
6. 用户在隔离本地环境运行服务并请求 /hello 验证真实查询;实验不要对外暴露数据库信息。
## 后续 categories 练习顺序
### 依赖组装组织建议(讨论记录,未代改代码)
- 继续使用显式构造函数注入,不引入 Wire、全局连接或通用依赖容器。
- 组装代码可以放在 cmd/lyra/dependencies.go,仍属于 package main;main 只调用 buildHandlers(db) 并注册路由。
- router 可定义只包含 gin.HandlerFunc 的 Handlers 结构,RegisterRoutes 接收该结构,避免路由增加导致参数列表不断增长。
- 当前只需要 Hello 字段,其他路由随实际实现添加,不提前创建 model/service 等占位代码。
- 后续组装顺序为 db → model → 按需 service → handler;每层只接收实际需要的依赖,不传递整个应用容器。
- 该方式减少 main 的杂乱,但不消除新增组件时必须更新组装关系的工作;对象在启动时构造,请求复用连接池。
- Dependencies 位置建议:当前若确需该结构,直接定义在 cmd/lyra/dependencies.go,不为单个类型新建包。以后多个入口确实需要复用组装逻辑时,再迁移到 internal/app;该包作为组装上层可引用 model/service/handler/router,业务包不得反向引用 app。当前不创建新目录或依赖容器。
1. 在 module 根目录引入 github.com/Masterminds/squirrel。
2. 准备已执行现有迁移的隔离测试库;如需数据,用户单独准备至少一个分类,测试本身不写数据。
3. 测试从 LYRA_TEST_DATABASE_URL 读取连接串;缺失时跳过,不回退到开发配置。
4. 注册 pgx stdlib 驱动,创建五秒超时 Context,以 sqlx.ConnectContext 连接并清理连接。
5. 定义带 db 标签的局部结果结构,使用 Squirrel 显式声明列、可选 sort 条件、sort/id 排序、有限条数和 Dollar 占位符。
6. 调用 ToSql 获取 SQL 和 args,通过 db.SelectContext 执行并映射切片。
7. 先验证 SQL/args,再验证真实查询;通过后再提取正式 model 方法。
## 验证与注意事项
- Go 命令在 go/projects/lyra 执行。
- 查询构造测试应验证使用 $1 而非 ?,过滤值位于 args 中而非拼入 SQL。
- 集成测试验证目标库身份、查询无错误、字段映射、过滤与排序。
- 空表返回空切片属于成功,但不能证明非空数据映射和排序正确;需要已知数据才能完成这些验收。
- 环境变量缺失导致 SKIP 不算数据库查询已通过。
- 不记录 DSN、密码或可能包含凭据的连接错误原文。
- 完成后执行 go test ./internal/model -run TestCategoryQuery -v -count=1。
- 正式复用时使用 sqlx.SelectContext(ctx, executor, ...) 支持 *sqlx.DB 和 *sqlx.Tx;sqlx.ExtContext 接口本身没有 SelectContext 方法。
+23
View File
@@ -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
+63
View File
@@ -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
)
+159
View File
@@ -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=
+5
View File
@@ -0,0 +1,5 @@
# api 包
- 定义 HTTP 请求/响应 DTO,按资源分文件。
- DTO 与内部模型分开:即使初期字段相近,也不把数据库模型直接暴露为响应,由 handler 层完成显式转换。
- 不引用 handler、service、model,也不依赖 Gin,避免传输契约与实现耦合或循环引用。
@@ -0,0 +1,9 @@
package api
// CategoryResponse 分类列表响应 DTO,字段名显式约定小写 JSON 键。
type CategoryResponse struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Sort int `json:"sort"`
}
+21
View File
@@ -0,0 +1,21 @@
package api
// ErrCode 错误码,前3位数表示错误类型,后3位数表示具体错误
type ErrorCode int
const (
CodeInvalidArgument ErrorCode = 10001
CodeInternalError ErrorCode = 10002
CodeLinkNotFound ErrorCode = 10003
)
func (e ErrorCode) Message() string {
switch e {
case CodeInvalidArgument:
return "请求参数不正确"
case CodeLinkNotFound:
return "链接不存在"
default:
return "服务暂时不可用"
}
}
+13
View File
@@ -0,0 +1,13 @@
package api
type Response struct {
Success bool `json:"success"`
Data any `json:"data,omitempty"`
Error *ErrorInfo `json:"error,omitempty"`
}
type ErrorInfo struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Debug string `json:"debug,omitempty"`
}
+85
View File
@@ -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/`);服务级简单接口保留在根包平铺,不预建空业务包。
+69
View File
@@ -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()
}
}
+10
View File
@@ -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。
+132
View File
@@ -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})
}
}
+34
View File
@@ -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/`);服务级简单接口保留在根包平铺,不预建空业务包。
+17
View File
@@ -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
}
+134
View File
@@ -0,0 +1,134 @@
-- Lyra 初始迁移:与 docs/demo.sql 的建库内容保持一致(函数、三张表、约束、注释、索引、触发器)。
-- +goose Up
-- updated_at 维护函数
-- +goose StatementBegin
CREATE FUNCTION public.set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
-- +goose StatementEnd
-- 用户表
CREATE TABLE public.users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account text NOT NULL UNIQUE,
password_hash text NOT NULL,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_account_not_empty CHECK (btrim(account) <> ''),
CONSTRAINT users_display_name_not_empty CHECK (btrim(display_name) <> '')
);
COMMENT ON TABLE public.users IS '后台管理用户表';
COMMENT ON COLUMN public.users.id IS '用户 ID';
COMMENT ON COLUMN public.users.account IS '登录账号,当前大小写敏感';
COMMENT ON COLUMN public.users.password_hash IS '密码哈希';
COMMENT ON COLUMN public.users.display_name IS '展示昵称';
COMMENT ON COLUMN public.users.created_at IS '创建时间';
COMMENT ON COLUMN public.users.updated_at IS '更新时间,触发器自动刷新';
-- 分类表
CREATE TABLE public.categories (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text NOT NULL UNIQUE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
icon text NOT NULL,
sort integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT categories_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
CONSTRAINT categories_title_not_empty CHECK (btrim(title) <> '')
);
COMMENT ON TABLE public.categories IS '链接分类表';
COMMENT ON COLUMN public.categories.id IS '分类 ID,内部关联用';
COMMENT ON COLUMN public.categories.slug IS '页面地址(路由)标识';
COMMENT ON COLUMN public.categories.title IS '分类标题';
COMMENT ON COLUMN public.categories.description IS '分类描述';
COMMENT ON COLUMN public.categories.icon IS '分类图标标识';
COMMENT ON COLUMN public.categories.sort IS '分类排序,越小越靠前';
COMMENT ON COLUMN public.categories.created_at IS '创建时间';
COMMENT ON COLUMN public.categories.updated_at IS '更新时间,触发器自动刷新';
-- 链接表
CREATE TABLE public.links (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category_id bigint NOT NULL,
title text NOT NULL,
description text NOT NULL DEFAULT '',
url text NOT NULL,
icon text,
keywords text[] NOT NULL DEFAULT ARRAY[]::text[],
sort integer NOT NULL DEFAULT 0,
pinned boolean NOT NULL DEFAULT false,
pinned_sort integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT links_category_fk FOREIGN KEY (category_id) REFERENCES public.categories (id) ON DELETE RESTRICT,
CONSTRAINT links_title_not_empty CHECK (btrim(title) <> ''),
CONSTRAINT links_url_not_empty CHECK (btrim(url) <> ''),
CONSTRAINT links_pinned_sort_consistent CHECK (pinned = (pinned_sort IS NOT NULL)),
CONSTRAINT links_keywords_one_dimension CHECK (cardinality(keywords) = 0 OR array_ndims(keywords) = 1),
CONSTRAINT links_keywords_no_null_elements CHECK (array_position(keywords, NULL) IS NULL)
);
COMMENT ON TABLE public.links IS '链接表,归属分类';
COMMENT ON COLUMN public.links.id IS '链接 ID';
COMMENT ON COLUMN public.links.category_id IS '所属分类 ID,删分类前需先处理链接';
COMMENT ON COLUMN public.links.title IS '链接标题';
COMMENT ON COLUMN public.links.description IS '链接描述';
COMMENT ON COLUMN public.links.url IS '链接地址,应用层校验,去尾斜杠后全局唯一';
COMMENT ON COLUMN public.links.icon IS '链接图标,可空,前端回退';
COMMENT ON COLUMN public.links.keywords IS '搜索关键词数组,供本地搜索匹配';
COMMENT ON COLUMN public.links.sort IS '分类内排序,越小越靠前';
COMMENT ON COLUMN public.links.pinned IS '首页快捷入口(手动固定),非点击排行';
COMMENT ON COLUMN public.links.pinned_sort IS '首页快捷入口顺序,越小越靠前';
COMMENT ON COLUMN public.links.created_at IS '创建时间';
COMMENT ON COLUMN public.links.updated_at IS '更新时间,触发器自动刷新';
-- 索引
CREATE INDEX categories_order_idx
ON public.categories (sort, id);
CREATE INDEX links_category_order_idx
ON public.links (category_id, sort, id);
CREATE INDEX links_pinned_order_idx
ON public.links (pinned_sort, id)
WHERE pinned;
CREATE UNIQUE INDEX links_normalized_url_unique
ON public.links (rtrim(url, '/'));
-- 触发器
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON public.users
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER categories_set_updated_at
BEFORE UPDATE ON public.categories
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER links_set_updated_at
BEFORE UPDATE ON public.links
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- +goose Down
-- 按依赖逆序删除:链接表 → 分类表 → 用户表 → 函数。
-- 删表会一并移除表上的触发器与索引,故无需单独 DROP;
-- 不用 CASCADE / IF EXISTS,出现意外依赖时让删除直接报错。
DROP TABLE public.links;
DROP TABLE public.categories;
DROP TABLE public.users;
DROP FUNCTION public.set_updated_at();
+21
View File
@@ -0,0 +1,21 @@
# migrations
Lyra 的 [Goose](https://pressly.github.io/goose/) SQL 迁移目录,是数据库结构的**唯一正式执行来源**。
[docs/demo.sql](../docs/demo.sql) 仅保留作参考,不要再手工执行它,避免与迁移重复建表。
## 操作手册
安装 goose CLI、配置 `.env.local` 连接、首次初始化、日常新增迁移、回滚警告与
故障排查等全部操作步骤,见唯一完整手册:[docs/goose.md](../docs/goose.md)。
## 现有迁移
| 文件 | 内容 |
|---|---|
| `00001_init.sql` | 初始结构:`set_updated_at()` 函数、`users`/`categories`/`links` 三张表、约束、注释、索引与触发器,建库内容与 `docs/demo.sql` 一致 |
## 执行模型约定
- 迁移只作用于 `public` schema,沿用现有设计,暂不做 schema 划分。
- 单个操作者串行执行,不引入数据库锁等并发控制。
- 部署执行入口是手工 CLI,服务启动时**不会**自动执行迁移。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+303
View File
@@ -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())
}
// 补偿操作跟上面差不多,就是业务逻辑修改
```
+70
View File
@@ -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` |
| `[]` | 没有技能 |
| `["!*"]` | 拒绝所有技能 |
+70
View File
@@ -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` | |
+117
View File
@@ -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).
+24
View File
@@ -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连接,这时候又需要远程连接这个服务,这样的话就能和访问本地服务一样访问了
+84
View File
@@ -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**,程序从当初的下一条指令继续运行。
+45
View File
@@ -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` 传递
+18
View File
@@ -0,0 +1,18 @@
## 快速复制文件路径
### Shift + 右键菜单(通用)
1. 在文件资源管理器中,**按住 `Shift` 键不放**
2. 右键点击目标文件
3. 选择 **「复制为路径(A)」**
### 功能区按钮
1. 选中文件
2. 点击资源管理器顶部的 **「主页」** 选项卡
3. 点击 **「复制路径」** 按钮(剪贴板图标)
### 拖拽到终端
- 直接把文件**拖入** PowerShell 或 CMD 窗口,会自动粘贴完整路径。
+123
View File
@@ -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 不存在,检查扩展是否已安装并启用 |
+64
View File
@@ -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位身份证号 |
File diff suppressed because it is too large Load Diff
+225
View File
@@ -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、空亡:音信稀时,属土,为勾陈,凡谋事主一、五、七。
断辞曰:
空亡事不长,阴人小乘张,求财无有利,
行人有灾殃,失物寻不见,官事主刑伤,
病人逢暗鬼,乞解保安康。
#### **六、 使用原则与心法**
- **心诚意专**:占测时心态必须虔诚、专注,意念集中于所问之事。
- **一事一占**:同一件事在短期内不应反复占问,以第一次的结果为准。所谓“再三渎,渎则不告”。
- **动念即占**:当某个问题或念头强烈地出现在脑海中时,是最佳的占测时机。
- **趋吉避凶**:小六壬提供的是一种预警和参考。得到吉卦,应顺势而为;得到凶卦,则应反思、谨慎、规避,而非坐以待毙。它是一种积极的“行为指导”,而非消极的“宿命论”。
+21
View File
@@ -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 视为空字符串 |
+44
View File
@@ -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;
```
+60
View File
@@ -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]
}
```
+56
View File
@@ -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 |
+156
View File
@@ -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 为映射对象 |
+263
View File
@@ -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())
}
```
> 一般来说,句点引入不推荐使用,因为它们会导致较低的代码可读性
+100
View File
@@ -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