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
198 changed files with 27136 additions and 339 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"
+16
View File
@@ -19,6 +19,20 @@ vendor/
.pub/ .pub/
build/ build/
# ---- Node / TypeScript ----
node_modules/
dist/
.astro/
*.tsbuildinfo
.next/
out/
coverage/
.env*
!.env.example
# ---- Claude Code ----
.claude/worktrees/
# ---- IDE / 编辑器 ---- # ---- IDE / 编辑器 ----
.idea/ .idea/
.vscode/ .vscode/
@@ -31,3 +45,5 @@ Thumbs.db
# ---- Note 目录 ---- # ---- Note 目录 ----
note/项目/ note/项目/
docs/plans
go/projects/lyra/data
+19 -11
View File
@@ -6,26 +6,32 @@
| 目录 | 说明 | | 目录 | 说明 |
|---|---| |---|---|
| `go/` | Go module `lab`(Go 1.26.2),go.mod 在该目录内 | | `go/` | Go 代码:`practice/` 是共用 module(`module lab`)的练习区,`projects/` 各项目为独立 module |
| `note/` | 个人笔记(非代码),`note/项目/` 被 gitignore | | `note/` | 个人笔记(非代码),`note/项目/` 被 gitignore |
| `dart/` | 预留,尚未建立 | | `dart/` | 预留,尚未建立 |
## Go 约定 ## Go 约定
- **Module 在 `go/` 目录下,不在仓库根**——所有 go 命令需在 `go/` 内执行 - Go 代码集中在 `go/`,目录本身不是 module,按用途分两区:
- 每个叶子目录 = 一个独立小程序(`package main` + `main.go`) - `go/practice/`——共用 module 的练习区,`go.mod` 在该目录内(见 [go/practice/README.md](go/practice/README.md))
- 多个小程序可并存,不同目录、各自独立 - `go/projects/<项目名>/`——每个项目一个独立 module,自带 `go.mod`(见 [go/projects/README.md](go/projects/README.md))
- 主题目录(basics/concurrency/stdlib/patterns/algorithms/web/testing/project)可随时增删 - **go 命令在对应 module 的根目录执行**:练习代码在 `go/practice/` 内执行;projects 每个项目在自己的目录内执行
- 测试文件用 `*_test.go`,运行单个测试:`cd go && go test ./testing/ -run TestSliceAppend` - 练习区中每个叶子目录通常是一个独立小程序(`package main` + `main.go`),多个小程序并存互不冲突;库包(如算法实现)与只有测试文件的包也是合法形态
- 主题目录可随时增删;写新程序时挑最贴的主题,在里面建一个新子目录
- 测试文件用 `*_test.go`,运行单个测试:`cd go/practice && go test ./testing/slice -run TestSliceAppend`
## 开发命令 ## 开发命令
``` ```
# Go——所有命令在 go/ 目录下执行 # Go 练习——在 go/practice/ 目录下执行
cd go cd go/practice
go run ./<主题>/<程序名>/ # 运行单个程序 go run ./basics/runtime/ # 运行单个程序
go test ./... # 运行全部测试 go test ./... # 运行全部测试(部分练习阻塞/依赖环境,不保证全过)
go test ./testing/ -v # 运行 testing 包测试 go test ./testing/... -v # 运行 testing 包及其子包测试
go mod tidy # 整理依赖
# Go projects——在 go/projects/<项目名>/ 各自目录下执行
go run . # 运行项目(按实际入口调整)
go mod tidy # 整理依赖 go mod tidy # 整理依赖
``` ```
@@ -34,3 +40,5 @@ go mod tidy # 整理依赖
- 仓库无 CI、无 pre-commit hooks、无代码检查——每个程序自行保证可编译即可 - 仓库无 CI、无 pre-commit hooks、无代码检查——每个程序自行保证可编译即可
- 笔记和练习代码严格分离:程序相关说明写在程序目录的 README.md,系统性知识放 `note/` - 笔记和练习代码严格分离:程序相关说明写在程序目录的 README.md,系统性知识放 `note/`
- `.gitignore` 已覆盖 Go 二进制、Dart/Flutter 构建产物、IDE 配置、操作系统文件 - `.gitignore` 已覆盖 Go 二进制、Dart/Flutter 构建产物、IDE 配置、操作系统文件
- 编辑、暂存或提交代码、笔记、配置时留意真实数据库连接、用户名密码、令牌密钥、私钥、个人路径及意外二进制/生成物
- 发现疑似真实敏感信息先暂停,只报告路径、行号和类型(不复述值),建议脱敏或移除、必要时轮换;演示占位值不算真实凭据,经用户确认无敏感风险后可继续
+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 - `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 行为等) | | [`practice/`](practice/README.md) | 练习区:共用一个 module(`go.mod` 在 `practice/` 内),语法 / 算法 / 测试等日常练手代码 |
| `concurrency/` | 并发:goroutine / channel / select / context | | [`projects/`](projects/README.md) | 项目区:每个子目录是独立 module 的完整小应用,自带 `go.mod`、自行管理依赖 |
| `stdlib/` | 标准库练习(net/http、encoding/json、io…) |
| `patterns/` | 设计模式 |
| `algorithms/` | 数据结构与算法 |
| `web/` | net/http、Gin 等框架 |
| `testing/` | 练习 Go 测试机制(`*_test.go`、`go test`、table-driven、testify) |
| `project/` | 完整小项目(多文件) |
> 主题目录可随时增删。写新程序时挑最贴的主题,在里面建一个新子目录放 `main.go` 即可。 ## 边界
- 写小段代码验证语言行为、练语法和标准库 → 放 `practice/` 对应主题目录。
- 程序长成完整应用,或依赖需要独立于练习区管理 → 在 `projects/` 下新建目录,单独 `go mod init`。
- 两个区互不包含:`practice/` 的 module 不覆盖 `projects/`,每个 projects 项目也是独立 module。
-15
View File
@@ -1,15 +0,0 @@
module lab
go 1.26.2
require (
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.11.0 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
-18
View File
@@ -1,18 +0,0 @@
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+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)
}
+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)
}
+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)
}
+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]
}
}
}
}
+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,服务启动时**不会**自动执行迁移。
-48
View File
@@ -1,48 +0,0 @@
package testing
import (
"sort"
"testing"
"time"
)
func TestNormal(t *testing.T) {
numbers := map[int][]int{
1: {8, 2, 4, 4},
2: {0, 5, 3},
3: {1},
}
for _, numbers := range numbers {
middle := GetMiddNumber(numbers)
t.Log(middle)
}
}
func GetMiddNumber(numbers []int) float64 {
sort.Ints(numbers)
length := len(numbers)
isOddNUmber := length%2 == 1
if !isOddNUmber {
return float64(numbers[length/2-1]+numbers[length/2]) / 2.0
}
return float64(numbers[length/2])
}
func TestChannel(t *testing.T) {
var ball = make(chan string)
kickBall := func(playerName string) {
for {
t.Log(<-ball, "传球", "\n")
time.Sleep(time.Second)
ball <- playerName
}
}
go kickBall("张三")
go kickBall("李四")
go kickBall("王二麻子")
go kickBall("刘大")
ball <- "裁判" // 开球
var c chan bool // 一个零值nil通道
<-c // 永久阻塞在此
}
+2 -2
View File
@@ -25,13 +25,13 @@
#### 三、远程操作 #### 三、远程操作
| 操作 | 命令 | 说明 | | 操作 | 命令 | 说明 |
| ---------------- | --------------------------------------------------------- | ------------------- | | ---------------- | ----------------------------------------------------------- | ------------------- |
| 克隆仓库 | `git clone <url>` | 拉取远程项目 | | 克隆仓库 | `git clone <url>` | 拉取远程项目 |
| 拉取远程代码 | `git pull` | 获取并合并远程分支 | | 拉取远程代码 | `git pull` | 获取并合并远程分支 |
| 推送代码 | `git push` | 推送当前分支到远程 | | 推送代码 | `git push` | 推送当前分支到远程 |
| 推送并设置上游 | `git push -u origin <branch>` | 建立跟踪关系 | | 推送并设置上游 | `git push -u origin <branch>` | 建立跟踪关系 |
| 查看远程地址 | `git remote -v` | 显示远程仓库地址 | | 查看远程地址 | `git remote -v` | 显示远程仓库地址 |
| 远程查询所有标签 | git ls-remote --tags https://github.com/xxx.git \| grep X | 查看X相关的所有标签 | | 远程查询所有标签 | `git ls-remote --tags https://github.com/xxx.git \| grep X` | 查看X相关的所有标签 |
#### 四、合并与变基 #### 四、合并与变基
@@ -0,0 +1,143 @@
# Gitea 简单 CI 实践
> 用 Gitea Actions 给仓库搭一条最简单的持续集成:推送代码后自动做语法检查、跑单元测试。
## 1. 启用 Gitea Actions
- Gitea 1.21 及以上版本的实例默认启用 Actions(`app.ini` 中 `[actions] ENABLED` 默认为 true);
- 但仓库级默认禁用,需在仓库 **设置 → Actions** 中打开。
## 2. 准备并注册 act_runner
job 由 act_runner 执行,可以二进制安装或者容器安装,官方文档:<https://docs.gitea.com/runner/>
准备前提:
- runner 所在机器能通过网络访问 Gitea 实例;
- 本例用 Docker 执行器(label 里的 `docker://` 前缀),runner 所在机器需有可用的 Docker。
本地 [Compose 示例](local-ci/compose.yaml) 用 `gitea/runner` 容器跑 Runner:
首次启动时 entrypoint 读取 `GITEA_INSTANCE_URL`、`GITEA_RUNNER_REGISTRATION_TOKEN`、
`GITEA_RUNNER_LABELS` 自动完成注册,注册结果写入挂载的 `/data` 下的 `.runner`
文件并持久化,重启容器不必手动注册。只有不用 Compose、直接安装二进制运行时,
才需要手动执行注册与启动命令(见官方文档)。
注册令牌等同凭据:妥善保管,只在环境变量、secrets 或 runner 自己的配置文件里
出现,不要写进命令行历史、文档或仓库文件。
label 的格式是 `名称:执行器://参数`:以 Compose 中的
`demo-ci:docker://node:22-bookworm` 为例,名称为 `demo-ci`,
执行器为 Docker,参数为 job 容器镜像 `node:22-bookworm`。
工作流的 `runs-on` 按**名称部分**匹配。
### Runner 的 Docker 运行方式
`docker://` 表示 job 在容器中运行,但不决定 Runner 使用哪一个 Docker daemon:
| 术语 | 含义 | 取舍 |
|---|---|---|
| DinD(Docker-in-Docker) | Runner 容器里另起 Docker daemon,由它创建 job 容器 | 与宿主 Docker 分开;job 默认也与外层 Compose 网络分开,需要配置访问 Gitea 的路径 |
| DooD(Docker-outside-of-Docker) | Runner 通过宿主机 Docker socket 使用宿主 daemon,不在容器里另起 daemon | 配置和缓存复用较简单;能接触 socket 的 job 可能控制宿主 Docker,不宜在共享宿主上运行不可信代码 |
| Rootless | Docker daemon 以非 root 身份运行,不是 DinD 的反义词 | 可以与 DinD 组合;降低部分权限风险,但仍需留意网络、存储限制及宿主资源访问 |
本地 [Compose 示例](local-ci/compose.yaml) 使用需要特权容器的 `3-dind`,
并非 Rootless。三种术语并不处于同一分类:DinD / DooD 说明**使用哪个 daemon**,
Rootless 说明**daemon 以什么权限运行**;它也不等于完全隔离。
## 3. 最小 Node demo
两个零依赖文件,放在仓库根目录。
`demo.js`(CommonJS,一个加法函数):
```js
function add(a, b) {
return a + b;
}
module.exports = { add };
```
`demo.test.js`(用 Node 内置的 `node:test` 与 `node:assert/strict`):
```js
const test = require('node:test');
const assert = require('node:assert/strict');
const { add } = require('./demo');
test('整数相加', () => {
assert.equal(add(1, 2), 3);
});
test('负数相加', () => {
assert.equal(add(-1, -2), -3);
});
```
先在本地验证能跑:`node --test demo.test.js`。
## 4. 编写工作流
在仓库根目录新建 `.gitea/workflows/ci.yml`:
```yaml
name: Demo CI
on:
push:
branches: [main] # 默认分支不是 main 的仓库按需修改
jobs:
test:
runs-on: demo-ci
steps:
- name: 检出仓库
uses: https://gitea.com/actions/checkout@v4
- name: 语法检查
run: node --check demo.js && node --check demo.test.js
- name: 运行测试
run: node --test demo.test.js
```
要点:
- `runs-on: demo-ci` 必须与 runner 注册 label 的名称部分一致;
- Gitea 中 `uses` 不带完整 URL 时默认从 GitHub 拉取,这里显式写 gitea.com
的绝对地址;runner 仍需能访问该 action 源;
- job 会跑在 label 指定的 `node:22-bookworm` 容器里:Node 内置测试运行器
开箱即用,不需要 `npm install`,也没有任何第三方依赖。
## 5. 推送并查看结果
```bash
git add demo.js demo.test.js .gitea/workflows/ci.yml
git commit -m "add demo CI"
git push
```
打开仓库 **Actions** 页,能看到这次 push 触发的 run;
点进 job 可展开每个 step 的日志。
常见排查:
| 现象 | 排查方向 |
|---|---|
| run 一直等待、没有 job 被领取 | 仓库 Actions 是否启用;runner 是否在线(runner 页看状态);`runs-on` 与 runner label 名称是否一致。不同版本对不匹配的处理有差异,结合 runner 日志与实际执行环境核对 |
| checkout 步骤失败或超时 | runner 到 Gitea 实例、到 action 源(gitea.com)的网络连通性 |
| 语法检查或测试失败 | 直接看该 step 日志里的报错/断言输出,在本地用同样命令复现 |
本地用 Compose + Docker-in-Docker 时,还要区分 runner 容器与 job 容器的网络:
runner 能解析 `gitea`,不代表 job 也能。若 checkout 的 `git fetch` 报
`Could not resolve host: gitea`,先检查 job 容器能否访问 Gitea,而不只是检查 runner。
本地测试可在 Runner 配置的 `container:` 下设 `network: host`,通过 Compose
挂载该配置、设置 `CONFIG_FILE` 并重建 runner,见 [本地 Compose 示例](local-ci/compose.yaml)。
这里的 `host` 指 runner 容器所在的网络命名空间,不是宿主机网络;job 与 runner
共用网络会降低隔离性,只适合可信的本地测试,不应直接当作生产环境通用方案。
## 参考
- Gitea Actions 快速上手:<https://docs.gitea.com/usage/actions/quickstart/>
- act_runner 注册:<https://docs.gitea.com/runner/registration/>
- act_runner 标签:<https://docs.gitea.com/runner/labels/>
@@ -0,0 +1,88 @@
# 本地单机 Gitea + runner(DinD)测试环境,仅用于本机可信代码测试。
# 配套笔记:上级目录《Gitea 简单 CI 实践》——让其中的 demo workflow
# (runs-on: demo-ci、checkout@v4、Node 22)不依赖云服务器即可本机跑通。
#
# ── 使用步骤(命令均从仓库根目录执行;若已 cd 进本目录,可去掉 -f 直接 `docker compose up -d`)──
#
# 1. 生成并导出注册令牌(只放在当前 shell 里,切勿写进仓库或 .env 文件):
# export GITEA_RUNNER_REGISTRATION_TOKEN="$(openssl rand -hex 32)"
# Gitea >= 1.23:在 Gitea 首次初始化(完成安装向导)之前设置该变量,
# 它会被固定为 runner 注册令牌;实例已初始化后再设置不会改变令牌,
# 此时以 管理后台/仓库 → Actions → Runners 页面显示的令牌为准。
#
# 2. 启动(首次运行会拉取两个镜像):
# docker compose -f note/资源/DevOps/Gitea/local-ci/compose.yaml up -d
#
# 3. 打开 http://localhost:3000 完成首次安装(数据库保持默认 SQLite 即可)。
# 若 runner 早于安装完成启动,注册会失败并按 restart 策略自动重试,
# 安装完成后无需手动干预,稍等即可看到 runner 在线。
#
# 4. 注册/登录 → 新建仓库 → 仓库 设置 → Actions 启用;
# 按《Gitea 简单 CI 实践》加入 demo.js、demo.test.js 与
# .gitea/workflows/ci.yml。本机 git 克隆/推送走 HTTP 协议:
# git clone http://localhost:3000/<owner>/<repo>.git
#
# 5. 仓库 Actions 页查看 run;需要排查 runner 时:
# docker compose -f note/资源/DevOps/Gitea/local-ci/compose.yaml logs runner
#
# 6. 账号、仓库、runner 注册与镜像缓存在三个命名卷里,保留卷即保留数据;
# 彻底重来:docker compose -f note/资源/DevOps/Gitea/local-ci/compose.yaml down -v
#
# ── 安全提醒 ──
# runner 容器 privileged: true(DinD 必需),权限等同宿主 root;
# 本文件只用于本机可信仓库的本地测试,勿用于不可信仓库或对外提供服务。
services:
gitea:
image: docker.gitea.com/gitea:latest
restart: unless-stopped
environment:
# 单机演示固定 SQLite,数据随 gitea-data 卷持久化
GITEA__database__DB_TYPE: sqlite3
# 页面/克隆地址显示为 localhost,方便本机浏览器与 git 客户端
GITEA__server__ROOT_URL: http://localhost:3000/
# 与 runner 用同一个固定注册令牌(含义见文件头第 1 步)
GITEA_RUNNER_REGISTRATION_TOKEN: "${GITEA_RUNNER_REGISTRATION_TOKEN:?set GITEA_RUNNER_REGISTRATION_TOKEN}"
ports:
# 只绑定回环地址,不暴露到局域网;git 走 HTTP,无需映射 SSH 端口
- "127.0.0.1:3000:3000"
volumes:
- gitea-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
runner:
image: docker.io/gitea/runner:3-dind
restart: unless-stopped
# DinD:job 容器跑在 runner 内部的嵌套 Docker daemon 里,需要 privileged
privileged: true
depends_on:
gitea:
condition: service_healthy
environment:
CONFIG_FILE: /config.yaml
# 注册与克隆都发生在 runner 容器自身,它在 compose 网络内可直连 gitea 服务
GITEA_INSTANCE_URL: http://gitea:3000
GITEA_RUNNER_NAME: local-demo
# 与 demo workflow 的 runs-on: demo-ci 一致;node:22-bookworm 提供 Node 22
GITEA_RUNNER_LABELS: demo-ci:docker://node:22-bookworm
GITEA_RUNNER_REGISTRATION_TOKEN: "${GITEA_RUNNER_REGISTRATION_TOKEN:?set GITEA_RUNNER_REGISTRATION_TOKEN}"
volumes:
- ./runner-config.yaml:/config.yaml:ro
# runner 状态(含 .runner 注册文件)
- runner-data:/data
# 嵌套 dockerd 的镜像与构建缓存
- runner-docker:/var/lib/docker
# DinD 默认给 job 建独立网络;checkout 的 git fetch 会访问 http://gitea:3000,
# job 若无法解析 gitea 就会失败,即使 runner 容器能解析也没用。
# runner-config.yaml 设置 container.network: host,使 job 共用 runner 容器的
# 网络命名空间(不是宿主机网络);隔离性随之降低,仅限本地可信测试。
volumes:
gitea-data:
runner-data:
runner-docker:
@@ -0,0 +1,302 @@
# Every option with its default value, all commented out. Read this file, do not copy it.
# `./gitea-runner config init` writes a config file to copy the lines you change into.
# Logging for the runner process itself (messages printed to stderr), plus the copy of
# each task's log kept under log.job. Neither controls how workflow step output is streamed
# to the Gitea UI; tune that with runner.log_report_* below.
log:
# logrus severity: trace, debug, info, warn, error, fatal, panic.
# trace and debug turn on caller/file:line in log lines. Default if omitted: info.
#level: info
# Write a copy of each task's log to dir as <start time>-task-<id>.log, so a job's output
# survives a failure to send it to Gitea. A path turns them on, empty turns them off.
# retention is how long a log is kept (0s keeps all), max_size caps one log (0 is no limit).
#job:
# dir: ""
# retention: 168h
# max_size: 1GB
runner:
# Where to store the registration result.
#file: .runner
# Execute how many tasks concurrently at the same time.
# With `container.network` empty, each concurrent docker job takes a subnet from the
# daemon's address pool, so a high capacity can exhaust it. See `default-address-pools`
# in the docker daemon config.
#capacity: 1
# Extra environment variables to run jobs.
#envs:
# A_TEST_ENV_NAME_1: a_test_env_value_1
# A_TEST_ENV_NAME_2: a_test_env_value_2
# Extra environment variables to run jobs from a file.
# It will be ignored if it's empty or the file doesn't exist.
#env_file: .env
# The timeout for a job to be finished.
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
# So the job could be stopped by the Gitea instance if its timeout is shorter than this.
#timeout: 3h
# The timeout for the runner to wait for running jobs to finish when shutting down.
# Any running jobs that haven't finished after this timeout will be cancelled.
#shutdown_timeout: 0s
# Whether skip verifying the TLS certificate of the Gitea instance.
#insecure: false
# The timeout for fetching the job from the Gitea instance.
# Values above the 60s RPC timeout are capped to it.
#fetch_timeout: 5s
# The interval for fetching the job from the Gitea instance.
#fetch_interval: 2s
# The maximum interval for fetching the job from the Gitea instance.
# The runner uses exponential backoff when idle, increasing the interval up to this maximum.
# Set to 0 or same as fetch_interval to disable backoff.
#fetch_interval_max: 5s
# While idle, remove stale bind-workdir task directories and orphaned host-mode
# scratch directories (left behind when a host cleanup delete stalls) older than
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
# (or any non-positive value) disables stale-directory cleanup entirely, along with
# the docker network and volume cleanup below.
#workdir_cleanup_age: 24h
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use
# docker it removes the per-job networks and volumes of jobs this runner did not live to
# tear down, which would otherwise hold a subnet of the daemon address pool and the job's
# workspace contents until the host is rebuilt.
#idle_cleanup_interval: 10m
# The base interval for periodic log flush to the Gitea instance.
# Logs may be sent earlier if the buffer reaches log_report_batch_size
# or if log_report_max_latency expires after the first buffered row.
#log_report_interval: 5s
# The maximum time a log row can wait before being sent.
# This ensures even a single log line appears on the frontend within this duration.
# Must be less than log_report_interval to have any effect.
#log_report_max_latency: 3s
# Flush logs immediately when the buffer reaches this many rows.
# This ensures bursty output (e.g., npm install) is delivered promptly.
#log_report_batch_size: 100
# The interval for reporting task state (step status, timing) to the Gitea instance.
# State is also reported on step transitions (start/stop) and after each log flush.
#state_report_interval: 5s
# Per-attempt deadline for flushing the final logs and task state when a job
# finishes, on a detached context so a server cancel can't block the acknowledgement.
#report_close_timeout: 10s
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
# and github_mirror is not empty. In this case,
# it replaces https://github.com with the value here, which is useful for some special network environments.
#github_mirror: ''
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
# Set to false to clone the full history.
#action_shallow_clone: true
# When true (the default), inject the ACT=true environment variable into jobs.
# Set to false so workflows gated on `if: ${{ !env.ACT }}` behave like they do on GitHub.
#set_act_env: true
# When true (the default), apply compatibility patches to the actions a job runs, so actions
# written for GitHub work against this instance. Set to false to run them exactly as published,
# at the price of the stock artifact actions refusing and the cache client keeping to v1.
#patch_actions: true
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
# If it's empty when registering, it will ask for inputting labels.
# If it's empty when execute `daemon`, will use labels in `.runner` file.
#labels:
# - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
# - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
# Default false matches GitHub actions/runner. Enable only for jobs that need an interactive
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
# when a TTY is present.
#allocate_pty: false
# Image for a job whose runs-on matches none of the labels above. A runner without a
# docker daemon runs such a job on the host instead.
#default_image: "docker.gitea.com/runner-images:ubuntu-latest"
# What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools:
# none: nothing. A docker job sees what its image ships there, a host job an empty dir, and
# either way what it installs is gone when the job ends.
# shared: one tool cache every job reuses. Two jobs writing the same tool version at once
# corrupt it, so use it only with capacity 1.
#tool_cache_mode: none
# Optional executable on the host, run once after each task's built-in cleanup
# (post-steps, container teardown, bind-workdir removal). Additive only.
#
# IMPORTANT: While this script runs the runner stops task heartbeats and stays
# offline from Gitea's perspective until the script exits. A script that never
# returns blocks new work until post_task_script_timeout kills it (default 5m).
# Keep scripts short; set post_task_script_timeout to a safe upper bound.
#
# Output -> runner process log (not the job log). Non-zero exit -> warning only.
# Windows: use .exe, .bat, or .cmd. PowerShell (.ps1) is not supported yet as
# the configured path; wrap PowerShell commands in a .cmd file instead.
# Full guide: docs/post-task-script.md
#post_task_script: ''
# Hard limit on post_task_script runtime. Default if omitted: 5m.
#post_task_script_timeout: 5m
# Scripts run inside the job environment before the job's first step and after its last
# one, the equivalent of GitHub's ACTIONS_RUNNER_HOOK_JOB_STARTED and
# ACTIONS_RUNNER_HOOK_JOB_COMPLETED, which are read when these are unset. The paths are
# resolved inside the job environment. Either one failing fails the job.
# Full guide: docs/job-hooks.md
#hooks:
# job_started: ''
# job_completed: ''
cache:
# Enable caching (used by actions/cache and similar actions). Off means no built-in server is
# started and no job is registered with an external_server either.
#enabled: true
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
# Ignored when external_server is set.
#dir: ""
# Outbound IP or hostname that job containers use to reach this runner's cache server.
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
# If the runner itself runs in Docker, automatic detection can choose an
# address on the runner container's network that job containers cannot reach
# when the runner creates a separate per-job network. In that case, set this
# to a hostname/IP reachable from job containers, and set port to a fixed
# published port or put the job containers on a shared Docker network.
# Ignored when external_server is set.
#host: ""
# Port for the built-in cache server. 0 picks a random free port.
# Ignored when external_server is set.
#port: 0
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
# Set on every runner that should share a cache pool. A trailing slash is optional.
# Jobs reach the server at this URL too, so set it to the reverse proxy when one fronts the server.
# Example: "http://cache-host:8088/"
# Requires external_secret (below) to match the value on the cache-server.
#external_server: ""
# Shared secret between this runner and the external cache-server.
# Required when external_server is set. Must be identical on every runner and the cache-server.
# Generate with: openssl rand -hex 32
#external_secret: ""
# Path to a file containing the shared secret, as an alternative to external_secret.
# Use this to keep the secret out of this file.
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
# Setting both external_secret and external_secret_file is an error.
#external_secret_file: ""
# When true, reuse a cached action instead of fetching from the remote on every job.
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
# until its cache entry expires or is manually removed.
#offline_mode: false
# Serve the actions cache service v2 API. The actions that use it fall back to v1 on any host
# they do not take for GitHub, so reaching it means editing that check out of their own bundle,
# put back after the copy into the job. That edit is made either way, this only governs the API
# advertised. A bundle that does not match is left alone. With v2, artifact uploads go via the
# cache server unless container jobs cannot reach its Docker network.
#v2: true
# How the cache server discards entries, ignored when external_server is set since that
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three
# limits off. Sizes accept 10GB, 512mb, 1TiB or a plain byte count, binary either way.
# Whatever these allow, the cache still sheds entries to keep free space on its volume
# above health_check.min_free_disk_space_mb when health checks are enabled.
# Remove entries nothing has read or written within this window. Only last access counts.
#retention: 168h
# Cap one repository, removing its least recently accessed entries until it fits. An entry
# larger than the limit is dropped rather than emptying the repository to make room.
#repo_size_limit: 10GB
# Cap the whole cache the same way. Off by default, since the free space floor bounds it.
#size_limit: 0
# Minimum time between two eviction sweeps. This one has no "off".
#sweep_interval: 1h
container:
# Specifies the network to which the container will connect.
# Could be host, bridge or the name of a custom network.
# If it's empty, runner will create a network automatically.
# For dockerized runners using the built-in cache server, a custom shared
# network can be required so job containers can reach cache.host/cache.port.
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
network: host
# network_create_options only apply when `network` is left empty and the runner
# auto-creates a per-job network that does not already exist. They have no effect
# when a custom `network` name is set, because that network is used as-is and never
# created by the runner. Omit the entire block to use Docker's defaults. An auto-created
# network is labelled com.gitea.runner.uuid=<this runner's uuid>, which is how the idle
# cleanup tells its own leftovers apart from those of other runners on the same daemon.
#network_create_options:
# enable_ipv4: true # Omit to use Docker's default (IPv4 enabled). Set false to disable IPv4.
# enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
#privileged: false
# Any other options to be used when the container is started, for example:
# options: --add-host=my.gitea.url:host-gateway
# A volume declared here replaces the one the runner would mount on the same container path, so the
# tool cache can be kept on the host. Its source must also be allowed by valid_volumes below:
# options: --volume /host/toolcache:/opt/hostedtoolcache
# With privileged disabled, options that could escape the container (--security-opt, --device,
# --cap-add, --pid, ...) are ignored in a workflow's container.options, but keep working here.
#options:
# The parent directory of a job's working directory.
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
# If the path starts with '/', the '/' will be trimmed.
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
# If it's empty, /workspace will be used.
# Purely numeric subdirectories under this path are reserved for task workspaces and may be removed by idle cleanup.
#workdir_parent:
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
# valid_volumes:
# - data
# - /src/*.json
# If you want to allow any volume, please use the following configuration:
# valid_volumes:
# - '**'
#valid_volumes: []
# Overrides the docker client host with the specified one.
# If it's empty, runner will find an available docker host automatically.
# If it's "-", runner will find an available docker host automatically, but won't mount it into the job and action containers.
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
#docker_host: ""
# Pull docker image(s) even if already present.
# Defaults to false when the key is omitted.
# Two exceptions: an image pinned by digest (image@sha256:...) cannot change, so it is never
# re-pulled, and a pull that fails while a copy is already on the host does not fail the job,
# which runs on that copy with a warning in its log.
#force_pull: false
# Rebuild docker image(s) even if already present
#force_rebuild: false
# Always require a reachable docker daemon, even if not required by runner
#require_docker: false
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
#docker_timeout: 0s
# Mount the workspace from a host directory instead of a Docker volume, so jobs
# can bind-mount it by its own path into sibling containers (".:/app" in docker
# compose). Not needed when jobs get the Docker proxy or workflows use the
# GITEA_DOCKER_WORKSPACE variable.
# The workspace parent directory must be mounted into the runner container.
#bind_workdir: false
# How long a job waits for a service container that declares a healthcheck to become
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
#service_ready_timeout: 5m
host:
# The parent directory of a job's working directory.
# If it's empty, $HOME/.cache/act/ will be used.
#workdir_parent:
# Optional local task-admission checks. Disabled by default. When enabled, low
# disk space or a failing script pauses new task fetching; existing jobs continue.
# No health checks run while any job is active; the last result is reused until idle.
health_check:
#enabled: false
# Minimum free space required on the filesystem holding runner workspaces.
# Defaults to 1024 MiB when omitted or set to zero.
#min_free_disk_space_mb: 1024
# Optional additional executable. A non-zero exit, timeout, or startup failure
# marks the runner unavailable.
#script: ''
# How long a script result is cached and its maximum execution time.
#interval: 30s
#timeout: 10s
metrics:
# Enable the Prometheus metrics endpoint.
# When enabled, metrics are served at /metrics, liveness at /healthz, and
# task-admission readiness at /readyz.
#enabled: false
# The address for the metrics HTTP server to listen on.
# Defaults to localhost only. Set to ":9101" to allow external access,
# but ensure the port is firewall-protected as there is no authentication.
#addr: "127.0.0.1:9101"
# Consecutive polling failures may last this long before /readyz returns 503.
#readiness_grace: 30s
@@ -0,0 +1,82 @@
> 私钥永不出生(生成)于服务器,且永不离开客户端
### 客户端生成密钥对
```bash
ssh-keygen -t ed25519 -C "mail"
```
- `-t`:指定密钥算法。`ed25519` 在同等安全强度下密钥更短、性能更好、是当前最佳实践;`RSA 4096` 依然安全,且兼容性更广,如果是很老的系统(OpenSSH < 6.5),可能需要`RSA`
- `-C`:添加注释,不影响加密、解密或登录认证的安全性,纯粹是一个给人看的备注字段
使用上述命令会生成一对密钥,通常位于`~/.ssh`目录下:公钥是`id_ed25519.pub`,私钥是`id_ed25519`。
### 上传公钥到目标服务器
方式1
```bash
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@服务器IP
```
方式2
```bash
cd ~/.ssh
echo "公钥内容" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
chmod 700 ~/.ssh
```
> 手动 echo 是通用方法,但更推荐 `ssh-copy-id`,它会自动处理目录创建、权限设置和追加
### 测试密钥登录
```bash
ssh root@服务器IP
```
- 如果直接登录成功,没有询问密码,说明密钥配置完全正确,继续下一步。
- 如果仍然要求输密码,需要检查上一步的公钥是否粘贴完整,或 `authorized_keys` 权限是否为 `600`。
### 修改`sshd_config`
修改服务器的`/etc/ssh/sshd_config`文件,将下方这些行取消注释或者添加:
```
PubkeyAuthentication yes # 开启密钥认证
PasswordAuthentication no # 关闭密码认证
PermitRootLogin prohibit-password # root账户禁用密码登录
```
保存退出后,使用`sshd -t`检查配置文件语法是否正确
重启SSH服务
> SSH服务名因发行版而异:
>
> `Debian`/`Ubuntu`使用`systemctl restart ssh`
>
> ``RHEL`/`CentOS`/`Fedora` 上使用`systemctl restart sshd`
### 最终验证
1. 使用SSH发起连接后没有输入密码的提示,能直接连接服务器
```bash
ssh root@服务器IP
```
2. 临时删除私钥后再次登录会报错,大概如下
```
root@服务器IP: Permission denied (publickey,gssapi-keyex,gssapi-with-mic).
@@ -0,0 +1,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;
```
+37 -4
View File
@@ -109,15 +109,48 @@ func main() {
```powershell ```powershell
# mysql根据远程数据库表生成 # mysql根据远程数据库表生成
goctl model mysql datasource --url="olive:xxvkikaDDx01&&99@tcp(127.0.0.1:3306)/ebming" --table="user_invitation_record" --dir="." -c --home ../../../../deploy/goctl/1.8.3 goctl model mysql datasource --url="user:password@tcp(127.0.0.1:3306)/database" --table="user_table" --dir="." -c --home D:/template/1.8.3
# mysql根据ddl生成 # mysql根据ddl生成
goctl model mysql ddl --src h5.sql --dir . -c --home D:/zhangyuxin/template/1.8.3 goctl model mysql ddl --src h5.sql --dir . -c --home D:/template/1.8.3
# pgsql根据远程数据库表生成 # pgsql根据远程数据库表生成
goctl model pg datasource -url "postgres://labman:tN5bWz2KpL9Fy4@127.0.0.1:15432/sern" -table user -dir ./user --cache --home ../../goctl_template/1.8.3 goctl model pg datasource -url "postgres://user:password@127.0.0.1:15432/database" -table user -dir ./user --cache --home D:/template/1.8.3
# pgsql根据远程数据库表生成,带模式(schema) # pgsql根据远程数据库表生成,带模式(schema)
goctl model pg datasource -url "postgres://admin:zhangyuxin@127.0.0.1:25432/test?sslmode=disable" -s "users" -table "profile" -dir ./go-zero/model/users/ --cache --home ../../deploy/goctl goctl model pg datasource -url "postgres://user:password@127.0.0.1:25432/database?sslmode=disable" -s "users" -table "profile" -dir ./go-zero/model/users/ --cache --home ../../deploy/goctl
``` ```
> 有的时候会遇到`unsupported database type`的错误,如果需要支持该字段,需要在`goctl.yaml`文件添加数据库类型映射配置,并保证goctl环境变量`GOCTL_EXPERIMENTAL=on`开启。
## config
`goctl config`是用于对goctl静态配置文件做管理。
```
$ goctl config --help
Usage:
goctl config [command]
Available Commands:
clean Clean goctl config file
init Initialize goctl config file
Flags:
-h, --help help for config
Use "goctl config [command] --help" for more information about a command.
```
`goctl.yaml`文件字段
目前暂时仅支持对 model 数据类型映射的配置
| 字段名称 | 说明 |
| --------------- | ------------------------------------------------------------ |
| model | model 配置 |
| model.types_map | model 配置之类型映射规则,是一个 map<string,obj> 结构,key 为数据库类型,value 为映射对象 |
@@ -1,200 +0,0 @@
### validator
[GitHub地址](github.com/go-playground/validator)
包验证器,可以用于验证参数是否符合规则,比如下方的比较验证,还有其他可以查看网址的详细介绍
go-zero自带的有校验器,可以使用
| Tag | Description |
| ---- | --------------------- |
| eq | Equals |
| gt | Greater than |
| gte | Greater than or equal |
| lt | Less Than |
| lte | Less Than or Equal |
| ne | Not Equal |
### goverter
[GitHub地址](https://github.com/jmattheis/goverter)
类型转换器,可以用于类型的转换,比如下面的代码
```go
func (l *CreatePopupLogic) CreatePopup(in *backgroundrpcmodel.ResPopup) (*backgroundrpcmodel.ResResult, error) {
_, err := l.svcCtx.PopupModel.Insert(l.ctx, u.Convert.SysPopupR2S(in.Info))
if err != nil {
return nil, rerr.NewFromSQL(err)
}
return &backgroundrpcmodel.ResResult{
Result: true,
}, nil
}
```
使用
```
goverter gen .
```
### asynq
[GitHub网址](https://github.com/hibiken/asynq)
- 入队流程
```go
payload := &jobtype.xxxPayload{}
payloadByte, err := jsoniter.Marshal(payload)
if err != nil {
logt.Error("xxx jsoniter.Marshal err:", err)
return nil, zerr.New(rescode.DataFormatConversionError)
}
_, err = l.svcCtx.AsynqClient.Enqueue(asynq.NewTask(jobtype.xxx, payloadBytes, asynq.Queue(consts.QueueLow)))
if err != nil {
logt.Error("xxx enqueue err:", err)
return nil, zerr.New(rescode.EnqueueErr)
}
```
- 自行定义了队列优先级,在入队的时候可以加参数进行选择
```
consts.QueueCritical: 6,
consts.QueueDefault: 3,
consts.QueueLow: 1,
```
- 处理前流程(反序列化,获取数据)
```go
var payload jobtype.xxx
err := json.Unmarshal(task.Payload(), &payload)
if err != nil {
logt.Error("xxx unmarshal payload err:", err)
return zerr.New(rescode.DataFormatConversionError)
}
```
### gorm
[官网文档](https://gorm.io/zh_CN/docs/)
**使用gorm + sqlx,但是复用sql连接**
```go
func NewServiceContext(c config.Config) *ServiceContext {
logx.Infof("Cache config: %+v", c.CacheRedis)
gormDB, err := gorm.Open(postgres.Open(c.Database.DataSource), &gorm.Config{})
if err != nil {
msg := fmt.Sprintf("连接数据库失败 %s ", err.Error())
logx.Error(msg)
panic(msg)
}
sqlDB, err := gormDB.DB()
if err != nil {
msg := fmt.Sprintf("获取底层连接失败: %s", err.Error())
logx.Error(msg)
panic(msg)
}
sqlxConn := sqlx.NewSqlConnFromDB(sqlDB)
return &ServiceContext{
Config: c,
UserAuthModel: models.NewUserAuthModel(sqlxConn, c.CacheRedis, gormDB),
}
}
```
### excelize
读写Excel的Go库
[github](https://github.com/qax-os/excelize)
#### 创建工作簿
```go
f := excelize.NewFile()
defer func() {
if err := f.Close(); err != nil {
fmt.Println(err)
}
}()
// 创建一个新的Sheet,返回的index为该工作簿的索引
index, err := f.NewSheet("SheetName")
if err != nil {
return fmt.Errorf("创建Sheet失败: %w", err)
}
```
#### 打开文件
```go
f, err := excelize.OpenFile(filePath)
if err != nil {
return fmt.Errorf("打开 Excel失败: %w", err)
}
defer func() {
if err := f.Close(); err != nil {
fmt.Println(err)
}
}()
```
#### 写入cell值
```go
f.SetCellValue("SheetName", "B2", 100)
```
#### 设置工作簿默认活动工作表
```go
f.SetActiveSheet(index)
```
#### 创建或更新excel表
```go
if err := f.SaveAs("Book1.xlsx"); err != nil {
return fmt.Errorf("保存失败: %w", err)
}
```
#### 获取指定cell值
```go
cell, err := f.GetCellValue("Sheet1", "B2")
if err != nil {
return fmt.Errorf("读取cell失败: %w", err)
}
fmt.Println(cell)
```
#### 获取所有工作簿的cell值(二维数组)
```go
rows, err := f.GetRows("Sheet1")
if err != nil {
return fmt.Errorf("读取失败: %w", err)
}
for _, row := range rows {
for _, colCell := range row {
fmt.Print(colCell, "\t")
}
fmt.Println()
}
```
@@ -0,0 +1,8 @@
# Go 常用第三方库
- [validator](validator.md) —— 参数校验器,验证参数是否符合规则
- [goverter](goverter.md) —— 类型转换器,用于类型转换代码生成
- [asynq](asynq.md) —— 异步任务队列,任务的入队与处理流程
- [gorm](gorm.md) —— ORM,gorm + sqlx 复用 sql 连接的用法
- [excelize](excelize.md) —— 读写 Excel 的 Go 库
- [bcrypt](bcrypt.md) —— 密码哈希;Go 团队维护的官方扩展库 `golang.org/x/crypto/bcrypt`,不属于标准库
@@ -0,0 +1,38 @@
# asynq
[GitHub网址](https://github.com/hibiken/asynq)
- 入队流程
```go
payload := &jobtype.xxxPayload{}
payloadByte, err := jsoniter.Marshal(payload)
if err != nil {
logt.Error("xxx jsoniter.Marshal err:", err)
return nil, zerr.New(rescode.DataFormatConversionError)
}
_, err = l.svcCtx.AsynqClient.Enqueue(asynq.NewTask(jobtype.xxx, payloadBytes, asynq.Queue(consts.QueueLow)))
if err != nil {
logt.Error("xxx enqueue err:", err)
return nil, zerr.New(rescode.EnqueueErr)
}
```
- 自行定义了队列优先级,在入队的时候可以加参数进行选择
```
consts.QueueCritical: 6,
consts.QueueDefault: 3,
consts.QueueLow: 1,
```
- 处理前流程(反序列化,获取数据)
```go
var payload jobtype.xxx
err := json.Unmarshal(task.Payload(), &payload)
if err != nil {
logt.Error("xxx unmarshal payload err:", err)
return zerr.New(rescode.DataFormatConversionError)
}
```
@@ -0,0 +1,193 @@
# 官方扩展库 `bcrypt`
## `bcrypt` 是什么
`bcrypt` 是专门用于**密码哈希**的算法,不是可逆加密。
```text
password
↓
bcrypt
↓
hash
```
### 核心原则
```text
数据库只存 hash
bcrypt 自动处理 salt
使用 CompareHashAndPassword 验证
cost 根据服务器性能决定
不要自己用 MD5 / SHA256 存密码
```
------
### cost
`cost` 是 bcrypt 的计算难度。
生产环境通常可以从 `10 ~ 12` 开始测试,具体值应根据服务器性能决定。单次 bcrypt 计算控制在大约 `100ms ~ 300ms` 是比较常见的工程范围。
`cost` 每增加 1,计算量大约接近翻倍。
cost 越高:
- 计算越慢
- 暴力破解成本越高
- 服务器 CPU 消耗也越高
bcrypt 生成的 hash 本身会记录当时使用的 `cost`。
因此,即使生产环境将当前配置从:
```text
cost = 10
```
升级为:
```text
cost = 12
```
数据库中原本使用 `cost = 10` 生成的旧 hash,仍然可以通过:
```go
bcrypt.CompareHashAndPassword(...)
```
正常验证。
`CompareHashAndPassword` 会根据已有 hash 中记录的信息进行密码验证,不依赖当前系统配置的 cost。
登录成功后,可以通过 `bcrypt.Cost()` 获取旧 hash 使用的 cost。如果低于当前配置,可以重新生成 hash 并更新数据库。
------
### 72-byte 限制
bcrypt 密码输入最多支持约 `72 bytes`。
如果只允许 ASCII:
```text
72 bytes ≈ 72 个字符
```
通常已经足够。
建议业务层主动限制:
```go
if len([]byte(password)) > 72 {
return errors.New("password too long")
}
```
------
## 基础使用
### 依赖导入
```go
import "golang.org/x/crypto/bcrypt"
```
`golang.org/x/crypto/bcrypt` 属于 Go 官方维护的扩展库,但不属于 Go 标准库。
------
### 生成 hash
```go
hash, err := bcrypt.GenerateFromPassword(
[]byte(password),
12,
)
```
生成的 hash 类似:
```text
$2a$12$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
其中大致包含:
```text
$2a$ → bcrypt 版本
12$ → cost
后续内容 → 编码后的 salt + hash
```
bcrypt 会自动生成随机 salt,因此不需要自己生成或额外保存 salt。
------
### 验证密码
```go
err := bcrypt.CompareHashAndPassword(
[]byte(passwordHash),
[]byte(password),
)
if err == nil {
// 密码正确
}
```
------
### 判断密码是否相同
不能直接比较两个 bcrypt hash,因为 bcrypt 每次都会生成新的随机 salt。
即使密码完全相同:
```text
hash1 != hash2
```
判断某个密码是否和旧密码相同时,应使用:
```go
err := bcrypt.CompareHashAndPassword(
[]byte(oldHash),
[]byte(password),
)
if err == nil {
// password 与 oldHash 对应的密码相同
}
```
------
### 获取 hash 的 cost
```go
cost, err := bcrypt.Cost(
[]byte(user.PasswordHash),
)
```
通常用于**用户登录成功后**,判断当前密码 hash 是否使用了旧的 cost。
例如:
```text
数据库 hash cost = 10
当前系统 cost = 12
↓
登录成功
↓
重新生成 cost = 12 的 hash
↓
更新数据库
```
这样可以在用户正常登录过程中逐步升级旧密码 hash。
@@ -0,0 +1,61 @@
# Casbin 学习笔记
> 版本基线:casbin **v3.11.0**;Go import 路径 `github.com/casbin/casbin/v3`(源码仓库 `apache/casbin`)。本文是概念速记,不是教程。
> Casbin 负责**授权**(能不能做某事),不负责**身份认证**(你是谁)。
## 1. 心智模型:PERM
| 段 | 作用 |
|---|---|
| `[request_definition]` `r = sub, obj, act` | 一次访问请求长什么样 |
| `[policy_definition]` `p = sub, obj, act` | 一条策略规则长什么样 |
| `[policy_effect]` `e = some(where (p.eft == allow))` | 多条策略如何合成结论 |
| `[matchers]` `m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act` | `r` 与 `p` 如何配对 |
- `sub, obj, act` 只是最典型的字段,不是硬编码:可加 `dom`、资源属性,也可用对象做 ABAC。
- RBAC 再加 `[role_definition] g = _,_`,表示主体与角色的继承关系;`g(a, b)` 意为“a 属于 b”,角色链会递归解析。
- 模型只描述“怎么判”,策略才决定“允许谁”,两者可分开存储与更新。
## 2. 核心组件
Casbin 运行时由以下组件构成,多数可在不同程度上替换或选配:
| 组件 | 职责 |
|---|---|
| Model | PERM 四段组成的规则模型,描述匹配逻辑,不含具体策略 |
| Policy | 具体策略规则的集合,可在内存或外部存储 |
| Enforcer | 判定入口:把请求套进模型与策略,得到 true/false |
| Adapter | 策略的存储读写层(DB/文件等),由应用选配 |
| RoleManager | 维护角色继承关系,支撑 `g()` |
| Watcher | 感知其他实例的变更并触发重载;Dispatcher 负责派发变更事件 |
组件协作:Model + Policy 交给 Enforcer 判定;Adapter 负责把 Policy 读入/写出;RoleManager 供 `g()` 解析继承;Watcher/Dispatcher 让多实例感知变更。
## 3. 判定链与策略示意
请求 → matcher 匹配 → 命中的策略 effect 合成 → 结果。
```
g, alice, admin # alice 属于 admin
p, admin, report, read # admin 可读 report
Enforce(alice, report, read) => true # 经角色继承
Enforce(bob, report, read) => false # 无授权
```
- 判定按请求逐条扫描策略:matcher 命中后,由 effect 合成最终结论。
- `some(where (p.eft == allow))` 是白名单语义:只要有一条 allow 就放行。
## 4. 关键提醒
- **deny 与 error 都 fail-closed**:`false` 是判定为拒,`err != nil` 是判定失败;安全场景两者都当拒绝,别把错误当放行。
- **并发优先 `SyncedEnforcer`**:常用 API 有锁;普通 `Enforcer` 需自行加锁,也不要假设所有接口都并发安全。
- **内存策略不会自动跨实例同步**:Casbin 核心不内置同步,多副本要自己配置 Watcher 通知或周期重载/对账。
- **ABAC 能判断单条对象的属性/所有权,但不会自动生成 SQL 查询过滤**,列表与行级数据范围仍需业务层实现。
- **迁移 v2 → v3**:只确认 adapter 自身路径带 `/v3` 不够,要核对其 `go.mod` 是否依赖 `github.com/casbin/casbin/v3`;v2 与 v3 的 `model.Model` 是不同 Go 包类型。
- `Enforce` 的参数要与 `r` 定义一致,类型/语义由模型与 matcher 决定,不是固定三元组。
## 官方来源
- v3.0.0 release(module path 变更):<https://github.com/apache/casbin/releases/tag/v3.0.0>
- v3.11.0 `persist/adapter.go`:<https://github.com/apache/casbin/blob/v3.11.0/persist/adapter.go>
- ABAC 文档:<https://casbin.org/docs/abac>
@@ -1,12 +1,10 @@
### excelize库 # excelize
读写Excel的Go库 读写Excel的Go库
[github](https://github.com/qax-os/excelize) [github](https://github.com/qax-os/excelize)
## 基本用法 ## 创建工作簿
#### 创建工作簿
```go ```go
f := excelize.NewFile() f := excelize.NewFile()
@@ -22,7 +20,7 @@
} }
``` ```
#### 打开文件 ## 打开文件
```go ```go
f, err := excelize.OpenFile(filePath) f, err := excelize.OpenFile(filePath)
@@ -36,19 +34,19 @@
}() }()
``` ```
#### 写入cell值 ## 写入cell值
```go ```go
f.SetCellValue("SheetName", "B2", 100) f.SetCellValue("SheetName", "B2", 100)
``` ```
#### 设置工作簿默认活动工作表 ## 设置工作簿默认活动工作表
```go ```go
f.SetActiveSheet(index) f.SetActiveSheet(index)
``` ```
#### 创建或更新excel表 ## 创建或更新excel表
```go ```go
if err := f.SaveAs("Book1.xlsx"); err != nil { if err := f.SaveAs("Book1.xlsx"); err != nil {
@@ -56,7 +54,7 @@ f.SetActiveSheet(index)
} }
``` ```
#### 获取指定cell值 ## 获取指定cell值
```go ```go
cell, err := f.GetCellValue("Sheet1", "B2") cell, err := f.GetCellValue("Sheet1", "B2")
@@ -66,7 +64,7 @@ f.SetActiveSheet(index)
fmt.Println(cell) fmt.Println(cell)
``` ```
#### 获取所有工作簿的cell值(二维数组) ## 获取所有工作簿的cell值(二维数组)
```go ```go
rows, err := f.GetRows("Sheet1") rows, err := f.GetRows("Sheet1")
@@ -80,4 +78,3 @@ f.SetActiveSheet(index)
fmt.Println() fmt.Println()
} }
``` ```
@@ -0,0 +1,29 @@
# gorm
[官网文档](https://gorm.io/zh_CN/docs/)
**使用gorm + sqlx,但是复用sql连接**
```go
func NewServiceContext(c config.Config) *ServiceContext {
logx.Infof("Cache config: %+v", c.CacheRedis)
gormDB, err := gorm.Open(postgres.Open(c.Database.DataSource), &gorm.Config{})
if err != nil {
msg := fmt.Sprintf("连接数据库失败 %s ", err.Error())
logx.Error(msg)
panic(msg)
}
sqlDB, err := gormDB.DB()
if err != nil {
msg := fmt.Sprintf("获取底层连接失败: %s", err.Error())
logx.Error(msg)
panic(msg)
}
sqlxConn := sqlx.NewSqlConnFromDB(sqlDB)
return &ServiceContext{
Config: c,
UserAuthModel: models.NewUserAuthModel(sqlxConn, c.CacheRedis, gormDB),
}
}
```
@@ -0,0 +1,23 @@
# goverter
[GitHub地址](https://github.com/jmattheis/goverter)
类型转换器,可以用于类型的转换,比如下面的代码
```go
func (l *CreatePopupLogic) CreatePopup(in *backgroundrpcmodel.ResPopup) (*backgroundrpcmodel.ResResult, error) {
_, err := l.svcCtx.PopupModel.Insert(l.ctx, u.Convert.SysPopupR2S(in.Info))
if err != nil {
return nil, rerr.NewFromSQL(err)
}
return &backgroundrpcmodel.ResResult{
Result: true,
}, nil
}
```
使用
```
goverter gen .
```
@@ -0,0 +1,15 @@
# validator
[GitHub地址](github.com/go-playground/validator)
包验证器,可以用于验证参数是否符合规则,比如下方的比较验证,还有其他可以查看网址的详细介绍
go-zero自带的有校验器,可以使用
| Tag | Description |
| ---- | --------------------- |
| eq | Equals |
| gt | Greater than |
| gte | Greater than or equal |
| lt | Less Than |
| lte | Less Than or Equal |
| ne | Not Equal |
@@ -1,4 +1,6 @@
## `errors.AsType` ## `errors`
### `errors.AsType`
> *用泛型把* `errors.As` *的“先声明变量再传指针”简化为“直接返回* `(E, bool)`*”,是* `errors.As` *的语法糖升级* > *用泛型把* `errors.As` *的“先声明变量再传指针”简化为“直接返回* `(E, bool)`*”,是* `errors.As` *的语法糖升级*
@@ -0,0 +1,98 @@
## 1.27版本变化
### 支持泛型方法
Go 1.27 虽然开放了泛型方法,但有明确的限制:
1.接口定义中不允许包含泛型方法。
2.拥有泛型方法的方法,不能用于实现接口。
```go
// 普通结构体,本身没有任何泛型参数
type Serializer struct{}
// T 属于方法 Serialize,每个调用可以独立拥有不同的 T
func (s *Serializer) Serialize[T any](data T) string {
return fmt.Sprintf("%v", data)
}
// 使用:
sz := Serializer{}
// 同一个 sz 实例,每次调用都可以处理完全不同的类型!
str1 := sz.Serialize("hello") // 自动推导 T = string
str2 := sz.Serialize(123) // 自动推导 T = int
str3 := sz.Serialize(true) // 自动推导 T = bool
```
### 结构体字面量:字段选择器当 key
嵌套或匿名结构体中的字段,现在可以直接初始化,不必再一层层嵌套字面量
```go
type Person struct {
Sex string
Name string
}
type Student struct {
StudentID int
Person
}
// Go 1.27 之前:必须显式嵌套 Person
s1 := Student{
StudentID: 1,
Person: Person{
Sex: "male",
Name: "张三",
},
}
// Go 1.27:嵌套结构体Person的字段可以直接当 key 用
s1 := Student{
StudentID: 1,
Sex: "male",
Name: "张三",
}
```
### 函数类型推断:从“手动挡”到“自动挡”
```go
// Go 1.27 之前:必须手动写出类型实参 [int],否则编译报错
fn := IntToString(FormatToString[int])
formatter:= []IntToString{FormatToString[int]}
// Go 1.27 后:自动推导,不用手写
fn := IntToString(FormatToString)
formatter := []IntToString{FormatToString}
```
### 标准库更新:`encoding/json/v2`
> Go 1.27 标准库中的 v1 `encoding/json` 内部已经被改用 v2 引擎驱动,保留了 v1 的原始 API 签名和行为
新旧版本主要行为对比
| **场景** | **encoding/json (v1)** | **encoding/json/v2** |
| ------------------------------- | -------------------------- | -------------------------------------------- |
| **`nil` 切片/Map 序列化** | 转为 `null` | 转为 `[]` 和 `{}` *(符合语义,防前端空指针)* |
| **重复键处理 (Duplicate Keys)** | 允许,后面的覆盖前面的 | **直接报错** *(增强安全性,防 JSON 注入)* |
| **非法 UTF-8 字符** | 格式化替换,静默忽略 | **直接报错** *(要求严格符合规范)* |
| **字段名匹配 (大小写敏感)** | 默认**忽略**大小写匹配 | 默认**严格匹配**大小写 *(提升解析性能)* |
| **固定长度数组解包** | 长度不一致时自动截断或补零 | 必须与 JSON 数组长度严格相等,否则报错 |
支持可选项
| Options | 作用 |
| -------------------------------------- | ----------------------- |
| `json.FormatNilSliceAsNull(true)` | 允许使用 v1 的 nil 行为 |
| `json.MatchCaseInsensitiveNames(true)` | 允许忽略大小写 |
新增标签`omitzero`
| **维度** | **omitempty** | **omitzero** |
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| **判断标准** | **是否为“空 (Empty)”** (如:空字符串 `""`、长度为 0 的切片/Map、`false`、`0`、指针 `nil`) | **是否为“ Go 类型的零值 (Zero Value)”** (满足 `reflect.Value.IsZero()` 或实现 `IsZero() bool` 接口) |
| **结构体/时间类型处理** | **有重大缺陷**,对非指针的结构体(如 `time.Time`)无效!依然会被序列化。 | **完美支持** 未初始化的 `time.Time{}` 或任何嵌套结构体零值均会被正确跳过。 |
| **自定义类型零值** | 不支持自定义零值判断逻辑。 | 支持只要实现 `IsZero() bool` 方法即可自定义是否跳过。 |
@@ -0,0 +1,45 @@
# squirrel 的坑
> `github.com/Masterminds/squirrel`,纯 SQL 查询构建器(query builder),2026-09 查证。
## 坑 1:Like 家族多 key 不排序,SQL 输出不确定(真雷)
`Eq` / `NotEq` / `Lt` / `Gt` 等 map 条件生成 SQL 前会对 key 做 `sort.Strings` 字典序排序(v1.1.0 起,`expr.go` 的 `getSortedKeys`),输出确定。
但 `Like` / `NotLike` / `ILike` / `NotILike` 的 `toSql` 是直接 `for key, val := range lk`——跟随 Go map 随机遍历序,**多 key 时每次生成的 SQL 条件顺序随机**:
```
第1次: WHERE nickname LIKE ? AND mobile LIKE ? | args=[%张% %138%]
第3次: WHERE mobile LIKE ? AND nickname LIKE ? | args=[%138% %张%] ← 顺序翻转
```
### 后果
- 查询结果没错(AND 交换律),但 **SQL 字符串 + args 顺序在变**
- 如果用「SQL 字符串 + 参数」生成缓存 key(hash 后当 Redis key 之类),同一查询会打出多个 key:
- 命中率暴跌:n 个 Like 条件最多 n! 种排列 → n! 个缓存 key
- 同一份数据存多份副本,TTL 内占内存
- 依赖 SQL diff 的单测会随机失败
### 规避
- `Like` 只放**单个 key**
- 多模糊条件拆成多次 `.Where(...)`(内部是 append 切片,天然保序)或用 `sq.And{...}` 显式控制顺序
### 上游状态
- 无任何 issue 报告过此事(全量搜索过,含 closed),作者从未回应
- 当年修 Eq 排序的 PR #141 只改了 Eq/Lt 两处,紧邻的 Like 一行未动,讨论里也没人发现——纯遗漏,非有意设计(作者在 #135 明确说过支持排序)
- 库近乎停滞:最新 release v1.5.4(2023-03),master 最后 commit 2024-02,**别指望上游修**
## 坑 2:Eq 排序按字典序,与联合索引列序无关(知道即可)
`Eq{"b":2, "a":1}` 永远生成 `WHERE a = ? AND b = ?`,与你书写的顺序无关(issue #321)。
对主流数据库**基本无影响**:MySQL / PostgreSQL / SQL Server / Oracle 的 CBO 都会重排谓词,索引选择看的是「条件覆盖了哪些列(最左前缀)、选择度、类型是否隐式转换」,不是 WHERE 里的书写顺序。只有对谓词顺序敏感的引擎/老优化器才会踩到。
## 顺带:这个库的亮点
- builder 是**值语义不可变**的(基于 lann/builder 持久化数据结构),每次链式调用返回新副本,可安全分叉复用,不像 GORM 那样共享 Statement、分叉前要 `Session()` 克隆
- `Eq` 家族的确定性输出有源码保证(`expr.go` 排序逻辑),可放心用于缓存 key 场景
- 子查询:`FromSelect(builder, alias)`,或任何 builder 作为 `Sqlizer` 嵌进 `Where/Having/Join`
+41
View File
@@ -0,0 +1,41 @@
# Gitleaks 使用笔记
Gitleaks 是扫描疑似密钥的命令行工具,不需要部署 Docker。它通过规则匹配识别令牌、云平台凭据、私钥等;命中不代表凭据仍有效,未命中也不代表绝对安全。
## Linux 安装
从 [官方 Releases](https://github.com/gitleaks/gitleaks/releases/latest) 下载对应架构的 Linux 压缩包:`uname -m` 为 `x86_64` 时选 `linux_x64`,为 `aarch64` 时选 `linux_arm64`。核对下载页提供的校验和,解压后安装:
```sh
sudo install -m 0755 gitleaks /usr/local/bin/gitleaks
gitleaks version
```
## 提交前自动检测
在目标 Git 仓库中检查 `.git/hooks/pre-commit` 是否已存在;若存在,将下面的检测命令并入现有脚本,**不要覆盖**。新建时内容为:
```sh
#!/bin/sh
gitleaks git --staged --redact --verbose .
```
然后运行 `chmod +x .git/hooks/pre-commit`。正常流程是 `git add <文件>` → `git commit -m "提交说明"`:Git 自动执行 hook;发现疑似密钥时命令返回非零状态,提交被阻止。`--redact` 避免在终端输出完整密钥。若设置了 `core.hooksPath`,应在指定的 hook 目录配置,而非 `.git/hooks/`。
## 手动扫描与测试
在仓库目录下运行:
| 命令 | 检查范围 |
| --- | --- |
| `gitleaks git --staged --redact --verbose .` | 本次暂存变更的新增内容,与上面的 hook 一致 |
| `gitleaks git --redact .` | Git 提交历史中的新增内容 |
| `gitleaks dir --redact .` | 当前目录中的文件,包括尚未暂存的文件 |
手动运行第一条命令可以先确认工具正常工作;没有命中通常返回 `0`,发现疑似密钥或扫描出错返回 `1`。普通文件修改不会因为装了 Gitleaks 就被阻止。要验证**自动拦截**,只用无效的模拟令牌测试,并先确认手动扫描能命中;随后运行普通的 `git commit`,预期 hook 阻止提交。若扫描未命中,别贸然提交测试令牌;若意外提交成功,不要推送,先检查 hook 是否执行及暂存内容。
## 注意事项
- `--staged` 扫描的是暂存差异中的新增内容,不等于检查每个暂存文件的全部历史内容;未暂存的修改也不在其中。
- 本地 hook 通常只对当前仓库有效,且可被 `git commit --no-verify` 绕过;重要项目还应在 CI 中扫描。
- 发现真实凭据时,先从暂存内容中移除并修复;若已推送或暴露,应尽快轮换凭据。误报可审慎使用 allowlist 或 `.gitleaksignore`,不要直接忽略未知来源的命中。
+175
View File
@@ -0,0 +1,175 @@
# Goose 数据库迁移笔记
Goose 用迁移文件管理数据库结构变更,并在数据库中记录已执行的迁移版本。
本文以 **PostgreSQL、Goose v3.29.0** 为例,命令使用 Bash 语法。
## 1. 基本概念
- **迁移文件**:按版本组织的数据库变更脚本。
- **Up**:应用变更,例如建表、增加字段。
- **Down**:撤销对应变更;能否恢复数据取决于具体 SQL。
- **版本记录**:Goose 默认通过 `goose_db_version` 表跟踪迁移状态。
- **工具版本与迁移版本不同**:`goose -version` 查看工具版本,`goose version` 查看数据库迁移版本。
通常将迁移文件放在 `migrations/` 目录。下文命令在该目录的上一级执行,目标数据库需事先创建。
## 2. 安装
需要先安装 Go,再安装指定版本的 Goose CLI:
```bash
go install github.com/pressly/goose/v3/cmd/goose@v3.29.0
goose -version
```
固定工具版本有助于保持不同环境的行为一致。Goose 可以作为独立命令行工具使用。
默认安装位置是 `$(go env GOPATH)/bin`;若设置了 `GOBIN`,则安装到该目录。找不到命令时,将实际安装目录加入 `PATH`:
```bash
# 未设置 GOBIN 时
export PATH="$PATH:$(go env GOPATH)/bin"
```
## 3. 配置数据库连接
在工作目录创建 `.env.local`,填写连接配置:
```dotenv
GOOSE_DRIVER=postgres
GOOSE_MIGRATION_DIR=./migrations
GOOSE_DBSTRING="postgres://example_user:URL_ENCODED_PASSWORD@127.0.0.1:5432/example_db?sslmode=disable"
```
以上用户名、密码和数据库名都是占位值,地址与端口也需按实际环境填写。
- `GOOSE_DRIVER`:数据库驱动;连接 PostgreSQL 时使用 `postgres`。
- `GOOSE_MIGRATION_DIR`:迁移文件目录。
- `GOOSE_DBSTRING`:数据库连接串。Goose 不会自动从应用配置文件或零散的应用环境变量中拼装它。
- Goose 默认读取 `.env`;本文统一使用 `-env=.env.local` 显式指定配置文件。
- `sslmode=disable` 适用于本地无 TLS 要求的场景;其他环境按实际 TLS 要求配置。
将含凭据的配置文件加入版本控制忽略规则。类 Unix 系统还可限制文件权限:
```bash
chmod 600 .env.local
```
系统中已有的同名环境变量可能优先于配置文件生效。修改文件后连接行为未改变时,可只列出相关变量名排查,避免打印连接串:
```bash
env | cut -d= -f1 | grep -i '^GOOSE'
```
## 4. 连接串中的 URL 编码
使用 URL 格式的连接串时,用户名、密码中的特殊字符需要进行百分号编码。
| 字符 | 编码 | 字符 | 编码 |
|---|---|---|---|
| `@` | `%40` | `:` | `%3A` |
| `#` | `%23` | `/` | `%2F` |
| `%` | `%25` | `?` | `%3F` |
例如,演示密码 `demo#pass` 在连接串中写成 `demo%23pass`。
- 分别编码用户名和密码,不要编码整个 URL。
- 避免对已编码的内容重复编码。
- 编码只改变连接串的写法,不改变数据库中的实际密码。
- 不要将真实密码提交给在线编码网站。
## 5. 常用命令
```bash
# 查看 Goose 工具版本
goose -version
# 离线检查迁移文件是否能被解析
goose -env=.env.local validate
# 查看迁移状态与数据库当前迁移版本
goose -env=.env.local status
goose -env=.env.local version
# 应用尚未执行的迁移
goose -env=.env.local up
# 回滚最近一个已应用的迁移;执行前检查对应 Down SQL
goose -env=.env.local down
```
`validate` 检查注解与语句拆分,不连接数据库,也不验证 SQL 在 PostgreSQL 上的语义。校验通过不代表数据库连接正常或迁移一定执行成功。
在 v3.29.0 中,`validate` 不接受位置路径参数。迁移目录通过 `GOOSE_MIGRATION_DIR` 或 `-dir` 指定:
```bash
goose -env=.env.local -dir=./migrations validate
```
## 6. 创建与执行迁移
### 创建迁移文件
```bash
goose -env=.env.local -s create create_example_items sql
```
`-s` 表示使用顺序编号,版本号按目录中已有迁移延续。例如,目录为空时,首个文件可命名为:
```text
migrations/00001_create_example_items.sql
```
### 编写 Up 与 Down
下面用一张示例表演示迁移结构:
```sql
-- +goose Up
CREATE TABLE public.example_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
-- +goose Down
DROP TABLE public.example_items;
```
该示例的 Down 会删除表及其中的数据。
### 检查并执行
```bash
goose -env=.env.local validate
goose -env=.env.local status
goose -env=.env.local up
goose -env=.env.local status
goose -env=.env.local version
```
执行前确认目标数据库已创建,且待创建的对象不存在。`status` 中的 `Pending` 表示对应迁移尚未应用。
Goose 会记录成功应用的迁移;再次执行 `up` 时,已应用版本会被跳过。
## 7. 变更与回滚原则
- **已执行的迁移文件不再修改**;后续结构变更通过新增迁移实现。
- 为需要回滚的迁移编写对应 Down,并检查它能否正确撤销结构变更。
- `down` 只回滚最近一个已应用迁移,不是清库或重建数据库的通用命令。
- 删除表或字段可能丢失数据;反向 SQL 不等于数据恢复。
- 回滚前阅读对应 SQL、评估影响并备份数据库。
- 避免手工执行与迁移文件重复的建表脚本,以免对象和版本记录不一致。
## 8. 常见问题
| 报错或现象 | 排查方向 |
|---|---|
| 找不到 `goose` 命令 | 检查安装是否成功,以及实际安装目录是否已加入 `PATH` |
| `hostname resolving error`,主机名混入非法字符 | 检查连接串分隔符,以及用户名、密码的 URL 编码 |
| `password authentication failed` | 核对用户名、密码及编码;检查系统同名环境变量是否覆盖配置 |
| `connection refused` / `i/o timeout` | 检查监听地址、端口、容器端口映射和防火墙 |
| `already exists` | 检查对象是否已被手工创建;先比较现有结构与迁移内容,再确定处理方式 |
| 修改 `.env.local` 后配置似乎未生效 | 确认使用了 `-env=.env.local`,并排查系统中已有的同名环境变量 |
遇到对象已存在的问题,不要直接删除现有数据,也不要手工插入版本记录来伪造迁移状态。
@@ -0,0 +1,12 @@
常见图片格式的核心区别,主要在于 **是否压缩、是否支持透明、是否支持矢量、文件大小、适合什么场景**
| 格式 | 类型 | 透明背景 | 动画 | 放大是否模糊 | 文件大小 | 常见用途 |
| -------------- | ---- | ---------- | ------ | ------------ | -------- | ------------------------ |
| **JPG / JPEG** | 位图 | ❌ | ❌ | ✅ 会模糊 | 小 | 照片、摄影、电商图 |
| **PNG** | 位图 | ✅ | ❌ | ✅ 会模糊 | 中~大 | Logo、截图、透明图片、UI |
| **SVG** | 矢量 | ✅ | 可实现 | ❌ 不模糊 | 通常很小 | Logo、图标、网页插画 |
| **WebP** | 位图 | ✅ | ✅ | ✅ 会模糊 | 很小 | 网站图片、App 图片 |
| **GIF** | 位图 | ✅但有限 | ✅ | ✅ 会模糊 | 通常较大 | 简单动图、表情包 |
| **AVIF** | 位图 | ✅ | ✅ | ✅ 会模糊 | 很小 | 新一代网页图片 |
| **BMP** | 位图 | 通常不支持 | ❌ | ✅ 会模糊 | 非常大 | 较少使用 |
| **TIFF** | 位图 | 视情况 | ❌ | ✅ 会模糊 | 很大 | 印刷、扫描、专业摄影 |
+32
View File
@@ -0,0 +1,32 @@
# TypeScript / JavaScript 练习区
本目录是 TS/JS 的学习 / 练手代码。与 Go 那边共用一个 module 不同,这里**每个实验是独立的 npm 项目**(各自的 `package.json`、lock 文件)。
## 约定
- **每个叶子目录 = 一个独立实验**:自带 `package.json`,依赖自行管理。
- 多个实验并存互不冲突,框架和版本可以各不相同(一个 Astro 4、一个 Astro 5 都行)。
- `node_modules/`、构建产物(`dist/`、`.astro/` 等)已在根 `.gitignore` 忽略,不会提交。
## 主题目录
| 目录 | 放什么 |
|---|---|
| `basics/` | 语法基础、类型系统、ES 新特性 |
| `web/` | 前端框架:Next.js、Astro、Vite 等 |
| `node/` | Node.js 后端练习(http、fs、CLI…) |
| `testing/` | 测试机制(Vitest、Playwright…) |
| `project/` | 完整小项目(多文件) |
> 主题目录可随时增删。写新实验时挑最贴的主题,在里面建一个新子目录即可。
## 新建一个 Astro 实验
```bash
cd ts/web/astro
npm create astro@latest my-lab -- --template basics --no-git --install
cd my-lab
npm run dev
```
> `my-lab` 换成实验名;想完全交互式地选模板/TypeScript 严格度,去掉 `--` 后面的参数即可。
+42
View File
@@ -0,0 +1,42 @@
# VCS
.git
.gitignore
.gitattributes
# Dependencies & build artifacts (rebuilt inside the image)
node_modules
.next
out
coverage
.vercel
.turbo
# Env files & secrets — never ship credentials into the build context
.env
.env.*
!.env.example
*.pem
*.key
*.p12
credentials*.json
secrets*
# Logs & OS noise
*.log
npm-debug.log*
pnpm-debug.log*
yarn-error.log*
.DS_Store
Thumbs.db
# Docker files themselves
Dockerfile
.dockerignore
# IDE & editor
.idea
.vscode
# Non-build project content
docs
.github

Some files were not shown because too many files have changed in this diff Show More