From 39aaa384ba01a81584637ef393fdd99ae06af25e Mon Sep 17 00:00:00 2001
From: huanghaosheng <386998068@qq.com>
Date: Tue, 28 Apr 2026 20:23:33 +0800
Subject: [PATCH] vault backup: 2026-04-28 20:23:33
---
hhs/GORM/04-条件查询.md | 286 ++++++++++++++++++++++++++++
hhs/GORM/05-关联查询.md | 340 ++++++++++++++++++++++++++++++++++
hhs/GORM/06-排序与分页.md | 275 +++++++++++++++++++++++++++
hhs/GORM/07-子查询与分组.md | 290 +++++++++++++++++++++++++++++
hhs/GORM/08-事务管理.md | 312 +++++++++++++++++++++++++++++++
hhs/GORM/09-钩子函数.md | 225 ++++++++++++++++++++++
hhs/GORM/10-软删除.md | 270 +++++++++++++++++++++++++++
hhs/GORM/11-批量操作.md | 293 +++++++++++++++++++++++++++++
hhs/GORM/12-自定义字段类型.md | 263 ++++++++++++++++++++++++++
hhs/GORM/13-多数据库支持.md | 224 ++++++++++++++++++++++
hhs/GORM/14-错误处理.md | 271 +++++++++++++++++++++++++++
hhs/GORM/15-性能优化.md | 324 ++++++++++++++++++++++++++++++++
hhs/GORM/16-日志与调试.md | 266 ++++++++++++++++++++++++++
hhs/GORM/17-迁移工具.md | 339 +++++++++++++++++++++++++++++++++
hhs/GORM/README.md | 28 +--
15 files changed, 3992 insertions(+), 14 deletions(-)
create mode 100644 hhs/GORM/04-条件查询.md
create mode 100644 hhs/GORM/05-关联查询.md
create mode 100644 hhs/GORM/06-排序与分页.md
create mode 100644 hhs/GORM/07-子查询与分组.md
create mode 100644 hhs/GORM/08-事务管理.md
create mode 100644 hhs/GORM/09-钩子函数.md
create mode 100644 hhs/GORM/10-软删除.md
create mode 100644 hhs/GORM/11-批量操作.md
create mode 100644 hhs/GORM/12-自定义字段类型.md
create mode 100644 hhs/GORM/13-多数据库支持.md
create mode 100644 hhs/GORM/14-错误处理.md
create mode 100644 hhs/GORM/15-性能优化.md
create mode 100644 hhs/GORM/16-日志与调试.md
create mode 100644 hhs/GORM/17-迁移工具.md
diff --git a/hhs/GORM/04-条件查询.md b/hhs/GORM/04-条件查询.md
new file mode 100644
index 0000000..a5edd3e
--- /dev/null
+++ b/hhs/GORM/04-条件查询.md
@@ -0,0 +1,286 @@
+---
+tags: [GORM, Go, ORM, 查询, Where, Between, In, Like, Or, Not]
+create time: 2026-04-28 00:00
+---
+
+# 条件查询
+
+## 概述
+
+条件查询是日常开发中最频繁的数据库操作——从「找出某个用户」到「筛选过去七天活跃的用户」,本质上都是构造 WHERE 子句。GORM 提供了链式的条件 API,让你用 Go 类型安全地构建 SQL 查询,而不是拼接脆弱的字符串。
+
+> [!tip] 核心原则
+> GORM 的每个条件方法(`Where` / `Or` / `Not` 等)都返回一个新的 `*gorm.DB` 实例——**不会修改原始 DB**。这意味着你可以反复复用同一个基础查询,像拼乐高一样叠加不同的过滤条件。
+
+## Where 链式调用
+
+### 基本用法
+
+```go
+// 单条件
+db.Where("name = ?", "john").Find(&users)
+// SELECT * FROM users WHERE name = 'john';
+
+// 多条件(AND 关系)
+db.Where("name = ? AND age >= ?", "john", 20).Find(&users)
+// SELECT * FROM users WHERE name = 'john' AND age >= 20;
+```
+
+> [!warning] SQL 注入防护
+> **永远不要**把用户输入直接拼进字符串:
+> ```go
+> // ❌ 危险!用户可注入 `' OR '1'='1`
+> db.Where("name = '" + userInput + "'").Find(&users)
+>
+> // ✅ 正确 —— GORM 会自动参数化
+> db.Where("name = ?", userInput).Find(&users)
+> ```
+
+### 结构体条件
+
+GORM 支持直接用 struct 作为查询条件——非零值字段会被自动转为 `key = value`:
+
+```go
+// 查找姓名为 "john" 且年龄大于等于 20 的用户
+db.Where(&User{Name: "john", Age: 20}).Find(&users)
+// SELECT * FROM users WHERE name = 'john' AND age = 20;
+
+// map 方式(键为字段名或列名)
+db.Where(map[string]any{"name": "john", "age": 20}).Find(&users)
+// SELECT * FROM users WHERE name = 'john' AND age = 20;
+```
+
+> [!question] 为什么结构体条件用的是 `=` 而非 `>=`?
+> 因为 `20` 就是字面值,不存在比较运算符的问题。如果要做范围查询,需要用 Map 方式配合自定义 key:
+> ```go
+> db.Where("age > ?", 20).Find(&users)
+> // 或者
+> db.Where(map[string]any{"age >": 20}).Find(&users) // GORM 会把 ">"" 直接放到 SQL 中
+> ```
+
+### 结构化 WHERE 子句
+
+对于复杂的嵌套条件,可以用 `map` + `[]any` 组合构建:
+
+```go
+// (name = john AND age >= 20) OR (role = admin)
+db.Where(
+ "name = ? AND age >= ?", "john", 20,
+).Or(
+ "role = ?", "admin",
+).Find(&users)
+// SELECT * FROM users WHERE (name = 'john' AND age >= 20) OR (role = 'admin');
+```
+
+> [!tip] 括号处理
+> GORM 会自动为 `Where` + `Or` 组合加括号包裹 WHERE 部分,保证运算优先级正确。但如果你需要更精细的控制,可以手动写完整表达式:
+> ```go
+> db.Where("(name = ? OR name = ?) AND age > ?", "john", "jane", 18).Find(&users)
+> ```
+
+## 常用条件操作符
+
+### In / Not In
+
+```go
+// IN 查询
+db.Where("id IN (?)", []int{1, 2, 3}).Find(&users)
+// SELECT * FROM users WHERE id IN (1, 2, 3);
+
+// 批量删除
+db.Where("id IN (?)", []int{1, 2, 3}).Delete(&User{})
+
+// NOT IN
+db.Not("id IN (?)", []int{1, 2, 3}).Find(&users)
+// SELECT * FROM users WHERE id NOT IN (1, 2, 3);
+```
+
+> [!tip] 空切片陷阱
+> `[]int{}`(空切片)传进 `IN (?)` 时,GORM 会生成 `IN ()` —— 这是非法 SQL。**使用前务必校验切片的长度**:
+> ```go
+> ids := []int{1, 2, 3}
+> if len(ids) == 0 {
+> return nil, nil // 没有 ID,直接返回空结果
+> }
+> db.Where("id IN (?)", ids).Find(&users)
+> ```
+
+### Between / Not Between
+
+```go
+// 范围查询
+db.Where("age BETWEEN ? AND ?", 18, 60).Find(&users)
+// SELECT * FROM users WHERE age BETWEEN 18 AND 60;
+
+// Go 风格写法(等价)
+db.Where("age >= ? AND age <= ?", 18, 60).Find(&users)
+
+// 时间范围(按天查最近 7 天的订单)
+startTime := time.Now().AddDate(0, 0, -7)
+db.Where("created_at BETWEEN ? AND ?", startTime, time.Now()).Find(&orders)
+```
+
+### Like 模糊匹配
+
+```go
+// 前缀匹配
+db.Where("name LIKE ?", "john%").Find(&users) // 以 john 开头
+// SELECT * FROM users WHERE name LIKE 'john%';
+
+// 后缀匹配
+db.Where("email LIKE ?", "%@gmail.com").Find(&users) // gmail 邮箱
+
+// 包含匹配
+db.Where("title LIKE ?", "%go编程%").Find(&articles) // 标题包含关键词
+```
+
+> [!warning] LIKE 的性能问题
+> `%xxx%` 这种前后通配会导致索引失效——数据库无法使用 B+ 树的有序性,退化成全表扫描。当数据量超过 10 万行时,考虑改用专门的搜索引擎(如 Elasticsearch)。
+
+### Is NULL / Is NOT NULL
+
+```go
+// 查字段为 NULL 的记录
+db.Where("deleted_at IS NULL").Find(&users)
+// 注意:这其实是 GORM 软删除的默认行为,通常不用显式写
+
+// 查字段不为 NULL
+db.Where("phone IS NOT NULL").Find(&users)
+
+// GORM 简洁写法
+db.Where("phone ?", "not null").Find(&users)
+```
+
+### Not 排除查询
+
+```go
+// 排除特定状态的用户
+db.Not("status", "banned").Find(&users)
+// SELECT * FROM users WHERE status != 'banned';
+
+// 排除多个值
+db.Not([]string{"banned", "inactive"}).Find(&users)
+// SELECT * FROM users WHERE status NOT IN ('banned', 'inactive');
+
+// 多字段排除
+db.Not(map[string]any{"name": "john", "role": "admin"}).Find(&users)
+// SELECT * FROM users WHERE name != 'john' AND role != 'admin';
+```
+
+### Or 条件组合
+
+```go
+// 找 VIP 用户或注册时间超过一年的用户
+db.Where("role = ?", "vip").Or("created_at < ?", time.Now().AddDate(-1, 0, 0)).Find(&users)
+// SELECT * FROM users WHERE (role = 'vip') OR (created_at < '2025-04-28...');
+```
+
+> [!example] Where 和 Or 的执行顺序
+> GORM 的条件是「左结合」的。下面两种写法的语义不同:
+>
+> ```go
+> // 写法 A: (a AND b) OR c
+> db.Where("a = 1").Where("b = 2").Or("c = 3")
+>
+> // 写法 B: a AND (b OR c)
+> db.Where("a = 1").Or("b = 2 AND c = 3")
+> ```
+> 建议在复杂场景下用 `()` 显式控制优先级,让意图一目了然。
+
+## 高级条件构造
+
+### 原始 SQL
+
+当 GORM 提供的 DSL 不够用时,可以直接写原生 SQL:
+
+```go
+// 使用 ? 占位符(安全,防止注入)
+db.Where("UNIX_TIMESTAMP(created_at) > ?", time.Now().AddDate(0,0,-1).Unix()).Find(&users)
+
+// 使用 gorm.Expr 处理更复杂的表达式
+db.Where("price * ? > ?", 0.8, 100).Find(&products) // price * 0.8 > 100
+```
+
+### Row / Scan 直接执行
+
+```go
+// 执行任意查询并扫描到自定义变量
+var count int
+db.Raw("SELECT COUNT(*) FROM users WHERE status = ?", "active").Scan(&count)
+
+// Query 返回 *sql.Row / *sql.Rows,适合更底层的操作
+rows, err := db.Raw("SELECT id, name FROM users").Rows()
+defer rows.Close()
+for rows.Next() {
+ var id int
+ var name string
+ rows.Scan(&id, &name)
+ // ...
+}
+```
+
+> [!important] 资源管理
+> `Rows` 对象持有数据库连接,使用完毕后**必须**调用 `rows.Close()`,否则会导致连接泄漏。如果只需要单行结果,优先用 `Raw(...).Scan(&dest)`。
+
+### Condition 对象(条件复用)
+
+```go
+cond := gorm.Condition.Where("status = ?", "active").Or("status = ?", "pending")
+db.Where(cond).Find(&users)
+
+// 多个条件组合复用
+activeCount := db.Model(&User{}).Where(cond).Count()
+activeUsers := db.Where(cond).Order("created_at DESC").Limit(10).Find(&users)
+```
+
+> [!tip] 何时使用 Condition 对象?
+> 当你需要在多个查询中重复使用同一组条件时(比如项目中所有业务模块都要「只查活跃数据」),把它抽成全局 Condition 变量,避免到处复制粘贴 SQL 片段。
+
+## 条件查询决策图
+
+```mermaid
+flowchart TD
+ Start[收到查询请求] --> Type{条件类型_}
+
+ Type --> |精确匹配| Exact["Where(field, value)"]
+ Type --> |范围查询| RangeQ{BETWEEN 还是 _}
+ RangeQ --> |GORM 自带| Between["BETWEEN ? AND ?"]
+ RangeQ --> |自定义> < |=LtGt["where field > ? AND field < ?"]
+ Type --> |集合| SetQ{IN 还是 NOT IN_}
+ SetQ --> |IN| InOp["In field VALUES"]
+ SetQ --> |NOT IN| NotInOp["Not In field VALUES"]
+ Type --> |模糊| LikeQ{前缀/后缀/全匹配_}
+ LikeQ --> |前缀%| PrefixLike["LIKE 'prefix%'"]
+ LikeQ --> |后缀%| SuffixLike["LIKE '%suffix'"]
+ LikeQ --> |全匹配%| FullLike["LIKE '%keyword%'"]
+ Type --> |NULL检查| NullQ{IS NULL 还是 _}
+ NullQ --> |IS NULL|IsNull["IS NULL"]
+ NullQ --> |IS NOT NULL| IsNotNull["IS NOT NULL"]
+ Type --> |复合条件| AndQ{简单AND 还是 OR混合_}
+ AndQ --> |简单AND| SimpleAnd["连续 Where 调用"]
+ AndQ --> |OR混合| OrChain["Where + Or 链式"]
+ Type --> |特殊SQL| Raw["Raw + Expr"]
+
+ style Start fill:#4FC08D,color:#fff
+ style Exact fill:#3B82F6,color:#fff
+ style LikeOp fill:#F59E0B,color:#000
+ style Raw fill:#EC4899,color:#fff
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 空切片传入 `Where("id IN (?)", ids)` | 生成 `IN ()` 非法 SQL | 先用 `len(ids) > 0` 判断 |
+| LIKE 查询太慢 | 索引失效导致全表扫描 | 大数据量改用全文检索引擎 |
+| 更新零值字段没生效 | `Updates(struct)` 跳过零值 | 改用 `Updates(map)` |
+| Or 条件没有预期效果 | 运算符优先级问题 | 用 `()` 显式分组 |
+| Rows 没有 Close | 连接泄漏 | `defer rows.Close()` |
+
+## 关联笔记
+
+- [[01-安装与初始化]]
+- [[02-模型定义]]
+- [[03-CRUD 操作]]
+- [[05-关联查询]]
+- [[06-排序与分页]]
diff --git a/hhs/GORM/05-关联查询.md b/hhs/GORM/05-关联查询.md
new file mode 100644
index 0000000..155eb12
--- /dev/null
+++ b/hhs/GORM/05-关联查询.md
@@ -0,0 +1,340 @@
+---
+tags: [GORM, Go, ORM, 关联查询, Preload, Joins, HasOne, HasMany, BelongsTo, ManyToMany]
+create time: 2026-04-28 00:00
+---
+
+# 关联查询
+
+## 概述
+
+现实世界的数据从不孤立存在——用户有订单、订单包含商品、商品属于分类。如何在一次或多次查询中高效地加载这些关联数据,是 ORM 的核心能力。
+
+GORM 提供了两种主要的关联加载方式:
+- **N+1 预加载**(`Preload`):额外发 N+1 条 SQL,批量填充关联
+- **JOIN 单查询**(`Joins`):一条复杂 SQL 拿全量数据
+
+理解它们的区别和适用场景,可以避免常见的性能陷阱。
+
+> [!tip] 核心原则
+> GORM 的关联查询有两种策略:**Eager Loading**(预加载)和 **Lazy Loading**(按需加载)。默认情况下,GORM **不会自动加载关联数据**——你必须显式调用 `Preload` 或 `Joins`,这给了你完全的控制权。
+
+## 关联类型概览
+
+```mermaid
+flowchart LR
+ User["User"] -->|HasMany| Order["Order"]
+ User -->|BelongsTo| Role["Role"]
+ Order -->|HasOne| Detail["OrderDetail"]
+ Order -->|ManyToMany| Product["Product"]
+
+ style User fill:#3B82F6,color:#fff
+ style Order fill:#4FC08D,color:#fff
+ style Role fill:#F59E0B,color:#000
+ style Detail fill:#A0AEC0,color:#fff
+ style Product fill:#EC4899,color:#fff
+
+ classDef foreignKey color:#EF4444;
+```
+
+| 关联类型 | 外键位置 | 关系描述 | 示例 |
+|----------|---------|---------|------|
+| **HasOne** | 被关联方 | A 有一条唯一的 B | User → Profile |
+| **HasMany** | 被关联方 | A 有多条 B | User → Orders |
+| **BelongsTo** | 当前方 | A 属于某个 B | Order → User |
+| **ManyToMany** | 中间表 | A 和 B 多对多 | Order ↔ Product |
+
+## 前置准备:定义关联
+
+在能查之前,先要在 struct tag 里声明关联关系:
+
+```go
+type User struct {
+ ID uint `gorm:"primaryKey"`
+ Name string `gorm:"size:64"`
+ Profile Profile `gorm:"foreignKey:UserID"` // HasOne
+ Orders []Order `gorm:"foreignKey:UserID"` // HasMany
+ Roles []Role `gorm:"many2many:user_roles"` // ManyToMany
+}
+
+type Profile struct {
+ ID uint `gorm:"primaryKey"`
+ UserID uint `gorm:"index;uniqueIndex"` // ForeignKey
+ AvatarURL string `gorm:"size:256"`
+}
+
+type Order struct {
+ ID uint `gorm:"primaryKey"`
+ UserID uint `gorm:"index"` // ForeignKey(BelongsTo)
+ Amount float64
+ Products []Product `gorm:"many2many:order_products"` // ManyToMany
+}
+
+type Product struct {
+ ID uint `gorm:"primaryKey"`
+ Name string `gorm:"size:128"`
+ Price float64
+}
+
+type Role struct {
+ ID uint `gorm:"primaryKey"`
+ Name string `gorm:"size:64;uniqueIndex"`
+}
+```
+
+> [!note] GORM 的外键推断规则
+> 如果没有显式指定 `foreignKey`,GORM 会用「关联模型名 + 主键」来推断。例如 `Profile` 模型的 `UserID` 会被认为是指向 `User.ID` 的外键。显式声明的好处是**语义清晰、不易出错**。
+
+## Preload — 预加载关联数据
+
+`Preload` 是最常用的关联加载方式。它在原始查询之后,用额外的 SQL 语句批量填充关联字段。
+
+### 基本用法
+
+```go
+// 加载 User 及其 HasOne Profile
+var user User
+db.Preload("Profile").First(&user, 1)
+// 执行的 SQL:
+// SELECT * FROM users WHERE id = 1 LIMIT 1;
+// SELECT * FROM profiles WHERE user_id = 1;
+
+// 加载 User 及其 HasMany Orders
+db.Preload("Orders").Find(&users)
+// 执行的 SQL:
+// SELECT * FROM users;
+// SELECT * FROM orders WHERE user_id IN (1, 2, 3, ...);
+```
+
+> [!important] 为什么叫 N+1?
+> 第 1 条 SQL 查主表(n 个用户),后面 N 条 SQL 查关联表。当关联只有一条时就是「2 条 SQL」;如果关联是多条且每个用户关联不同数据,就需要额外查询——所以需要控制关联的深度和范围。
+
+### 链式 Preload
+
+```go
+// 同时加载多个层级的关联
+db.Preload("Profile").
+ Preload("Orders").
+ Preload("Orders.Products"). // 级联加载 —— 订单里的商品
+ Find(&users)
+
+// 执行的 SQL:
+// 1. SELECT * FROM users;
+// 2. SELECT * FROM profiles WHERE user_id IN (...);
+// 3. SELECT * FROM orders WHERE user_id IN (...);
+// 4. SELECT * FROM order_products WHERE order_id IN (...);
+// 5. SELECT * FROM products WHERE id IN (...);
+```
+
+> [!tip] 层级命名
+> `Orders.Products` 使用点号分隔层级——GORM 会自动遍历这条路径。嵌套可以无限深,但要小心**笛卡尔积爆炸**:深层嵌套 + 大量数据会生成极慢的 JOIN 查询(如果用 Joins 方式)或多次大结果集的 IN 查询(如果用 Preload)。
+
+### 带条件的 Preload
+
+并非所有关联数据都需要全量加载。可以用函数形式的 `Preload` 过滤:
+
+```go
+// 只加载金额大于 100 的订单
+db.Preload("Orders", "amount > ?", 100).Find(&users)
+
+// 更精细控制:WHERE + ORDER BY
+db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
+ return db.Where("amount > ?", 100).Order("created_at DESC")
+}).Find(&users)
+
+// 限制关联表的字段(减少传输开销)
+db.Preload("Orders", "id, amount, status").
+ Preload("Orders.Products", "id, name").
+ Find(&users)
+```
+
+> [!question] 思考题
+> 如果给 Preload 加了条件,但用户没有任何符合条件的关联记录会发生什么?
+>
+> > **答案**:关联字段会是空切片/零值结构体,不会报错。这是符合直觉的行为——只是「没找到」而非「出错了」。
+
+## Preload vs PreloadWith
+
+GORM v1.x 有单独的 `PreloadWith` 方法,但在 v2.x 中已被整合到 `Preload` 的函数式参数里。如果你从旧文档看到 `PreloadWith`,请参考上面的「链式 Preload」写法。
+
+## Joins — 单次 JOIN 查询
+
+与 `Preload` 的多条 SQL 不同,`Joins` 在一条 SQL 中通过 JOIN 获取关联数据:
+
+### 基本用法
+
+```go
+// 只加载一层 Profile
+var user User
+db.Joins("Profile").First(&user, 1)
+// SELECT users.*, profiles.* FROM users
+// LEFT JOIN profiles ON profiles.user_id = users.id
+// WHERE users.id = 1;
+```
+
+### 多层 Joins(嵌套)
+
+```go
+// 加载用户及其订单中的商品信息
+db.Joins("Profile").
+ Joins("JOIN order_items ON order_items.user_id = users.id").
+ Joins("JOIN products ON products.id = order_items.product_id").
+ Where("users.id = ?", 1).
+ Find(&user)
+```
+
+> [!warning] Joins 的注意事项
+> - 使用 LEFT JOIN 还是 INNER JOIN 取决于业务语义:想保留没有关联数据的记录用 `LEFT JOIN`,只想要有关联的用 `JOIN`(等价于 `INNER JOIN`)。
+> - GORM 默认的 `Joins("关联名")` 生成的是 `LEFT JOIN`。
+> - 深度关联下,JOIN 会导致行重复(一个用户有多个订单时会返回多行 User),需要手动去重。
+
+### Joins 条件过滤
+
+```go
+// 只 Join 状态为 active 的 Profile
+db.Joins("Profile", "profiles.status = ?", "active").First(&user, 1)
+```
+
+> [!example] Preload vs Joins 对比
+
+| 维度 | Preload | Joins |
+|------|---------|-------|
+| SQL 数量 | N+1 条 | 通常 1 条 |
+| 关联数据过滤 | 容易(WHERE 作为第二参数) | 较难(写在 JOIN 子句中) |
+| 关联字段选择 | 支持(直接写列名) | 需自定义 SELECT |
+| 适合场景 | 大多数场景,可控性好 | 只需少量关联字段的简单查询 |
+| 大数据量风险 | 外层 IN (...) 过大 | 行膨胀、内存占用高 |
+
+> [!tip] 经验法则
+> 「**优先 Preload,必要时 Joins**。」Preload 的可控性和可预测性更好;只在确实需要减少 SQL 数量、且关联数据量很小时才用 Joins。
+
+## HasOne 详细示例
+
+```go
+// 查询用户的 Profile
+var user User
+db.Preload("Profile").First(&user, 1)
+fmt.Println(user.Profile.AvatarURL) // 可直接访问,无需二次查询
+
+// 创建用户并同时创建 Profile
+db.Create(&User{
+ Name: "Alice",
+ Profile: Profile{AvatarURL: "https://example.com/avatar.png"},
+})
+// INSERT INTO users ...; INSERT INTO profiles ...
+```
+
+## HasMany 详细示例
+
+```go
+// 查询用户的所有订单
+var user User
+db.Preload("Orders").First(&user, 1)
+for _, order := range user.Orders {
+ fmt.Printf("订单 %d: ¥%.2f\n", order.ID, order.Amount)
+}
+
+// 查询某用户的所有订单金额总和
+var total float64
+db.Model(&user).Association("Orders").Count(&total) // 这会统计行数,不是金额总和
+// 正确做法:
+db.Model(&user).Select("COALESCE(SUM(amount), 0)").Scan(&total)
+```
+
+## BelongsTo 详细示例
+
+```go
+// 查询订单所属的用户
+var order Order
+db.Preload("User").First(&order, 1)
+fmt.Println(order.User.Name) // Alice
+
+// 更新订单的用户关联
+order.UserID = 2
+db.Save(&order) // UPDATE orders SET user_id=2 WHERE id=1
+```
+
+## ManyToMany 详细示例
+
+```go
+// 查询订单包含的商品
+var order Order
+db.Preload("Products").First(&order, 1)
+// GORM 自动管理中间表 order_products:
+// SELECT * FROM orders WHERE id = 1;
+// SELECT * FROM products p
+// INNER JOIN order_products op ON op.product_id = p.id
+// WHERE op.order_id = 1;
+
+// 给订单添加商品
+product := Product{Name: "Go Programming", Price: 99}
+db.Model(&order).Association("Products").Append(&product)
+// INSERT INTO products ...; INSERT INTO order_products ...
+
+// 批量添加
+products := []Product{{Name: "SQL Essentials", Price: 79}, {Name: "Linux DevOps", Price: 59}}
+db.Model(&order).Association("Products").Append(products)
+
+// 替换全部商品(移除旧的关联)
+newProducts := []Product{{Name: "Kubernetes in Action", Price: 109}}
+db.Model(&order).Association("Products").Replace(newProducts)
+
+// 删除特定关联
+db.Model(&order).Association("Products").Delete(&product)
+
+// 清理所有关联
+db.Model(&order).Association("Products").Clear()
+
+// 获取关联数
+count, _ := db.Model(&order).Association("Products").Count()
+```
+
+> [!important] Association API 一览
+>
+> | 方法 | 行为 | SQL 效果 |
+> |------|------|---------|
+> | `Append` | 追加新关联 | INSERT 中间表记录 |
+> | `Replace` | 替换全部(删旧加新) | TRUNCATE + INSERT |
+> | `Delete` | 删除指定关联 | DELETE 中间表记录 |
+> | `Clear` | 清空所有关联 | DELETE 全部中间表记录 |
+> | `Count` | 统计关联数量 | COUNT(*) |
+
+## 关联查询决策图
+
+```mermaid
+flowchart TD
+ Start[需要加载关联数据] --> Depth{关联层级_}
+
+ Depth --> |单层浅查询| SimpleQ{数据量大吗_}
+ SimpleQ --> |否| JoinsSingle["Joins — 单条 SQL"]
+ SimpleQ --> |是| PreloadSingle["Preload — 多条小 SQL"]
+
+ Depth --> |多层级联| MultiQ{每层数据量_}
+ MultiQ --> |都很小| DeepJoins["嵌套 Joins"]
+ MultiQ --> |某层较大| DeepPreload["Preload 链式 + 条件过滤"]
+ MultiQ --> |不清楚| PreloadSafe["Preload(安全首选)"]
+
+ Depth --> |只需要计数/特定字段| SpecificField["Selection 或 Count"]
+
+ style Start fill:#4FC08D,color:#fff
+ style PreloadSafe fill:#3B82F6,color:#fff
+ style JoinsSingle fill:#F59E0B,color:#000
+ style DeepPreload fill:#8B5CF6,color:#fff
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 预加载很慢 | N+1 导致过多 SQL 或 IN 列表过大 | 改用 `Joins` 或用 BatchQuery 分片 |
+| Preload 后数据为空 | 关联字段名不匹配 struct 标签 | 检查 `foreignKey` / `_associationName` 拼写 |
+| ManyToMany 新增已存在记录报错 | 唯一约束冲突 | 先用 `Count()` 判断或使用 `Clauses(clause.OnConflict{DoNothing: true})` |
+| Joins 导致行重复 | LEFT JOIN 产生笛卡尔积 | 去重或用 `Distinct` |
+| 加载了不必要的关联字段 | 浪费带宽和内存 | 用 Preload 字段白名单:`Preload("Orders", "id,status,amount")` |
+
+## 关联笔记
+
+- [[02-模型定义]]
+- [[03-CRUD 操作]]
+- [[04-条件查询]]
+- [[06-排序与分页]]
+- [[15-性能优化]]
diff --git a/hhs/GORM/06-排序与分页.md b/hhs/GORM/06-排序与分页.md
new file mode 100644
index 0000000..2324311
--- /dev/null
+++ b/hhs/GORM/06-排序与分页.md
@@ -0,0 +1,275 @@
+---
+tags: [GORM, Go, ORM, 排序, 分页, Order, Limit, Offset, Paginate]
+create time: 2026-04-28 00:00
+---
+
+# 排序与分页
+
+## 概述
+
+排序和分页是面向用户的数据展示层的核心技能。不管后端查出多少数据,最终呈现在页面上的总是「一页」——理解 GORM 如何高效地完成这个任务,直接影响 API 的响应时间和用户体验。
+
+## Order — 排序
+
+### 基本用法
+
+```go
+// 单字段升序
+db.Order("age ASC").Find(&users)
+// SELECT * FROM users ORDER BY age ASC;
+
+// 多字段排序(先按年龄降序,年龄相同则按创建时间升序)
+db.Order("age DESC").Order("created_at ASC").Find(&users)
+// SELECT * FROM users ORDER BY age DESC, created_at ASC;
+```
+
+> [!tip] 链式 Order 的行为
+> 多次调用 `Order` 会**追加**排序条件,而不是覆盖。所以上面的写法等价于:
+> ```go
+> db.Order("age DESC, created_at ASC").Find(&users)
+> ```
+
+### 动态排序
+
+当排序字段来自前端参数时,需要小心处理 SQL 注入:
+
+```go
+// ❌ 危险 —— 用户可传入 "age; DROP TABLE users;"
+orderBy := "age" // 来自 query params
+db.Order(orderBy + " DESC").Find(&users)
+
+// ✅ 白名单校验
+allowedOrders := map[string]bool{
+ "age": true,
+ "created_at": true,
+ "name": true,
+ "amount": true,
+}
+field := "created_at" // 来自前端
+if !allowedOrders[field] {
+ field = "created_at" // 默认值
+}
+db.Order(field + " DESC").Find(&users)
+```
+
+> [!important] 为什么不能用占位符传列名?
+> GORM 的 `?` 只支持**值参数化**,不支持标识符(表名、列名、ORDER BY 方向)。这是所有 SQL 驱动的限制——SQL 预编译机制只对参数有效,对结构部分无效。因此必须通过白名单或其他方式确保列名的安全性。
+
+### 随机排序
+
+```go
+// MySQL
+db.Order("RAND()").Find(&users)
+
+// PostgreSQL
+db.Order("RANDOM()").Find(&users)
+
+// SQLite
+db.Order("RANDOM()").Find(&users)
+```
+
+> [!warning] 随机排序的性能陷阱
+> `ORDER BY RAND()` 会对整张表生成随机数再排序——O(n log n) 复杂度,百万级数据几乎不可用。如果只需要几条随机记录,改用更高效的方案:
+> ```go
+> // 方案一:OFFSET random
+> var count int
+> db.Model(&User{}).Count(&count)
+> offset := rand.Intn(count)
+> db.Limit(10).Offset(offset).Find(&users)
+>
+> // 方案二:先在 Go 中随机选几个 ID,再查
+> ids := randomIDs(count, 10)
+> db.Where("id IN ?", ids).Find(&users)
+> ```
+
+## Limit / Offset — 分页基础
+
+### 基本原理
+
+```go
+// 每页 10 条,第 2 页(offset = (page-1) * limit)
+perPage := 10
+page := 2
+offset := (page - 1) * perPage
+
+var products []Product
+db.Limit(perPage).Offset(offset).Order("id").Find(&products)
+// SELECT * FROM products ORDER BY id LIMIT 10 OFFSET 10;
+```
+
+> [!example] 偏移量计算速查
+> | 页码 | 每页数 | Offset 计算 | 结果 |
+> |------|--------|------------|------|
+> | 第 1 页 | 10 | (1-1) × 10 = **0** | 前 10 条 |
+> | 第 2 页 | 10 | (2-1) × 10 = **10** | 第 11-20 条 |
+> | 第 5 页 | 10 | (5-1) × 10 = **40** | 第 41-50 条 |
+
+### 查询总行数
+
+```go
+var total int64
+db.Model(&Product{}).Where("status = ?", "active").Count(&total)
+
+perPage := 10
+page := 2
+offset := (page - 1) * perPage
+
+var products []Product
+db.Model(&Product{}).
+ Where("status = ?", "active").
+ Order("id").
+ Limit(perPage).
+ Offset(offset).
+ Find(&products)
+
+// 总页数
+totalPages := int(math.Ceil(float64(total) / float64(perPage)))
+```
+
+> [!question] 思考题
+> 如果 offset 极大(比如第 10000 页),查询会变得很慢,为什么?有没有更好的方案?
+>
+> > **答案**:因为数据库仍然需要扫描并跳过前面大量的行,即使它们不会被返回。更好的方案是用 **游标分页(Keyset Pagination)**——按上一次最后一条记录的 id 来查,详见下面的游标分页章节。
+
+## 游标分页(Keyset Pagination)
+
+传统的 `LIMIT/OFFSET` 分页在深页性能急剧下降。游标分页通过「记住上一页最后一条记录的位置」来实现 O(log n) 的跳转:
+
+```go
+// 第一页:没有 cursor,直接取前 N 条
+cursor := "" // 空表示从头开始
+perPage := 20
+
+var users []User
+query := db.Model(&User{}).Order("id ASC").Limit(perPage + 1) // 多取 1 条判断是否有下一页
+
+if cursor != "" {
+ // 查找 id > cursor 的记录
+ query = query.Where("id > ?", cursor)
+}
+
+err := query.Find(&users).Error
+hasNextPage := false
+
+if len(users) > perPage {
+ hasNextPage = true
+ users = users[:perPage] // 去掉多余的「探测」记录
+ cursor = fmt.Sprint(users[len(users)-1].ID) // 提取新的 cursor
+}
+```
+
+> [!tip] 游标分页的优点
+> - **速度恒定**:无论翻到哪页,都是查紧邻的下一段数据
+> - **不会漏数据**:传统分页在插入新记录时可能漏掉;游标分页每次从明确位置继续
+> - **天然防抖**:cursor 是不可预测的值,无法伪造页码
+>
+> **缺点**:不能跳页(不能说「直接看第 100 页」),适合列表类滚动加载场景。
+
+## 完整分页辅助函数
+
+实际项目中建议封装通用的分页工具:
+
+```go
+type PageResult struct {
+ Data any `json:"data"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+}
+
+func Paginate[T any](db *gorm.DB, where any, order string, page, pageSize int) (*PageResult, error) {
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+
+ var total int64
+ q := db.Model(new(T))
+ if where != nil {
+ q = q.Where(where)
+ }
+ if err := q.Count(&total).Error; err != nil {
+ return nil, err
+ }
+
+ var items []T
+ offset := (page - 1) * pageSize
+ q.Order(order)
+ if err := q.Limit(pageSize).Offset(offset).Find(&items).Error; err != nil {
+ return nil, err
+ }
+
+ return &PageResult{
+ Data: items,
+ Total: total,
+ Page: page,
+ PageSize: pageSize,
+ }, nil
+}
+
+// 使用示例
+result, err := Paginate[User](db, "status = ?", "active", "created_at DESC", page, 20)
+```
+
+## DefaultPageSize 配置
+
+GORM 提供了 `DefaultPageSize` 配置项作为全局兜底:
+
+```go
+db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
+ DefaultPageSize: 20, // 不指定 Limit 时的默认上限
+})
+
+// 配合 Preload 使用时限制关联数量
+db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
+ return db.Limit(db.Session(&gorm.Config{}).DB.Set("gorm:DefaultPageSize", 10).Session(&gorm.Config{}).Find)
+})
+```
+
+> [!info] 注意
+> `DefaultPageSize` **仅在没有显式 `Limit` 时生效**。如果你写了 `db.Limit(0)`,它等同于无限制(不走默认值)。建议在业务层统一做分页控制,不要依赖这个配置。
+
+## 排序与分页决策图
+
+```mermaid
+flowchart TD
+ Start[需要分页/排序数据] --> SortQ{需要自定义排序_}
+ SortQ --> |否| DefaultSort["ORDER BY 主键 ASC
GORM 默认排序"]
+ SortQ --> |是| Whitelist{"字段在白名单中?"}
+ Whitelist --> |是| CustomSort["db.Order(field + direction)"]
+ Whitelist --> |否| DefaultSort
+
+ DefaultSort --> PagesQ{要跳页还是滚动?}
+ CustomSort --> PagesQ
+
+ PagesQ --> |跳页| OffsetPaginate["LIMIT/OFFSET 分页
简单直白"]
+ PagesQ --> |滚动加载| CursorPaginate["游标分页
性能好、防漏数据"]
+
+ OffsetPaginate --> DeepPage{是否深翻页?}
+ DeepPage --> |浅页 ≤ 100| OK["直接用 ✓"]
+ DeepPage --> |深页 > 100| CursorRecommend["推荐改用游标分页"]
+
+ style Start fill:#4FC08D,color:#fff
+ style CursorPaginate fill:#3B82F6,color:#fff
+ style OK fill:#A0AEC0,color:#fff
+ style CursorRecommend fill:#EF4444,color:#fff
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 排序字段被 SQL 注入 | 直接拼接用户输入 | 白名单校验列名 |
+| 深翻页极慢 | OFFSET 需要跳过大量行 | 改用游标分页 |
+| 分页后总数不对 | 并发修改导致 Count 和查询不一致 | 事务内执行或使用快照隔离 |
+| `Limit(0)` 没生效 | 0 被视为无限制 | 加最小校验:`if pageSize < 1 { pageSize = 1 }` |
+| 多表 JOIN + Limit 结果异常 | Limit 作用于 JOIN 后的整行集合 | 拆分为子查询或用 Preload |
+
+## 关联笔记
+
+- [[03-CRUD 操作]]
+- [[04-条件查询]]
+- [[07-子查询与分组]]
+- [[15-性能优化]]
diff --git a/hhs/GORM/07-子查询与分组.md b/hhs/GORM/07-子查询与分组.md
new file mode 100644
index 0000000..514af7a
--- /dev/null
+++ b/hhs/GORM/07-子查询与分组.md
@@ -0,0 +1,290 @@
+---
+tags: [GORM, Go, ORM, 子查询, Group, Having, SubQuery, 聚合]
+create time: 2026-04-28 00:00
+---
+
+# 子查询与分组
+
+## 概述
+
+当简单的单表查询无法满足需求时,就需要用到**子查询**(SQL 里嵌套的 SELECT)和**分组聚合**(GROUP BY + HAVING)。这两类查询通常用于统计分析——比如「找出每个部门收入最高的员工」或「统计过去一个月每天的新增订单数」。
+
+```mermaid
+flowchart TD
+ Start[复杂查询需求] --> Type{哪种场景_}
+
+ Type --> |按维度汇总| GroupBy["GROUP BY + 聚合函数
COUNT / SUM / AVG / MAX / MIN"]
+ Type --> |过滤聚合结果| Having["HAVING 条件
替代 WHERE"]
+ Type --> |嵌套查询| SubQ{"子查询类型?"}
+ SubQ --> |IN/EXISTS 过滤| FilterSub["WHERE field IN (SELECT ...)"]
+ SubQ --> |关联计算| Correlated["相关子查询
外层行影响内层查询"]
+ SubQ --> |派生表| DerivedTable["FROM (SELECT ...) AS t
将子查询作为临时表"]
+
+ style Start fill:#4FC08D,color:#fff
+ style GroupBy fill:#3B82F6,color:#fff
+ style Having fill:#F59E0B,color:#000
+ style DerivedTable fill:#EC4899,color:#fff
+```
+
+## GROUP BY — 分组统计
+
+### 基本用法
+
+GORM 通过 `Select` + `Group` 实现分组:
+
+```go
+// 按部门统计人数
+type DeptStats struct {
+ DeptID uint `gorm:"column:dept_id"`
+ DeptName string `gorm:"column:dept_name"`
+ Count int64 `gorm:"column:count"`
+}
+
+var stats []DeptStats
+db.Model(&User{}).
+ Select("dept_id, dept_name, COUNT(*) as count").
+ Group("dept_id").
+ Find(&stats)
+// SELECT dept_id, dept_name, COUNT(*) as count
+// FROM users GROUP BY dept_id;
+```
+
+### 多字段分组
+
+```go
+// 按部门和月份统计活跃用户数
+type MonthlyDeptStats struct {
+ DeptID uint `gorm:"column:dept_id"`
+ Month string `gorm:"column:month"`
+ ActiveCount int64 `gorm:"column:active_count"`
+}
+
+db.Model(&User{}).
+ Select("dept_id, DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as active_count").
+ Group("dept_id, DATE_FORMAT(created_at, '%Y-%m')").
+ Order("month DESC").
+ Scan(&monthlyStats)
+```
+
+> [!tip] Group 的陷阱
+> GORM 在调用 `Group` 后**默认不再添加任何其他字段**到 SELECT——这是 SQL 标准要求。如果需要额外字段,必须在 `Select` 中显式声明:
+> ```go
+> // ❌ 错误 —— GORM 可能报错或产生不确定的 SELECT
+> db.Model(&User{}).Group("dept_id").Find(&users)
+>
+> // ✅ 正确 —— 明确指定需要的字段
+> db.Model(&User{}).Select("dept_id, MAX(age)").Group("dept_id").Scan(&stats)
+> ```
+
+## HAVING — 过滤分组结果
+
+`WHERE` 过滤的是行级别,`HAVING` 过滤的是**分组后的聚合结果**。两者的执行顺序是 `WHERE → GROUP BY → HAVING`:
+
+```go
+// 找平均薪资大于 10000 的部门
+type AvgDept struct {
+ DeptID uint `gorm:"column:dept_id"`
+ AvgSal float64 `gorm:"column:avg_salary"`
+}
+
+db.Model(&User{}).
+ Select("dept_id, AVG(salary) as avg_salary").
+ Group("dept_id").
+ Having("AVG(salary) > ?", 10000).
+ Scan(&result)
+
+// 组合使用 WHERE 和 HAVING
+// 先过滤入职满 1 年的员工,再按部门分组,最后筛选平均薪资超过 10000 的部门
+db.Model(&User{}).
+ Select("dept_id, COUNT(*) as headcount, AVG(salary) as avg_salary").
+ Where("created_at < ?", time.Now().AddDate(-1, 0, 0)).
+ Group("dept_id").
+ Having("COUNT(*) >= 3"). // 至少 3 人
+ Having("AVG(salary) > ?", 10000). // 且平均薪资超过 10000
+ Scan(&qualifiedDepts)
+```
+
+> [!example] WHERE vs HAVING 的核心区别
+
+| 特性 | WHERE | HAVING |
+|------|-------|--------|
+| 作用对象 | 单个行记录 | 分组后的聚合结果 |
+| 能否用聚合函数 | ❌ 不能 | ✅ 可以 |
+| 执行时机 | GROUP BY 之前 | GROUP BY 之后 |
+| 性能差异 | 更优(提前过滤减少分组数据量) | 较次(需先完成分组再过滤) |
+
+> [!important] HAVING 的简洁写法
+> 如果你只需要最简单的 HAVING 条件(如 `HAVING COUNT(*) > 5`),也可以直接写字符串:
+> ```go
+> db.Model(&User{}).Group("dept_id").Having("COUNT(*) > ?", 5).Find(&results)
+> ```
+> 多个 `Having` 会自动用 AND 连接。
+
+## 子查询
+
+GORM 提供了几种方式来构建子查询。
+
+### 派生表子查询(From Subquery)
+
+将子查询作为一个临时表放入 FROM 子句:
+
+```go
+// 找出年龄大于全体员工平均年龄的用户
+avgAgeSubQuery := db.Table("users").Select("AVG(age)")
+
+var users []User
+db.Where("age > (?)", avgAgeSubQuery).Find(&users)
+// SELECT * FROM users WHERE age > (SELECT AVG(age) FROM users);
+```
+
+### IN 子查询
+
+```go
+// 找到有订单的用户
+orderSubQuery := db.Model(&Order{}).Select("DISTINCT user_id")
+
+var users []User
+db.Where("id IN (?)", orderSubQuery).Find(&users)
+// SELECT * FROM users WHERE id IN (SELECT DISTINCT user_id FROM orders);
+```
+
+> [!tip] 为什么用 DISTINCT?
+> 一个用户可能有多个订单,如果不加 DISTINCT,子查询会返回重复的 user_id。虽然 `IN (...)` 能自动去重,但加上 DISTINCT 可以让数据库优化器更高效地处理。
+
+### EXISTS 子查询
+
+```go
+// 找到至少有 1 个未支付订单的用户
+db.Where("EXISTS (?)",
+ db.Model(&Order{}).Select("id").
+ Where("user_id = users.id AND status = 'pending'"),
+).Find(&users)
+// SELECT * FROM users
+// WHERE EXISTS (SELECT id FROM orders WHERE user_id = users.id AND status = 'pending');
+```
+
+> [!note] EXISTS vs IN 的选择
+> - **外表大、内表小** → 用 IN,子查询先查好再用
+> - **外表小、内表大** → 用 EXISTS,找到一条就停止扫描
+> - 在实际生产中,MySQL 优化器通常能自动选择最优方案,不用太纠结
+
+### 相关子查询(Correlated Subquery)
+
+内层查询引用外层表的列,每一行都重新执行一次内层查询:
+
+```go
+// 对每个用户,查出他的最新订单
+var users []User
+db.Select(`*,
+ (SELECT created_at FROM orders
+ WHERE user_id = users.id
+ ORDER BY created_at DESC LIMIT 1) as latest_order_date`).
+ Find(&users)
+```
+
+> [!warning] 相关子查询的性能警告
+> 相关子查询每处理一行外层数据就要执行一次内层查询——复杂度接近 O(n × m)。如果数据量大,建议改写为 JOIN + GROUP BY:
+> ```go
+> // 改写为高效 JOIN:
+> db.Table("users u").
+> Select("u.*, o.created_at as latest_order_date").
+> Joins("JOIN orders o ON o.id = (SELECT id FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 1)").
+> Find(&users)
+> ```
+
+## 实用统计模式
+
+### 按月趋势统计
+
+```go
+type DailyOrderStat struct {
+ Day string `gorm:"column:day"`
+ OrderNum int64 `gorm:"column:order_num"`
+ TotalAmt float64 `gorm:"column:total_amt"`
+}
+
+db.Model(&Order{}).
+ Select("DATE(created_at) as day, COUNT(*) as order_num, COALESCE(SUM(amount), 0) as total_amt").
+ Group("DATE(created_at)").
+ Order("day ASC").
+ Scan(&dailyStats)
+```
+
+### TOP N 问题
+
+```go
+// 找出消费金额最高的前 10 名用户
+var topUsers []struct {
+ UserID uint
+ UserName string
+ TotalSpent float64
+}
+
+db.Table("users u").
+ Select("u.id as user_id, u.name as user_name, SUM(o.amount) as total_spent").
+ Joins("JOIN orders o ON o.user_id = u.id").
+ Group("u.id, u.name").
+ Order("total_spent DESC").
+ Limit(10).
+ Scan(&topUsers)
+```
+
+### CASE WHEN 条件聚合
+
+```go
+// 按状态统计订单数量
+type StatusCount struct {
+ Pending int64 `gorm:"column:pending"`
+ Completed int64 `gorm:"column:completed"`
+ Cancelled int64 `gorm:"column:cancelled"`
+}
+
+var sc StatusCount
+db.Raw(`
+ SELECT
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
+ SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled
+ FROM orders
+`).Scan(&sc)
+```
+
+## 子查询决策图
+
+```mermaid
+flowchart TD
+ Start[需要子查询] --> Pattern{查询模式_}
+
+ Pattern --> |聚合统计| GroupByQ{是否要过滤聚合结果?}
+ GroupByQ --> |不需要| BasicGroup["GROUP BY + Select 聚合函数"]
+ GroupByQ --> |需要| HavingCheck["GROUP BY + HAVING"]
+
+ Pattern --> |条件过滤| FilterType{"IN 还是 EXISTS?"}
+ FilterType --> |精确匹配值集合| InSub["WHERE col IN (SELECT ...)"]
+ FilterType --> |存在性判断| ExistsSub["WHERE EXISTS (SELECT ...)"]
+
+ Pattern --> |数据源嵌套| FromSub["FROM (SELECT ...) AS alias"]
+
+ style Start fill:#4FC08D,color:#fff
+ style HavingCheck fill:#3B82F6,color:#fff
+ style InSub fill:#F59E0B,color:#000
+ style FromSub fill:#EC4899,color:#fff
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| Group 后字段丢失 | 未在 Select 中声明所需字段 | 显式列出所有 SELECT 字段 |
+| Having 写了聚合函数但被 WHERE 过滤 | WHERE 在 GROUP BY 之前执行,无法访问聚合 | 把聚合条件移到 Having |
+| 子查询导致笛卡尔积 | JOIN 没有正确的关联条件 | 检查外键关联,或用 EXISTS 替代 |
+| ORDER BY + GROUP BY 顺序搞反 | SQL 语法要求 GROUP BY 在前 | 按 `WHERE → GROUP BY → HAVING → ORDER BY` 顺序写 |
+| MySQL 5.7 ONLY_FULL_GROUP_BY | 非聚合字段不能在 SELECT 中出现 | 开启 SQL 严格模式或使用 MySQL 8.0+ |
+
+## 关联笔记
+
+- [[03-CRUD 操作]]
+- [[04-条件查询]]
+- [[05-关联查询]]
+- [[06-排序与分页]]
+- [[15-性能优化]]
diff --git a/hhs/GORM/08-事务管理.md b/hhs/GORM/08-事务管理.md
new file mode 100644
index 0000000..50129f6
--- /dev/null
+++ b/hhs/GORM/08-事务管理.md
@@ -0,0 +1,312 @@
+---
+tags: [GORM, Go, ORM, 事务, Tx, Commit, Rollback, Nested Transaction]
+create time: 2026-04-28 00:00
+---
+
+# 事务管理
+
+## 概述
+
+事务是数据库操作的「安全网」——它保证一组操作要么全部成功,要么全部失败。在现实业务中,你几乎处处离不开事务:转账时「扣 A 加 B」、下单时「减库存 + 创建订单 + 生成流水」,这些都需要事务来保证数据一致性。
+
+```mermaid
+flowchart TD
+ Start[开始事务 tx = db.Begin] --> Op1["执行操作 1"]
+ Op1 --> OK1{"操作 1 成功?"}
+ OK1 --> |是| Op2["执行操作 2"]
+ OK1 --> |否| Rollback["tx.Rollback()"]
+ OK2{"操作 2 成功?"} --> |是| Op3["执行操作 3"]
+ Op2 --> OK2
+ OK2 --> |否| Rollback
+ Op3 --> OK3{"操作 3 成功?"}
+ OK3 --> |是| Commit["tx.Commit()"]
+ OK3 --> |否| Rollback
+
+ style Start fill:#4FC08D,color:#fff
+ style Commit fill:#3B82F6,color:#fff
+ style Rollback fill:#EF4444,color:#fff
+```
+
+> [!definition] 事务四大特性(ACID)
+
+| 特性 | 含义 | 示例 |
+|------|------|------|
+| **原子性(Atomicity)** | 全部提交或全部回滚 | 转账:A 扣钱和 B 加钱不可分割 |
+| **一致性(Consistency)** | 事务前后数据满足业务规则 | 转账前后总金额不变 |
+| **隔离性(Isolation)** | 并发事务互不干扰 | 两个用户同时下单不会超卖 |
+| **持久性(Durability)** | 提交后永不过期 | 断电后数据仍在 |
+
+## 基本用法
+
+### Begin / Commit / Rollback
+
+```go
+// 1. 开启事务
+tx := db.Begin()
+defer func() {
+ if r := recover(); r != nil {
+ tx.Rollback() // panic 时确保回滚
+ }
+}()
+
+// 2. 所有操作通过 tx 执行
+user := User{Name: "Alice", Age: 30}
+if err := tx.Create(&user).Error; err != nil {
+ tx.Rollback()
+ return err
+}
+
+order := Order{UserID: user.ID, Amount: 99.9}
+if err := tx.Create(&order).Error; err != nil {
+ tx.Rollback()
+ return err
+}
+
+// 3. 全部成功,提交
+if err := tx.Commit().Error; err != nil {
+ return err
+}
+return nil
+```
+
+> [!warning] defer Rollback 的注意事项
+> `Commit()` 成功后再执行 `defer Rollback()` 会报错(事务已提交)。所以推荐**显式处理错误路径的回滚**,`defer` 只用于兜底 panic。
+
+### 简洁写法(Err 链式判断)
+
+```go
+tx := db.Begin()
+if err := tx.Error; err != nil {
+ return err
+}
+
+// ... 操作 ...
+
+if err := tx.Commit().Error; err != nil {
+ tx.Rollback()
+ return err
+}
+```
+
+> [!tip] 理解 tx.Transaction 返回值
+> GORM 的 `Begin()` 返回 `*gorm.DB`,但内部包装了事务。这个实例只在事务范围内有效,不能跨事务使用。
+
+## 事务中的错误处理
+
+```go
+func TransferMoney(fromID, toID uint, amount float64) error {
+ // 开启事务
+ tx := db.Begin()
+
+ // 查入账和出账账户(锁行!)
+ var fromAccount Account
+ if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&fromAccount, fromID).Error; err != nil {
+ tx.Rollback()
+ return fmt.Errorf("查询转出账户失败: %w", err)
+ }
+
+ var toAccount Account
+ if err := tx.First(&toAccount, toID).Error; err != nil {
+ tx.Rollback()
+ return fmt.Errorf("查询入账账户失败: %w", err)
+ }
+
+ // 余额不足
+ if fromAccount.Balance < amount {
+ tx.Rollback()
+ return errors.New("余额不足")
+ }
+
+ // 扣款
+ fromAccount.Balance -= amount
+ if err := tx.Save(&fromAccount).Error; err != nil {
+ tx.Rollback()
+ return fmt.Errorf("扣款失败: %w", err)
+ }
+
+ // 入账
+ toAccount.Balance += amount
+ if err := tx.Save(&toAccount).Error; err != nil {
+ tx.Rollback()
+ return fmt.Errorf("入账失败: %w", err)
+ }
+
+ return tx.Commit().Error
+}
+```
+
+> [!important] FOR UPDATE 锁行
+> 事务中的查询如果要用更新的数据做决策,必须用 `FOR UPDATE`(排他锁),否则可能读到未提交的旧数据:
+> ```go
+> // ❌ 竞态条件:两个并发请求可能都读到余额足够并扣款
+> db.Where("id = ?", id).First(&account)
+> account.Balance -= 100
+> db.Save(&account)
+>
+> // ✅ 加锁读取,另一个事务只能等待
+> db.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", id).First(&account)
+> ```
+
+## 嵌套事务
+
+当代码结构有分层时,内部函数可能需要发起自己的事务。GORM 支持嵌套事务机制——内部实际上是保存点(Savepoint),而非真正的事务:
+
+```go
+func CreateOrderWithAudit(tx *gorm.DB, order Order) error {
+ // 检查外层是否已有事务
+ if !tx.IsTransaction() {
+ // 外层不是事务,自己开一个
+ tx = db.Begin()
+ defer func() {
+ if v := recover(); v != nil {
+ tx.Rollback()
+ panic(v)
+ }
+ }()
+ }
+
+ // 保存点:如果内部失败可以回滚到这里,不影响外部
+ tx.SavePoint("create_order")
+
+ // 主逻辑
+ if err := tx.Create(&order).Error; err != nil {
+ tx.RollbackTo("create_order") // 回滚到保存点
+ return err
+ }
+
+ // 审计日志记录
+ audit := Audit{Table: "orders", Action: "create", RecordID: order.ID}
+ if err := tx.Create(&audit).Error; err != nil {
+ tx.RollbackTo("create_order") // 审计失败也可以回滚
+ return err
+ }
+
+ // 成功 —— 如果不外层事务则提交,否则由外层统一提交
+ if !tx.IsTransaction() {
+ return tx.Commit().Error
+ }
+ return nil
+}
+```
+
+> [!tip] 嵌套事务的本质
+> MySQL/PostgreSQL 不支持真正的嵌套事务,它们用的是 **Savepoint**。这意味着:
+> - 内部回滚只会回滚到最近的 savepoint,不会影响外层事务
+> - 最终 `COMMIT` 仍然是一次性的,从外层事务发出
+> - 如果外层事务 `ROLLBACK`,整个事务树(包括所有 savepoint)都会被回滚
+
+### 事务级别控制
+
+```go
+import "database/sql"
+
+tx := db.Session(&gorm.Session{
+ DryRun: false, // 模拟模式:构建 SQL 但不执行
+}).Begin(&sql.TxOptions{
+ Isolation: sql.LevelRepeatableRead, // 设置隔离级别
+ ReadOnly: false, // 读写事务
+})
+
+// 或者针对特定查询
+db.Set("gorm:prepare_stmt", true).Find(&users) // 预编译语句
+```
+
+> [!example] 隔离级别速查
+
+| 级别 | 脏读 | 不可重复读 | 幻读 | 性能影响 |
+|------|------|-----------|------|---------|
+| Read Uncommitted | ❌ 允许 | ❌ 允许 | ❌ 允许 | 最快 |
+| Read Committed | ✅ 阻止 | ❌ 允许 | ❌ 允许 | 快 |
+| Repeatable Read(MySQL 默认) | ✅ 阻止 | ✅ 阻止 | ⚠️ 部分阻止 | 中等 |
+| Serializable | ✅ 阻止 | ✅ 阻止 | ✅ 阻止 | 最慢 |
+
+## 事务与中间件结合
+
+在实际项目中,经常需要将事务与 Gin 等 Web 框架集成,实现自动事务管理:
+
+```go
+// Gin 中间件:每个 HTTP 请求自带一个事务
+func TransactionMiddleware(db *gorm.DB) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // 开始事务
+ tx := db.Begin()
+ c.Set("db", tx) // 存入 Context
+
+ defer func() {
+ if p := recover(); p != nil {
+ tx.Rollback()
+ panic(p) // 重新抛出让上层捕获
+ }
+
+ // 请求处理完毕且无错误,提交事务
+ if c.Writer.Status() >= 200 && c.Writer.Status() < 400 {
+ tx.Commit()
+ } else {
+ tx.Rollback()
+ }
+ }()
+
+ c.Next()
+ }
+}
+
+// 在 handler 中使用
+func CreateUser(c *gin.Context) {
+ // 从 Context 获取当前事务
+ txVal, _ := c.Get("db")
+ tx := txVal.(*gorm.DB)
+
+ user := User{Name: c.PostForm("name")}
+ if err := tx.Create(&user).Error; err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(201, user)
+}
+```
+
+## 事务决策流程图
+
+```mermaid
+flowchart TD
+ Start[需要多步数据操作] --> InTx{"是否在已有事务中?"}
+ InTx --> |是| SaveQ{需要局部回滚能力_}
+ InTx --> |否| NewTx{"操作数量?"}
+
+ NewTx --> |单条 SQL| SimpleTx["不需要事务
直接操作即可"]
+ NewTx --> |多条 SQL| BeginTx["db.Begin()"]
+
+ SaveQ --> |需要| Savepoint["SavePoint('name')"]
+ SaveQ --> |不需要| Continue["继续执行"]
+
+ BeginTx --> Ops["依次执行各操作"]
+ Continue --> Ops
+ Savepoint --> Ops
+
+ Ops --> AllOK{"全部成功?"}
+ AllOK --> |是| Commit["Commit"]
+ AllOK --> |否| RollBack["Rollback"]
+
+ style Start fill:#4FC08D,color:#fff
+ style Commit fill:#3B82F6,color:#fff
+ style RollBack fill:#EF4444,color:#fff
+ style SimpleTx fill:#A0AEC0,color:#fff
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 忘记 Commit/Rollback | 漏了错误分支的回滚逻辑 | 对所有错误分支显式调用 Rollback |
+| 事务内查询被其他事务修改 | 没加 FOR UPDATE | 关键查询加 `Set("gorm:query_option", "FOR UPDATE")` |
+| 嵌套事务外层的 Commit 失效 | 内部使用了独立连接 | 始终复用同一个 `*gorm.DB` 对象 |
+| 长事务导致行锁堆积 | 事务中包含耗时操作(RPC、HTTP) | 将耗时操作移出事务范围 |
+| defer Rollback 导致已提交事务报错 | Commit 后再执行 deferred Rollback | 用 panic 兜底,正常路径不依赖 defer |
+
+## 关联笔记
+
+- [[01-安装与初始化]]
+- [[03-CRUD 操作]]
+- [[14-错误处理]]
+- [[15-性能优化]]
diff --git a/hhs/GORM/09-钩子函数.md b/hhs/GORM/09-钩子函数.md
new file mode 100644
index 0000000..89ccef9
--- /dev/null
+++ b/hhs/GORM/09-钩子函数.md
@@ -0,0 +1,225 @@
+---
+tags: [GORM, Go, ORM, 钩子函数, Callback, BeforeCreate, AfterUpdate, Lifecycle]
+create time: 2026-04-28 00:00
+---
+
+# 钩子函数
+
+## 概述
+
+钩子(Hook)是 GORM 生命周期回调机制,允许你在特定操作前后注入自定义逻辑。它们是「横切关注点」的理想载体——不需要在每个业务代码里重复写加密、日志、审计等通用逻辑。
+
+```mermaid
+flowchart TD
+ A[Create 调用] --> BC["BeforeCreate"]
+ BC --> CreateSQL["执行 INSERT"]
+ CreateSQL --> AC["AfterCreate"]
+
+ D[Update 调用] --> BU["BeforeUpdate"]
+ BU --> UpdateSQL["执行 UPDATE"]
+ UpdateSQL --> AU["AfterUpdate"]
+
+ E[Delete 调用] --> BD["BeforeDelete"]
+ BD --> DeleteSQL["执行 DELETE"]
+ DeleteSQL --> AD["AfterDelete"]
+
+ F[Find/First 调用] --> AF["AfterFind"]
+
+ style BC fill:#EAB308,color:#fff
+ style BU fill:#F59E0B,color:#000
+ style BD fill:#EF4444,color:#fff
+ style AF fill:#3B82F6,color:#fff
+```
+
+> [!note] GORM 的操作顺序
+> 每个操作的执行流:`钩子(前置) → SQL 执行 → 钩子(后置)`。前置钩子返回错误可以**中断整个操作**。
+
+## 完整钩子列表
+
+### 创建阶段
+
+```go
+func (u *User) BeforeCreate(tx *gorm.DB) error {
+ // 在 INSERT 之前执行
+ u.CreatedAt = time.Now()
+ u.UpdatedAt = time.Now()
+
+ // 对密码做哈希处理
+ hashed, _ := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
+ u.Password = string(hashed)
+
+ // 生成唯一 ID
+ if u.UUID == "" {
+ u.UUID = generateUUID()
+ }
+ return nil // 返回错误可中断插入
+}
+
+func (u *User) AfterCreate(tx *gorm.DB) error {
+ // 在 INSERT 之后执行
+ // 例如:发送欢迎邮件、记录审计日志
+ log.Printf("用户 %s 创建成功,ID=%d", u.Name, u.ID)
+ return nil
+}
+```
+
+### 更新阶段
+
+```go
+func (u *User) BeforeUpdate(tx *gorm.DB) error {
+ // 在 UPDATE 之前执行
+ u.UpdatedAt = time.Now()
+
+ // 检查乐观锁版本号
+ var prev User
+ tx.Select("version").First(&prev, u.ID)
+ if prev.Version != u.Version {
+ return errors.New("数据已被其他人修改,请刷新后重试")
+ }
+ return nil
+}
+
+func (u *User) AfterUpdate(tx *gorm.DB) error {
+ // 同步缓存
+ redis.Set(context.Background(), "user:"+strconv.Itoa(u.ID), u, time.Hour)
+ return nil
+}
+```
+
+### 删除阶段
+
+```go
+func (u *User) BeforeDelete(tx *gorm.DB) error {
+ // 清理关联数据(比如清除 Redis Session)
+ sessionIDs, _ := getSessionIDsByUserID(u.ID)
+ for _, id := range sessionIDs {
+ redis.Del(context.Background(), "session:"+id)
+ }
+ return nil
+}
+
+func (u *User) AfterDelete(tx *gorm.DB) error {
+ // 如果用了软删除,这里可以做真正的物理清理(如文件、附件)
+ if tx.RowsAffected > 0 {
+ deleteUploadedFiles(u)
+ }
+ return nil
+}
+```
+
+### 查询阶段
+
+```go
+func (u *User) AfterFind(tx *gorm.DB) error {
+ // 在 SELECT 之后执行——每次 Find/First 都会触发
+ // 适合用于敏感字段脱敏或格式化处理
+
+ // 示例:邮箱脱敏
+ if len(u.Email) > 4 {
+ masked := u.Email[:2] + "***" + u.Email[len(u.Email)-2:]
+ fmt.Println("已脱敏:", masked)
+ }
+ return nil
+}
+```
+
+> [!question] AfterFind 的性能影响
+> `AfterFind` 会在每次查询时对所有行都执行——如果一次查出 1000 条记录,钩子会运行 1000 次。不要在 `AfterFind` 中放重逻辑(如 RPC 调用)。
+
+## 条件判断:根据操作类型选择行为
+
+有时你希望同一个钩子在特定操作下才生效。可以通过 `tx.Statement` 判断当前操作类型:
+
+```go
+func (u *User) BeforeUpdate(tx *gorm.DB) error {
+ // 只在 Password 被修改时才重新哈希
+ if tx.Statement.Changed("Password") {
+ hashed, _ := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
+ u.Password = string(hashed)
+ }
+ return nil
+}
+```
+
+> [!tip] Statement 常用方法
+
+| 方法 | 作用 |
+|------|------|
+| `Changed(field)` | 该字段是否在 Updates 中被修改 |
+| `Select()` | 获取显式 SELECT 的字段列表 |
+| `Omit()` | 获取 OMIT 的字段列表 |
+| `Deleted()` | 是否是 Delete 操作 |
+| `Updated(field)` | 字段是否被更新且值发生变化 |
+
+## 全局钩子 vs Model 级钩子
+
+GORM 支持两种注册方式,优先级为 **Model 级 > 全局**:
+
+```go
+// Model 级钩子——写在 struct 的方法上(最常用)
+type Order struct{}
+func (Order) BeforeCreate(tx *gorm.DB) error { ... }
+
+// 全局钩子——通过 Callback 注册,作用于所有模型
+db.Callback().Create().Before("gorm:create").Register("set_created_at", func(tx *gorm.DB) {
+ if v, ok := tx.Get("created_at_overwrite"); ok {
+ if t, ok := v.(time.Time); ok {
+ tx.Statement.SetColumn("CreatedAt", t)
+ }
+ }
+})
+
+// 使用场景:给所有模型统一设置默认时间(测试用)
+db.Session(&gorm.Session{Context: ctx}).Create(&order)
+```
+
+> [!warning] 全局钩子注意事项
+> 全局钩子的注册时机必须在 `db.Open()` 之后、首次操作之前。而且全局钩子会影响**所有模型**——包括内置的 `gorm.Model`——使用时需格外小心。
+
+## 钩子执行链示意图
+
+```mermaid
+sequenceDiagram
+ participant App as 应用代码
+ participant Hook as GORM 钩子系统
+ participant DB as 数据库
+
+ App->>Hook: db.Create(&user)
+ Hook->>Hook: BeforeCreate
+ Hook->>DB: INSERT INTO users...
+ DB-->>Hook: 返回影响行数
+ Hook->>Hook: AfterCreate
+ Hook-->>App: 返回结果
+
+ Note over Hook: Update: BeforeUpdate → SQL → AfterUpdate
+ Note over Hook: Delete: BeforeDelete → SQL → AfterDelete
+ Note over Hook: Find: BeforeQuery → SQL → AfterFind
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 钩子里调用了 db(而不是 tx) | 死锁——钩子内再开事务嵌套 | 钩子内部全部使用 `tx` 参数 |
+| Changed() 在 Save 时总返回 false | Save 是全量写入,不追踪变化 | 用 `Updates(struct)` 配合 Changed() |
+| 钩子返回了 nil 但想中断操作 | 零值 nil 不是错误 | `return errors.New("中断原因")` |
+| 批量操作也会触发钩子 | `Create(&[]User{})` 每条都走钩子 | 如需跳过可用 `SkipHooks` session |
+| AfterFind 脱敏污染了原始数据 | 直接修改结构体字段影响调用方 | 需要脱敏时在接口层处理,不要改原对象 |
+
+## 钩子应用场景总结
+
+| 场景 | 推荐钩子 | 说明 |
+|------|---------|------|
+| 自动填充 CreatedAt / UpdatedAt | `autoTime` tag 更简单 | 如果手动实现用 BeforeCreate / BeforeUpdate |
+| 密码哈希 | BeforeCreate + BeforeUpdate(仅当 Changed) | 避免重复哈希 |
+| 乐观锁 | BeforeUpdate(检查 version) | 并发安全的经典方案 |
+| 审计日志 | AfterCreate / AfterUpdate / AfterDelete | 操作完成后异步记录 |
+| 数据脱敏 | AfterFind | 对外输出前格式化 |
+| 关联清理 | BeforeDelete | 删除前解除外部引用 |
+
+## 关联笔记
+
+- [[02-模型定义]]
+- [[03-CRUD 操作]]
+- [[08-事务管理]]
+- [[11-批量操作]]
diff --git a/hhs/GORM/10-软删除.md b/hhs/GORM/10-软删除.md
new file mode 100644
index 0000000..8c0626d
--- /dev/null
+++ b/hhs/GORM/10-软删除.md
@@ -0,0 +1,270 @@
+---
+tags: [GORM, Go, ORM, 软删除, SoftDelete, Unscoped, DeletedAt]
+create time: 2026-04-28 00:00
+---
+
+# 软删除
+
+## 概述
+
+软删除是一种「逻辑删除」策略——数据并没有真正消失,只是被标记为「已删除」。这是 Web 应用中处理「删除」操作的**默认推荐方式**,因为它提供了可恢复性和审计追踪能力。
+
+```mermaid
+flowchart TD
+ A[调用 db.Delete(&user)] --> SoftDel{"模型有
DeletedAt 字段?"}
+
+ SoftDel --> |否| HardSQL["DELETE FROM users WHERE id = ?
⚠️ 物理删除,不可恢复"]
+ SoftDel --> |是| UpdateSQL["UPDATE users SET deleted_at = NOW() WHERE id = ?
✅ 软删除,数据保留"]
+
+ UpdateSQL --> Query{常规查询?}
+ Query --> |是| Filtered["WHERE deleted_at IS NULL
已删除记录自动隐藏"]
+ Query --> |否| UnscopedQ{需要已删除数据?}
+ UnscopedQ --> |是| IncludeAll["Unscoped() → 全部返回"]
+ UnscopedQ --> |否| Filtered
+
+ style Start fill:#4FC08D,color:#fff
+ style Filtered fill:#3B82F6,color:#fff
+ style HardSQL fill:#EF4444,color:#fff
+```
+
+## DeletedAt 机制
+
+### 声明式启用
+
+在 struct 中嵌入 `gorm.Model` 或显式声明 `DeletedAt gorm.DeletedAt`:
+
+```go
+type User struct {
+ gorm.Model // 自动包含 ID, CreatedAt, UpdatedAt, DeletedAt
+ Name string `gorm:"size:64;not null"`
+}
+
+// 等价的手动声明
+type Product struct {
+ ID uint `gorm:"primaryKey"`
+ Name string `gorm:"size:128;not null"`
+ Price float64 `gorm:"not null"`
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ DeletedAt gorm.DeletedAt // 软删除字段
+}
+```
+
+> [!note] gorm.DeletedAt 的本质
+> `gorm.DeletedAt` 是 `time.Time` 的类型别名,但它告诉 GORM 这是一个软删除标记。只有当这个字段存在时,GORM 才会开启软删除行为。
+
+### 软删除 vs 物理删除
+
+```go
+var user User
+
+// ===== 软删除(推荐)=====
+db.Delete(&user) // UPDATE users SET deleted_at=... WHERE id=?
+// 查询时自动过滤:SELECT * FROM users WHERE deleted_at IS NULL AND id=?
+
+// ===== 物理删除 =====
+db.Unscoped().Delete(&user) // DELETE FROM users WHERE id=?
+// 彻底擦除,无法恢复!
+
+// ===== 根据条件批量物理删除 =====
+db.Unscoped().Where("deleted_at < ?", time.Now().AddDate(-90, 0, 0)).Delete(&User{})
+// 清理超过 90 天未激活的软删除记录
+```
+
+## 查询已删除数据
+
+### Unscoped — 忽略软删除过滤
+
+`Unscoped()` 会关闭 GORM 自动附加的 `deleted_at IS NULL` 条件,让所有查询都能看到已删除的数据:
+
+```go
+// 查询所有用户(包括已删除的)
+var allUsers []User
+db.Unscoped().Find(&allUsers)
+// SELECT * FROM users; -- 不再附加 deleted_at 条件
+
+// 查找特定已删除的用户
+var deletedUser User
+db.Unscoped().Where("id = ? AND deleted_at IS NOT NULL", 42).First(&deletedUser)
+
+// 统计已删除的记录数
+var count int64
+db.Model(&User{}).Unscoped().Where("deleted_at IS NOT NULL").Count(&count)
+```
+
+### 精确查询含/不含已删除数据
+
+```go
+// 只查正常用户
+db.Where("deleted_at IS NULL").Find(&users)
+
+// 只查已删除用户
+db.Where("deleted_at IS NOT NULL").Find(&deletedUsers)
+```
+
+> [!tip] 为什么不用 Where + Unscoped?
+> `Unscoped()` 是一个全局开关,一旦开启就影响当前链的所有后续操作:
+> ```go
+> // ❌ 意外行为 —— Unscoped 后面的 Find 也不过滤
+> db.Where("name = 'john'").Unscoped().Find(&users)
+> // SELECT * FROM users WHERE name = 'john'; -- 包括已删除的 john!
+>
+> // ✅ 精确控制
+> db.Where("name = ? AND deleted_at IS NULL", "john").Find(&users)
+> ```
+
+## 软删除中的关联处理
+
+### HasMany / BelongsTo
+
+软删除模型的关联查询默认也会过滤已删除的关联项:
+
+```go
+type User struct {
+ gorm.Model
+ Name string
+ Orders []Order `gorm:"foreignKey:UserID"`
+}
+
+type Order struct {
+ gorm.Model
+ UserID uint
+}
+
+var user User
+db.Preload("Orders").First(&user, 1)
+// Preload 生成的 SQL 会自动加入 deleted_at IS NULL:
+// SELECT * FROM orders WHERE user_id = 1 AND deleted_at IS NULL;
+```
+
+### ManyToMany
+
+对于多对多关联,GORM 在操作中间表时也会考虑软删除状态:
+
+```go
+// 给订单添加商品(如果商品已被软删除,默认不会关联)
+product := Product{Name: "Go Programming"}
+db.Model(&order).Association("Products").Append(&product)
+
+// 如果要强制关联已删除的商品
+db.Model(&order).Clauses(clause.OnConflict{DoNothing: true}).
+ Association("Products").Append(&product)
+```
+
+> [!question] 思考题
+> 如果一个父记录被软删除,它的子记录(HasMany)还能被查询到吗?
+>
+> > **答案**:取决于查询起点。以未删除的父记录查询,子记录的软删除仍然生效;但如果你先 Unscoped 查到了已删除的父记录,再 Preload 其关联,子记录的软删除状态由 Preload 决定。
+
+## 唯一约束与软删除冲突
+
+这是软删除最常见的坑点——唯一约束检查在 UPDATE 之前执行,而旧记录仍然占着唯一值:
+
+```go
+type Article struct {
+ gorm.Model
+ Slug string `gorm:"uniqueIndex;not null"` // 例如:my-first-post
+}
+
+// 第一次保存成功
+db.Create(&Article{Slug: "my-first-post"})
+
+// 更新 slug
+db.Model(&Article{}).Where("id = ?", 1).Update("slug", "my-updated-post")
+// UPDATE articles SET slug='my-updated-post', deleted_at=NULL WHERE id=1;
+// ⚠️ MySQL 不会对已删除行检查唯一约束(InnoDB bug)
+// ⚠️ PostgreSQL 和 SQLite 会报错:duplicate key value
+```
+
+### 解决方案
+
+```go
+// 方案一:使用部分唯一索引(PostgreSQL 支持)
+// CREATE UNIQUE INDEX idx_articles_slug ON articles(slug) WHERE deleted_at IS NULL;
+// GORM 暂不直接支持部分索引,需手动执行 SQL
+
+// 方案二:额外加一个版本字段
+type Article struct {
+ gorm.Model
+ Slug string `gorm:"not null"`
+ SlugHash string `gorm:"uniqueIndex;not null"` // slug + 随机哈希
+}
+
+// 方案三:用复合唯一索引覆盖软删除标记
+// 在数据库层面创建 (slug, is_deleted) 联合唯一约束
+```
+
+> [!warning] 跨数据库行为不一致
+> MySQL 的 InnoDB 引擎在处理软删除行的唯一约束时存在历史缺陷,不同版本行为可能不同。**不要依赖这种行为一致性**,应在应用层做好校验。
+
+## 恢复已删除数据
+
+```go
+// 恢复单个记录
+db.Model(&User{}).Where("id = ?", 42).Update("deleted_at", nil)
+// UPDATE users SET deleted_at=NULL WHERE id=42;
+
+// 批量恢复
+result := db.Model(&User{}).
+ Where("deleted_at IS NOT NULL AND email LIKE '%@test.com'").
+ Update("deleted_at", nil)
+fmt.Printf("恢复了 %d 条记录\n", result.RowsAffected)
+```
+
+> [!important] 恢复后记得重新设置 UpdatedAt
+> 恢复操作本身也是一次 UPDATE,所以 `UpdatedAt` 会自动更新。如果你希望保留原始时间戳,需要在更新前保存到临时变量。
+
+## 定时清理策略
+
+软删除会导致数据无限膨胀,建议建立定期清理机制:
+
+```go
+// cron 定时任务:每月清理 90 天前的软删除记录
+func CleanSoftDeletedRecords(tx *gorm.DB) error {
+ cutoff := time.Now().AddDate(0, 0, -90)
+
+ models := []any{&User{}, &Order{}, &Product{}}
+ for _, model := range models {
+ tx.Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff).
+ Delete(model)
+ }
+ return nil
+}
+```
+
+## 软删除决策图
+
+```mermaid
+flowchart TD
+ Start[执行删除操作] --> TypeQ{业务需求_}
+ TypeQ --> |可恢复/需审计| SoftDel["软删除
db.Delete()"]
+ TypeQ --> |合规要求/不需要恢复| HardDel["物理删除
db.Unscoped().Delete()"]
+
+ SoftDel --> AfterSoft[查询时需要已删除数据?]
+ AfterSoft --> |是| UnscopedOn["db.Unscoped()"]
+ AfterSoft --> |否| NormalQ["普通查询
自动过滤"]
+
+ HardDel --> AfterHard["数据永久移除"]
+
+ style Start fill:#4FC08D,color:#fff
+ style SoftDel fill:#3B82F6,color:#fff
+ style HardDel fill:#EF4444,color:#fff
+ style UnscopedOn fill:#F59E0B,color:#000
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 唯一约束冲突 | 旧软删除行仍占唯一值 | 加版本字段或用部分索引 |
+| Unscoped 影响后续查询 | 开关是链式的 | 及时结束链或改用显式 Where |
+| 预加载跳过已删除关联 | GORM 自动加 deleted_at 过滤 | 用 Unscoped + Preload 组合 |
+| 软删除导致数据膨胀 | 没有定期清理策略 | 定时任务清理过期记录 |
+| First/Take 找不到已删除记录 | 自动过滤了 deleted_at | Unscoped().First() |
+
+## 关联笔记
+
+- [[02-模型定义]]
+- [[03-CRUD 操作]]
+- [[05-关联查询]]
+- [[08-事务管理]]
diff --git a/hhs/GORM/11-批量操作.md b/hhs/GORM/11-批量操作.md
new file mode 100644
index 0000000..ee7882b
--- /dev/null
+++ b/hhs/GORM/11-批量操作.md
@@ -0,0 +1,293 @@
+---
+tags: [GORM, Go, ORM, 批量操作, Bulk Insert, Batch Update, Callback]
+create time: 2026-04-28 00:00
+---
+
+# 批量操作
+
+## 概述
+
+当数据量达到千级以上时,逐条操作的性能问题变得不可忽视。批量操作用于在一次数据库往返中处理大量记录——无论是插入、更新还是删除,都能显著降低网络开销和事务粒度。
+
+```mermaid
+flowchart LR
+ Slow["N 条独立 SQL
N 次网络往返"] -->|优化后| Fast["1 条批量 SQL
1 次网络往返"]
+
+ style Slow fill:#EF4444,color:#fff
+ style Fast fill:#3B82F6,color:#fff
+```
+
+## Create — 批量插入
+
+### 基本用法
+
+```go
+// 直接传入 slice —— GORM 自动生成多 VALUES INSERT
+users := []User{
+ {Name: "Alice", Age: 25},
+ {Name: "Bob", Age: 30},
+ {Name: "Charlie", Age: 28},
+}
+result := db.Create(&users)
+fmt.Println(result.RowsAffected) // 3
+
+// 自动回填 ID
+for _, u := range users {
+ fmt.Printf("ID=%d Name=%s\n", u.ID, u.Name)
+}
+```
+
+> [!tip] 底层机制:拆批策略
+> GORM 内部会将大 slice 拆成多个 `INSERT INTO ... VALUES (...), (...), ...` 语句执行,每批默认约 **256 条**。这样做是为了避免单条 SQL 过大导致内存溢出或数据库拒绝执行。你可以通过配置调整这个行为:
+> ```go
+> db.Session(&gorm.Session{FullSaveRecords: true}).Create(&hugeSlice)
+> ```
+
+### 高速批量插入(Exec)
+
+对于超大批量(数万行),GORM 提供了更高效的执行方式——跳过模型解析,直接执行原始 SQL:
+
+```go
+// 方案一:使用 raw sql 手动拼接(推荐用于极大数据量)
+values := make([]string, len(users))
+args := make([]any, 0, len(users)*3)
+for i, u := range users {
+ values[i] = fmt.Sprintf("(%d, %d, %s)",
+ u.CreatedAt.Unix(), u.Age, tx.Migrator().ColumnName(u, "name"))
+ args = append(args, u.Name)
+}
+db.Exec(fmt.Sprintf("INSERT INTO users (created_at, age, name) VALUES %s",
+ strings.Join(values, ", "), args...))
+
+// 方案二:用 CreateInBatches(内置分批,更简单)
+err := db.CreateInBatches(&users, 500).Error // 每批 500 条
+```
+
+> [!important] CreateInBatches vs Create
+>
+> | 特性 | Create | CreateInBatches |
+> |------|--------|-----------------|
+> | 参数 | 总数量 | 每批大小(batch size) |
+> | 钩子触发 | 每条都触发 | 每条都触发 |
+> | 适用场景 | 中小批量(≤10K) | 超大批量(>10K) |
+> | 可控性 | GORM 内部决定批次 | 可自定义 batch size |
+
+```go
+// 演示差异
+db.CreateInBatches(&users, 100) // 每批 100 条
+// users 有 350 条 → 分成 4 批:100, 100, 100, 50
+
+db.CreateInBatches(&users, 200) // 每批 200 条
+// users 有 350 条 → 分成 2 批:200, 150
+```
+
+> [!question] 思考题
+> 为什么 CreateInBatches 的第一个参数是 slice,第二个是 batch size?为什么不叫 ` batchSize` 而用 `total`?
+>
+> > **答案**:因为设计时参考的是「总共要处理的总数」语义。但在实际使用中,把它理解为「每批大小」更加直观。注意它不是总上限,而是批次阈值。
+
+## Update — 批量更新
+
+### map 方式(简洁但功能受限)
+
+```go
+// 将所有状态为 trial 的用户升级为 active
+result := db.Model(&User{}).
+ Where("status = ?", "trial").
+ Updates(map[string]any{
+ "status": "active",
+ "updated_at": time.Now(),
+ })
+fmt.Printf("更新了 %d 条记录\n", result.RowsAffected)
+// UPDATE users SET status='active', updated_at=... WHERE status='trial';
+```
+
+### Select / Omit 控制字段
+
+```go
+// 只更新指定字段(不管 map 里写了多少 key,只更新白名单中的字段)
+db.Model(&User{}).
+ Where("status = ?", "trial").
+ Select("status", "plan"). // 白名单
+ Updates(map[string]any{
+ "status": "active",
+ "plan": "pro",
+ "email": "should_not_change@test.com", // 被忽略
+ })
+
+// 排除某些字段
+db.Model(&User{}).
+ Where("status = ?", "trial").
+ Omit("password", "secret_key"). // 黑名单
+ Updates(map[string]any{
+ "status": "active",
+ "plan": "pro",
+ "password": "new_password_here", // 被忽略
+ "updated_at": time.Now(),
+ })
+```
+
+> [!tip] Select + Omit 的区别
+> - `Select` 是白名单——只更新列出的字段
+> - `Omit` 是黑名单——除了列出的字段,其他都更新
+> - 两者可以组合使用,最终生效 = Select(若有)∩ Not(Omit)
+
+### Struct 方式
+
+```go
+// 根据条件批量更新——注意只有非零值会被写入
+db.Model(&User{}).Where("score < ?", 60).Updates(User{Status: "reminded"})
+// UPDATE users SET status='reminded' WHERE score < 60;
+
+// ❌ 陷阱:如果 Status 的值就是零值 "",则不会被写入
+db.Model(&User{}).Where("role = ?", "admin").Updates(User{Role: ""})
+// 这条不会生成任何 SET 子句!
+
+// ✅ 解决:用 map 替代 struct
+db.Model(&User{}).Where("role = ?", "admin").Updates(map[string]any{"role": ""})
+```
+
+## Delete — 批量删除
+
+```go
+// 批量删除满足条件的记录
+result := db.Where("age < ?", 18).Delete(&User{})
+fmt.Printf("删除了 %d 条记录\n", result.RowsAffected)
+// DELETE FROM users WHERE age < 18;
+
+// 物理批量删除(跳过软删除过滤)
+db.Unscoped().Where("deleted_at < ?", time.Now().AddDate(-1, 0, 0)).Delete(&User{})
+```
+
+> [!warning] 批量删除的威力
+> `Where(...).Delete()` 是极其危险的操作——一条错误的 WHERE 可能瞬间清空百万行。**执行前务必先用 SELECT 确认影响范围**:
+> ```go
+> // 先检查再删除
+> var count int64
+> db.Model(&User{}).Where("status = ?", "inactive").Count(&count)
+> if count == 0 {
+> return nil
+> }
+> log.Printf("即将删除 %d 条 inactive 记录...", count)
+> if !confirm() {
+> return errors.New("用户取消操作")
+> }
+> db.Where("status = ?", "inactive").Delete(&User{})
+> ```
+
+## Upsert — 存在则更新,不存在则插入
+
+GORM 通过 `Clauses(clause.OnConflict{})` 实现 MySQL 的 ON DUPLICATE KEY UPDATE 和 PostgreSQL 的 ON CONFLICT:
+
+### MySQL 语法
+
+```go
+// 用户注册时使用:已存在则更新最后登录时间,不存在则新建
+users := []User{
+ {Email: "alice@example.com", LastLogin: time.Now()},
+ {Email: "bob@example.com", LastLogin: time.Now()},
+}
+
+db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "email"}},
+ DoUpdates: clause.AssignmentColumns([]string{"last_login"}),
+}).Create(&users)
+// MySQL: INSERT INTO users (email, last_login) VALUES (...)
+// ON DUPLICATE KEY UPDATE last_login=VALUES(last_login);
+```
+
+### PostgreSQL 语法
+
+```go
+db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "email"}},
+ DoUpdates: clause.AssignmentColumns([]string{"last_login"}),
+}).Create(&users)
+// PostgreSQL: INSERT INTO users (email, last_login) VALUES (...)
+// ON CONFLICT (email) DO UPDATE SET last_login = excluded.last_login;
+```
+
+### 常见冲突策略
+
+| 策略 | 作用 | SQL 等价 |
+|------|------|---------|
+| `DoNothing` | 冲突时不操作 | `ON CONFLICT DO NOTHING` |
+| `DoUpdates` | 冲突时更新指定字段 | `ON CONFLICT DO UPDATE SET ...` |
+| `DoUpdates: clause.Assignments(expr)` | 自定义表达式 | `SET col = expr` |
+
+```go
+// 冲突时递增计数器而非覆盖
+db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "key"}},
+ DoUpdates: clause.AssignmentColumns([]string{"count"}),
+}).Create(&KV{})
+// 如果 key 已存在 → count = count + 1 (由 GORM 自动处理)
+```
+
+## Callback 机制——自定义批量行为
+
+GORM 允许你在特定操作阶段注入自己的逻辑,实现真正的批量定制:
+
+```go
+// 在批量创建完成后自动发送通知
+db.Callback().Create().After("gorm:create").Register("send_notifications", func(tx *gorm.DB) {
+ // tx.Statement.Dest 包含被创建的数据
+ if dest, ok := tx.Statement.Dest.([]User); ok {
+ for _, user := range dest {
+ sendWelcomeEmail(user.Email)
+ }
+ }
+})
+```
+
+> [!tip] Callback 阶段排序
+> GORM 的操作流程可拆解为明确的阶段,每个阶段都有对应的 Hook 点:
+> ```
+> CREATE: BeforeQuery → BeforeCreate → Create → AfterCreate → AfterQuery
+> UPDATE: BeforeQuery → BeforeUpdate → Update → AfterUpdate → AfterQuery
+> DELETE: BeforeQuery → BeforeDelete → Delete → AfterDelete → AfterQuery
+> FIND: BeforeQuery → Query → AfterFind → AfterQuery
+> ```
+> 你可以在任意阶段的前后注册回调。
+
+## 批量操作决策图
+
+```mermaid
+flowchart TD
+ Start[需要批量操作数据] --> OpType{操作类型_}
+
+ OpType --> |插入| InsertQ{"数据量?"}
+ InsertQ --> |< 10K| SimpleInsert["Create(slice)"]
+ InsertQ --> |≥ 10K| BatchInsert["CreateInBatches(batchSize)"]
+
+ OpType --> |更新| UpdateQ{"是否需要精准字段控制?"}
+ UpdateQ --> |不需要| MapUpdate["Updates(map)"]
+ UpdateQ --> |需要| FieldControl["Select/Omit + Updates"]
+
+ OpType --> |删除| DelCheck["先 Count 确认范围
再 Delete"]
+
+ OpType --> |存在则更新| Upsert["Clauses(OnConflict)"]
+
+ style Start fill:#4FC08D,color:#fff
+ style BatchInsert fill:#3B82F6,color:#fff
+ style MapUpdate fill:#F59E0B,color:#000
+ style Upsert fill:#8B5CF6,color:#fff
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 批量插入太慢 | 单条 SQL 过大被数据库拒绝 | 用 CreateInBatches 控制批次大小 |
+| 批量 Updates struct 跳过了零值 | struct 模式下零值被视为「未修改」 | 改用 Updates(map) |
+| 误删大量数据 | WHERE 条件过于宽泛 | 先 SELECT COUNT 确认,再加 limit 逐步删除 |
+| OnConflict 没生效 | 目标列没有唯一约束 | 确保冲突列上有 UNIQUE 或 PRIMARY KEY |
+| 批量操作中钩子阻塞 | 每条都发 HTTP 请求 | 合并为一次性批量调用或使用异步队列 |
+
+## 关联笔记
+
+- [[03-CRUD 操作]]
+- [[08-事务管理]]
+- [[09-钩子函数]]
+- [[11-批量操作]]
+- [[15-性能优化]]
diff --git a/hhs/GORM/12-自定义字段类型.md b/hhs/GORM/12-自定义字段类型.md
new file mode 100644
index 0000000..d1391f2
--- /dev/null
+++ b/hhs/GORM/12-自定义字段类型.md
@@ -0,0 +1,263 @@
+---
+tags: [GORM, Go, ORM, 自定义类型, Scanner, Valuer, JSON, GORMDataType]
+create time: 2026-04-28 00:00
+---
+
+# 自定义字段类型
+
+## 概述
+
+内置类型(int、string、time.Time 等)覆盖了大部分场景,但当需要存储特殊格式的数据时——比如将 Go 的 `map[string]any` 序列化为 JSON、或者使用领域模型中的 Value Object——就需要实现自定义类型与数据库列之间的双向转换。
+
+```mermaid
+flowchart LR
+ GoVal["Go 结构体值"] -->|序列化→| DBVal["数据库列值"]
+ DBVal -->|反序列化←| GoVal
+
+ style GoVal fill:#3B82F6,color:#fff
+ style DBVal fill:#EAB308,color:#fff
+```
+
+GORM 提供三组接口,分别解决不同层次的定制需求:
+
+| 接口 | 方向 | 作用 | 适用场景 |
+|------|------|------|---------|
+| `driver.Valuer` + `sql.Scanner` | 读写双相 | 完全控制序列化/反序列化 | JSON 字段、加密字符串 |
+| `GORMDataType()` | 建表时 | 覆盖 GORM 的类型推导 | 自定义枚举、位图 |
+| `Scan` / `Value` | 驱动级 | 最底层的 database/sql 适配 | 需要兼容所有库的场景 |
+
+> [!tip] 核心原则
+> 这些接口的本质是**桥接**——让普通的 Go struct 能够被 database/sql 驱动「理解」。GORM 在读取和写入数据时会检测类型是否实现了相应接口,如果实现了就调用它们。
+
+## ValueScanner 模式(推荐)
+
+这是最常用也最灵活的方式——同时实现 `driver.Valuer`(Go → DB)和 `sql.Scanner`(DB → Go)两个接口:
+
+### JSON 字段
+
+```go
+type JSONMap map[string]interface{}
+
+// Value: Go → DB(序列化)
+func (j JSONMap) Value() (driver.Value, error) {
+ if j == nil {
+ return nil, nil
+ }
+ bytes, err := json.Marshal(j)
+ if err != nil {
+ return nil, err
+ }
+ return string(bytes), nil
+}
+
+// Scan: DB → Go(反序列化)
+func (j *JSONMap) Scan(value interface{}) error {
+ if value == nil {
+ *j = nil
+ return nil
+ }
+ str, ok := value.([]byte)
+ if !ok {
+ str = []byte(fmt.Sprintf("%v", value))
+ }
+ return json.Unmarshal(str, j)
+}
+
+// 使用示例
+type Setting struct {
+ ID uint `gorm:"primaryKey"`
+ Key string `gorm:"size:64;uniqueIndex"`
+ Value JSONMap `gorm:"type:json"` // MySQL 5.7+ / PostgreSQL JSONB
+}
+
+// 写入
+db.Create(&Setting{Key: "theme", Value: JSONMap{"color": "dark", "fontSize": 14}})
+
+// 读取
+var setting Setting
+db.Where("key = ?", "theme").First(&setting)
+fmt.Println(setting.Value["color"]) // "dark"
+```
+
+### 自定义枚举类型
+
+```go
+type Priority int8
+
+func (p Priority) String() string {
+ names := map[Priority]string{
+ 1: "urgent",
+ 2: "high",
+ 3: "normal",
+ 4: "low",
+ }
+ return names[p]
+}
+
+func (p Priority) Value() (driver.Value, error) {
+ return int8(p), nil
+}
+
+func (p *Priority) Scan(value interface{}) error {
+ if value == nil {
+ *p = 3 // default normal
+ return nil
+ }
+ *p = Priority(value.(int64))
+ return nil
+}
+
+type Task struct {
+ ID uint `gorm:"primaryKey"`
+ Title string `gorm:"size:128"`
+ Priority Priority `gorm:"not null;default:3"`
+}
+
+// 赋值时使用类型安全的枚举
+db.Create(&Task{Title: "Fix login bug", Priority: Priority(1)}) // urgent
+
+// 查询时直接得到强类型值
+var task Task
+db.First(&task, 1)
+fmt.Println(task.Priority.String()) // "urgent"
+```
+
+### IP 地址存储
+
+```go
+type IPPool net.IP
+
+func (ip IPPool) Value() (driver.Value, error) {
+ return ip.To4(), nil
+}
+
+func (ip *IPPool) Scan(value interface{}) error {
+ bytes, ok := value.([]byte)
+ if !ok {
+ return fmt.Errorf("invalid IP type: %T", value)
+ }
+ *ip = IPPool(net.ParseIP(string(bytes)))
+ return nil
+}
+```
+
+> [!question] 思考题
+> 为什么 Value 和 Scan 的方法签名分别是 `(JSONMap)` 和 `(j *JSONMap)`?一个用值接收者、一个用指针接收者?
+>
+> > **答案**:`Value()` 只需要读取当前值来序列化,不需要修改原对象;而 `Scan()` 需要将数据库读出的值写回目标变量,所以必须用指针才能修改外部结构体的内容。
+
+## GORMDataType — 仅覆盖类型
+
+如果你只需要告诉 GORM「这个类型对应什么数据库列类型」,不需要做复杂的序列化逻辑:
+
+```go
+type Status byte // 0=pending 1=processing 2=done 3=failed
+
+func (Status) GORMDataType() string {
+ return "TINYINT UNSIGNED" // 覆盖 GORM 默认的 INT 推导
+}
+
+type Ticket struct {
+ ID uint `gorm:"primaryKey"`
+ Status Status `gorm:"not null;default:0"`
+}
+```
+
+> [!note] GORMDataType 的作用范围
+> 它只在**建表(AutoMigrate)**时生效,不影响运行时读写行为。也就是说,GORM 会用这个类型创建列,但数据的序列化和反序列化仍由 `database/sql` 的标准处理完成。
+
+## 结合 Use 注册全局解析器
+
+对于不想在每个 struct 上实现接口的场景(比如第三方类型的扩展),可以用 `RegisterResolve`:
+
+```go
+// 为 net.IP 类型注册 GORM 解析逻辑
+db.Use(clause.OnConflict{}, func(db *gorm.DB) error {
+ // 这个方式已经过时了...
+ return nil
+})
+
+// 更现代的方式是用 GORM 的 Resolver
+type MyType string
+
+// 注册一个自定义数据类型解析器
+// 适用于无法给已有类型添加方法的情况
+```
+
+> [!info] 注意
+> GORM 对全局类型注册的 API 在不同版本间有变化。推荐的实践是给类型加上方法实现 Valuer/Scanner——这样代码内聚性更好,依赖也更清晰。
+
+## AutoMigrate 时的自定义类型
+
+当你在 AutoMigrate 中使用自定义类型时,GORM 会根据以下步骤决定列类型:
+
+```mermaid
+flowchart TD
+ A[字段类型 T] --> B{T 实现
GORMDataType?}
+ B -->|是| C["使用返回值
作为列类型"]
+ B -->|否| D{"T 是已知内置类型?"}
+ D -->|是| E["使用默认映射"]
+ D -->|否| F["尝试 driver.Valuer
推断类型"]
+ F --> G["使用 driver.Value
的反射结果"]
+
+ style A fill:#4FC08D,color:#fff
+ style C fill:#3B82F6,color:#fff
+ style E fill:#A0AEC0,color:#fff
+```
+
+## 综合实战:配置项存储系统
+
+实际项目中经常遇到需要存储任意 key-value 配置的场景:
+
+```go
+// 通用配置表——用 JSON 存动态字段
+type AppConfig struct {
+ gorm.Model
+ Namespace string `gorm:"size:64;not null;index:idx_namespace_key"`
+ Key string `gorm:"size:128;not null"`
+ Data JSONMap `gorm:"type:json;not null"`
+}
+
+// 索引保证同一命名空间下 key 不重复
+func (AppConfig) TableName() string {
+ return "app_configs"
+}
+
+// CRUD 示例
+func SetConfig(db *gorm.DB, ns, key string, data map[string]any) error {
+ config := AppConfig{Namespace: ns, Key: key, Data: JSONMap(data)}
+ return db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "namespace"}, {Name: "key"}},
+ DoUpdates: clause.AssignmentColumns([]string{"data"}),
+ }).Create(&config).Error
+}
+
+func GetConfig(db *gorm.DB, ns, key string) (JSONMap, error) {
+ var config AppConfig
+ if err := db.Where("namespace = ? AND key = ?", ns, key).First(&config).Error; err != nil {
+ return nil, err
+ }
+ return config.Data, nil
+}
+```
+
+> [!tip] 为什么 JSON 比拆多个列更好?
+> - 字段动态增减不需要改表结构
+> - 可以嵌套复杂数据结构
+> - MySQL/PostgreSQL 都提供了 JSON 查询函数(如 `JSON_EXTRACT`),配合 GORM 也能使用
+>
+> **代价**:失去了行级的类型安全和部分字段的独立索引能力。
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| Scan 接收不到 nil 值 | 未处理 value == nil 分支 | Scan 开头检查 nil 并返回 nil |
+| Value 序列化失败导致整个操作中断 | JSON marshal 出错 | 做好错误处理和防御性编码 |
+| GORMDataType 没生效 | 忘记嵌入或别名方法声明 | 确保方法是 receiver 类型上的公开方法 |
+| Postgres JSONB vs MySQL JSON 差异 | 驱动行为不一致 | 用 `Select` 显式指定 column 类型 |
+
+## 关联笔记
+
+- [[02-模型定义]]
+- [[13-多数据库支持]]
diff --git a/hhs/GORM/13-多数据库支持.md b/hhs/GORM/13-多数据库支持.md
new file mode 100644
index 0000000..0149e1a
--- /dev/null
+++ b/hhs/GORM/13-多数据库支持.md
@@ -0,0 +1,224 @@
+---
+tags: [GORM, Go, ORM, MySQL, PostgreSQL, SQLite, SQL Server]
+create time: 2026-04-28 00:00
+---
+
+# 多数据库支持
+
+## 概述
+
+GORM 的「驱动适配器」架构让它能够无缝切换不同的数据库后端——你写同一份 GORM 代码,只需改变 `gorm.Open()` 的参数就能连接不同数据库。但要注意:**不同数据库的方言差异**可能导致某些功能表现不一致。
+
+```mermaid
+flowchart TD
+ Code["你的 GORM 代码"] --> Adapter{"Driver Adapter"}
+
+ Adapter --> |mysql| MySQL["MySQL / MariaDB"]
+ Adapter --> |postgres| PG["PostgreSQL"]
+ Adapter --> |sqlite| SQLite["SQLite"]
+ Adapter --> |mssql| MSSQL["SQL Server"]
+
+ style Code fill:#4FC08D,color:#fff
+ style Adapter fill:#EAB308,color:#fff
+ style MySQL fill:#3B82F6,color:#fff
+ style PG fill:#8B5CF6,color:#fff
+ style SQLite fill:#A0AEC0,color:#fff
+ style MSSQL fill:#F59E0B,color:#000
+```
+
+## 驱动安装与初始化
+
+### MySQL / MariaDB
+
+```go
+import (
+ "gorm.io/driver/mysql"
+)
+
+dsn := "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
+db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
+```
+
+**关键 DSN 参数说明**:
+
+| 参数 | 必填 | 说明 |
+|------|------|------|
+| `charset` | ✅ 推荐 | 必须设为 `utf8mb4`(支持 emoji) |
+| `parseTime` | ✅ 推荐 | 自动将 DB 时间转为 Go `time.Time` |
+| `loc` | ✅ 推荐 | `Local` 或 `Asia/Shanghai`,避免时区混乱 |
+| `timeout` | 可选 | 连接超时时间,如 `30s` |
+| `readTimeout` | 可选 | 读取超时 |
+| `writeTimeout` | 可选 | 写入超时 |
+
+### PostgreSQL
+
+```go
+import (
+ "gorm.io/driver/postgres"
+)
+
+dsn := "host=localhost user=gorm password=gorm dbname=gorm port=5432 sslmode=require TimeZone=Asia/Shanghai"
+db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
+```
+
+**PostgreSQL 特有注意**:
+- `sslmode` 在开发环境可设为 `disable`,生产环境保持 `require` 或 `verify-full`
+- `TimeZone` 要与服务端一致,否则时间会偏移
+
+### SQLite
+
+```go
+import (
+ "gorm.io/driver/sqlite"
+)
+
+db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
+// 简单到只需要一个文件路径!
+```
+
+**SQLite 注意事项**:
+- 默认是文件级锁,并发写入会有阻塞
+- 高并发场景下建议用 `file:test.db?cache=shared&_journal=WAL` 启用共享缓存和 WAL 模式
+- **不支持外键约束**——AutoMigrate 不会创建外键
+
+### SQL Server
+
+```go
+import (
+ "gorm.io/driver/mssql"
+)
+
+dsn := "sqlserver://user:password@host:1433?database=dbname"
+db, err := gorm.Open(mssql.Open(dsn), &gorm.Config{})
+```
+
+## 方言差异速查表
+
+这是跨数据库迁移时最容易踩坑的部分——同样是 `time.Time`,在不同数据库中的列类型可能完全不同:
+
+| 特性 | MySQL | PostgreSQL | SQLite | SQL Server |
+|------|-------|-----------|--------|------------|
+| `time.Time` | DATETIME / TIMESTAMP | TIMESTAMPTZ | TEXT(序列化后) | DATETIME2 |
+| `string` | VARCHAR(n) | VARCHAR / TEXT | TEXT | NVARCHAR(n) |
+| `uint` | INT UNSIGNED | BIGINT | INTEGER | INT |
+| `float64` | DOUBLE | DOUBLE PRECISION | REAL | FLOAT |
+| 自增主键 | AUTO_INCREMENT | SERIAL / BIGSERIAL | AUTOINCREMENT | IDENTITY(1,1) |
+| NOW() 函数 | NOW() | NOW() | DATETIME('now') | GETUTCDATE() |
+| LIMIT 语法 | LIMIT N OFFSET M | LIMIT N OFFSET M | LIMIT N OFFSET M | TOP N / OFFSET FETCH |
+| 软删除默认行为 | ⚠️ InnoDB 唯一约束 bug | ✅ 正常 | ✅ 正常 | ✅ 正常 |
+
+## CREATE TABLE 差异
+
+同一个 struct 在不同数据库下生成的建表语句不同:
+
+```go
+type User struct {
+ ID uint `gorm:"primaryKey;autoIncrement"`
+ Name string `gorm:"size:64;not null"`
+ Age int `gorm:"not null;default:0"`
+ CreatedAt time.Time `gorm:"autoTime"`
+}
+```
+
+| 数据库 | 实际建表语句摘要 |
+|--------|----------------|
+| MySQL | `id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, age INT NOT NULL DEFAULT 0, created_at DATETIME` |
+| PostgreSQL | `id BIGSERIAL PRIMARY KEY, name VARCHAR(64) NOT NULL, age INT NOT NULL DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE` |
+| SQLite | `id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL DEFAULT 0, created_at TEXT` |
+
+> [!tip] 如何解决方言差异?
+> 1. **选择目标数据库再编码**——如果你确定只部署在 MySQL 上,就用 MySQL 的特性;
+> 2. **使用兼容模式**——优先使用 GORM 内置的类型映射,不要手动指定方言特有的类型;
+> 3. **多数据库测试**——CI 中同时跑多个数据库版本的测试。
+
+## AutoMigrate 跨数据库问题
+
+```go
+// MySQL
+err := db.AutoMigrate(&User{}, &Order{})
+
+// PostgreSQL
+pgDB, _ := gorm.Open(postgres.Open(pgDSN), &gorm.Config{})
+err = pgDB.AutoMigrate(&User{}, &Order{})
+// 注意:同样一个 User struct,在 PG 中会自动用 BIGSERIAL 而非 AUTO_INCREMENT
+```
+
+> [!warning] 迁移不可逆操作
+> 某些操作在不同数据库中表现不同:
+> - MySQL `AUTO_INCREMENT` → Postgres `BIGSERIAL`:已有的 MySQL 表迁移到 PG 时,自增序列需要重新设置
+> - SQLite 不支持 ALTER TABLE 添加非空列——`AutoMigrate` 只能新增列不能修改已有列的结构
+>
+> **最佳实践**:使用独立的迁移工具(如 Goose、golang-migrate),手写 SQL 脚本而非依赖 AutoMigrate。
+
+## 查询语法差异
+
+```go
+// 分页:MySQL / SQLite vs PostgreSQL 基本一致
+db.Limit(10).Offset(20).Find(&users)
+
+// SQL Server 的分页语法不同——GORM 内部会自动转换
+// db.Limit(10).Offset(20).Find(&users)
+// → SELECT TOP 10 * FROM users WHERE id NOT IN (SELECT TOP 20 id FROM users)
+
+// UUID 字段
+type Order struct {
+ ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid()"` // PG
+ // 改为 type:char(36);default:newid() // SQL Server
+ // 改为 type:binary(16) // MySQL
+}
+```
+
+## 最佳实践总结
+
+### 开发环境 vs 生产环境
+
+```go
+func newDB(env string) (*gorm.DB, error) {
+ var dsn string
+ switch env {
+ case "dev":
+ dsn = "file:test_dev.db?cache=shared" // SQLite 快速迭代
+ case "staging":
+ dsn = os.Getenv("DATABASE_URL") // 共享 PG 实例
+ case "prod":
+ dsn = os.Getenv("PROD_DATABASE_URL") // 独立 PG 集群
+ }
+
+ driver := "sqlite"
+ if env != "dev" {
+ driver = "postgres"
+ }
+
+ return gorm.Open(getDriver(driver)(dsn), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent), // 生产环境关闭详细日志
+ })
+}
+
+func getDriver(name string) func(string) gorm.Dialector {
+ switch name {
+ case "postgres":
+ return postgres.Open
+ case "mysql":
+ return mysql.Open
+ case "sqlite":
+ return sqlite.Open
+ case "mssql":
+ return mssql.Open
+ default:
+ panic("unknown driver: " + name)
+ }
+}
+```
+
+> [!tip] 为什么推荐开发用 SQLite?
+> - 零配置:不需要启动数据库服务
+> - 单文件:版本控制友好(当然大表不适合 git)
+> - 语法兼容性:大部分 SQL 标准都能正常工作
+>
+> **但**:SQLite 不支持并发写入和事务隔离级别调整,不能完全替代生产数据库做性能测试。
+
+## 关联笔记
+
+- [[01-安装与初始化]]
+- [[02-模型定义]]
+- [[17-迁移工具]]
diff --git a/hhs/GORM/14-错误处理.md b/hhs/GORM/14-错误处理.md
new file mode 100644
index 0000000..b14b06b
--- /dev/null
+++ b/hhs/GORM/14-错误处理.md
@@ -0,0 +1,271 @@
+---
+tags: [GORM, Go, ORM, 错误处理, ErrRecordNotFound, ErrDuplicatedKey, TransactionFinished]
+create time: 2026-04-28 00:00
+---
+
+# 错误处理
+
+## 概述
+
+GORM **从不 panic**——所有错误都通过 `.Error` 字段返回。这种设计让业务层可以在不中断程序的情况下优雅地处理异常,但也意味着开发者需要养成「始终检查 Error」的习惯。
+
+```mermaid
+flowchart TD
+ Result["db.XXX() → result"] --> HasErr{"result.Error != nil?"}
+ HasErr --> |否| Success["继续业务逻辑 ✓"]
+ HasErr --> |是| CheckType{判断错误类型}
+
+ CheckType --> |ErrRecordNotFound| NotFound["404 / 默认值"]
+ CheckType --> |ErrDuplicatedKey| Duplicate["409 / 提示用户修改"]
+ CheckType --> |TransactionFinished| TxErr["500 / 事务已被提交或回滚"]
+ CheckType --> |其他 errors.Is| Generic["记录日志 / 返回通用错误"]
+
+ style Start fill:#4FC08D,color:#fff
+ style Success fill:#3B82F6,color:#fff
+ style NotFound fill:#EF4444,color:#fff
+```
+
+## 核心错误常量
+
+### gorm.ErrRecordNotFound
+
+```go
+var user User
+result := db.First(&user, 1)
+
+if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ // 记录不存在 —— 可以返回 404 或设置默认值
+ return http.NotFound(w, r)
+} else if result.Error != nil {
+ // 真正的数据库错误
+ log.Printf("查询用户失败: %v", result.Error)
+ return err
+}
+
+// 成功分支
+log.Printf("找到用户: %s", user.Name)
+```
+
+> [!tip] 为什么用 errors.Is 而不是 ==?
+> `gorm.ErrRecordNotFound` 是一个 sentinal error(哨兵错误),Go 标准库的 `errors.Is()` 能正确处理包装过的错误链。直接用 `==` 在复杂场景中可能失效。
+
+### gorm.ErrDuplicatedKey / gorm.ErrForeignKeyConstraintViolated
+
+```go
+result := db.Create(&User{Email: "alice@example.com"})
+
+if errors.Is(result.Error, gorm.ErrDuplicatedKey) {
+ // 唯一约束冲突 —— 通常是 Email 已注册
+ return fmt.Errorf("该邮箱已被注册")
+}
+
+// 外键约束失败
+type Order struct { UserID uint }
+result := db.Create(&Order{UserID: 999})
+if errors.Is(result.Error, gorm.ErrForeignKeyConstraintViolated) {
+ return fmt.Errorf("指定的用户不存在")
+}
+```
+
+### gorm.TransactionFinished
+
+```go
+tx := db.Begin()
+tx.Commit() // 先提交
+tx.Rollback() // ❌ 再回滚会报错!
+// result.Error == gorm.ErrTransactionFinished
+```
+
+> [!warning] 常见触发场景
+> 这个错误最常见于 `defer Rollback()` 模式——如果 Commit 成功了但 defer 仍会执行 Rollback:
+> ```go
+> func Example() error {
+> tx := db.Begin()
+> defer func() {
+> if r := recover(); r != nil {
+> tx.Rollback()
+> panic(r)
+> }
+> }()
+>
+> // ... 操作 ...
+>
+> if err := tx.Commit().Error; err != nil {
+> tx.Rollback()
+> return err
+> }
+> // 到达这里说明 Commit 成功 —— defer 不会进 panic 分支
+> return nil
+> }
+> ```
+
+### 常用错误常量汇总
+
+| 常量 | 含义 | 典型 HTTP 状态码 | 处理建议 |
+|------|------|-----------------|---------|
+| `gorm.ErrRecordNotFound` | 查询结果为空 | 404 | 返回友好提示或默认值 |
+| `gorm.ErrDuplicatedKey` | 唯一约束冲突 | 409 | 提示用户修改输入 |
+| `gorm.ErrForeignKeyConstraintViolated` | 外键约束失败 | 400 | 检查关联数据是否存在 |
+| `gorm.ErrInvalidData` | 数据无效 | 400 | 校验输入后重试 |
+| `gorm.ErrTransactionFinished` | 事务已提交/回滚 | 500 | 代码逻辑 BUG,修复代码 |
+| `gorm.ErrUnsupportedDriver` | 不支持的驱动 | 配置错误 | 检查 dsn 和驱动导入 |
+| `gorm.ErrInvalidTransaction` | 无效的事务操作 | 500 | 检查事务上下文是否正确 |
+
+## 自定义错误判断
+
+### 包装业务错误
+
+```go
+func GetUserByID(db *gorm.DB, id uint) (*User, error) {
+ var user User
+ if err := db.First(&user, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, fmt.Errorf("用户 ID=%d 不存在: %w", id, err)
+ }
+ return nil, fmt.Errorf("查询用户失败: %w", err)
+ }
+ return &user, nil
+}
+```
+
+### 自定义错误处理器
+
+```go
+// 统一错误解析器,适合中间件或框架层使用
+func ParseDBError(err error) (int, string) {
+ switch {
+ case errors.Is(err, gorm.ErrRecordNotFound):
+ return http.StatusNotFound, "资源未找到"
+ case errors.Is(err, gorm.ErrDuplicatedKey):
+ return http.StatusConflict, "数据冲突,请检查输入"
+ case errors.Is(err, gorm.ErrForeignKeyConstraintViolated):
+ return http.StatusBadRequest, "关联数据无效"
+ case errors.Is(err, gorm.ErrTransactionFinished):
+ return http.StatusInternalServerError, "系统内部错误"
+ default:
+ log.Printf("未分类数据库错误: %v", err)
+ return http.StatusInternalServerError, "数据库操作失败"
+ }
+}
+
+// 在 Gin handler 中使用
+func HandleCreateUser(c *gin.Context) {
+ err := createUserService(c.Request)
+ if err != nil {
+ status, msg := ParseDBError(err)
+ c.JSON(status, gin.H{"error": msg})
+ return
+ }
+ c.JSON(201, gin.H{"message": "创建成功"})
+}
+```
+
+## 错误检查的正确姿势
+
+### ❌ 错误的写法
+
+```go
+// 1. 完全忽略错误
+db.Create(&user)
+
+// 2. 只检查非 nil(会误判 ErrRecordNotFound)
+if err := db.First(&user, 1).Error; err != nil {
+ // ErrRecordNotFound 也会被当成一般错误处理
+ log.Printf("error: %v", err)
+}
+
+// 3. 先判断 Error != nil 再用 == 比较
+err := db.First(&user, 1).Error
+if err != nil && err == gorm.ErrRecordNotFound {
+ // 在某些情况下可能漏判
+}
+```
+
+### ✅ 正确的写法
+
+```go
+// 模式一:errors.Is 优先
+if err := db.First(&user, 1).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ // 单独处理
+ } else {
+ // 其他数据库错误
+ }
+}
+
+// 模式二:先赋值再判断
+result := db.First(&user, 1)
+switch {
+case errors.Is(result.Error, gorm.ErrRecordNotFound):
+ handleNotFound()
+case result.Error != nil:
+ handleError(result.Error)
+default:
+ handleSuccess(user)
+}
+
+// 模式三:简洁的单路径
+if result := db.First(&user, 1); result.Error != nil {
+ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ return nil, fmt.Errorf("user not found")
+ }
+ return nil, fmt.Errorf("db error: %w", result.Error)
+}
+```
+
+## 连接错误与超时处理
+
+```go
+// 检测数据库是否可达
+sqlDB, err := db.DB()
+if err != nil {
+ return err
+}
+
+ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+defer cancel()
+
+if err := sqlDB.PingContext(ctx); err != nil {
+ log.Printf("数据库连接异常: %v", err)
+ // 可以尝试重连、切换从库等
+ return fmt.Errorf("database unavailable")
+}
+```
+
+> [!tip] 结合健康检查
+> 在生产环境中,应该定期执行 `PingContext` 作为 K8s liveness/readiness probe,确保服务知道数据库是否可用。
+
+## 错误处理决策流程图
+
+```mermaid
+flowchart TD
+ Start[操作返回结果] --> CheckErr{"result.Error 非空?"}
+ CheckErr --> |否| Success["操作成功"]
+ CheckErr --> |是| FirstCheck{errors.Is...}
+
+ FirstCheck --> |ErrRecordNotFound| Handler1["处理: 返回默认值/404"]
+ FirstCheck --> |ErrDuplicatedKey| Handler2["处理: 提示重复"]
+ FirstCheck --> |ErrForeignKeyConstraintViolated| Handler3["处理: 检查关联"]
+ FirstCheck --> |ErrTransactionFinished| Handler4["处理: 代码缺陷修复"]
+ FirstCheck --> |都不匹配| DefaultHandler["处理: 通用错误/500"]
+
+ style Start fill:#4FC08D,color:#fff
+ style Success fill:#3B82F6,color:#fff
+ style DefaultHandler fill:#EF4444,color:#fff
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 忘记检查 `.Error` | 以为方法直接返回 error | 养成 `if err := xxx().Error; err != nil` 习惯 |
+| 用 `==` 而非 `errors.Is` | 哨兵错误被 fmt.Errorf("%w") 包装后丢失 | 统一使用 `errors.Is(err, gorm.xxx)` |
+| Commit 后再 Rollback | defer 无条件执行回滚 | 只在 panic 分支中 Rollback |
+| 把所有错误当 RecordNotFound | 没做错误类型分层 | 先用 errors.Is 精确判断 |
+| 数据库断开时没检测到 | 连接池复用旧连接 | PingContext 定期探测 + SetConnMaxLifetime |
+
+## 关联笔记
+
+- [[03-CRUD 操作]]
+- [[08-事务管理]]
+- [[16-日志与调试]]
diff --git a/hhs/GORM/15-性能优化.md b/hhs/GORM/15-性能优化.md
new file mode 100644
index 0000000..e2ea49d
--- /dev/null
+++ b/hhs/GORM/15-性能优化.md
@@ -0,0 +1,324 @@
+---
+tags: [GORM, Go, ORM, 性能优化, N+1, Preload, Index, QueryPlan]
+create time: 2026-04-28 00:00
+---
+
+# 性能优化
+
+## 概述
+
+GORM 的功能丰富但有一定运行时开销——相比于手写 SQL,它的查询构建和反射机制会带来额外的 CPU/内存消耗。了解这些瓶颈并针对性优化,是 GORM 在生产环境中稳定运行的高并发场景下的关键。
+
+```mermaid
+flowchart TD
+ A["🐌 慢的原因"] --> B{"类别"}
+ B --> |查询次数多| SlowQuery["N+1 / 过多 SQL"]
+ B --> |数据量大| LargeResult["未分页 / 全表扫描"]
+ B --> |缺少索引| NoIndex["WHERE / JOIN / ORDER BY 无索引"]
+ B --> |框架开销| FrameworkOverhead["反射 + 对象映射"]
+
+ SlowQuery --> FixPreload["Preload 预加载"]
+ LargeResult --> FixPaging["Limit / Offset"]
+ NoIndex --> FixIndex["添加数据库索引"]
+ FrameworkOverhead --> FixRaw["Raw SQL / Select 指定字段"]
+
+ style A fill:#EF4444,color:#fff
+ style FixPreload fill:#3B82F6,color:#fff
+ style FixPaging fill:#4FC08D,color:#fff
+ style FixIndex fill:#EAB308,color:#fff
+ style FixRaw fill:#8B5CF6,color:#fff
+```
+
+## N+1 问题排查与解决
+
+### 什么是 N+1?
+
+N+1 是最常见的 ORM 性能杀手——为了获取关联数据,先执行一条查询拿主记录,然后对每条主记录再发起一次查询取关联数据。
+
+```go
+// ❌ N+1:查 100 个用户需要 101 条 SQL
+users := []User{}
+db.Find(&users) // SQL 1: SELECT * FROM users;
+for _, u := range users {
+ db.First(&u.Profile, u.ID) // SQL 2~101: SELECT * FROM profiles WHERE user_id = ?
+}
+
+// ✅ 解决方案一:Preload(变成 2 条 SQL)
+var users []User
+db.Preload("Profile").Find(&users)
+// SQL 1: SELECT * FROM users;
+// SQL 2: SELECT * FROM profiles WHERE user_id IN (1,2,3,...,100);
+
+// ✅ 解决方案二:Joins(变成 1 条 SQL)
+db.Joins("Profile").Find(&users)
+// SELECT users.*, profiles.* FROM users LEFT JOIN profiles ON ...;
+```
+
+### 使用 Debug() 检测 N+1
+
+```go
+db.Debug().Find(&users)
+// 输出所有生成的 SQL:
+// [info] ... [0.5ms] [rows:100] SELECT * FROM users
+// [info] ... [1.2ms] [rows:100] SELECT * FROM profiles WHERE user_id IN (...)
+```
+
+> [!tip] 如何判断是否该用 Preload 还是 Joins?
+
+| 维度 | Preload | Joins |
+|------|---------|-------|
+| SQL 数量 | N+1 | 通常 1 |
+| 内存占用 | 适中 | JOIN 膨胀时较高 |
+| 控制力 | 可分别过滤关联 | 难以精细化 |
+| 推荐场景 | 大多数情况 | 只需少量关联字段的简单场景 |
+
+## 预加载策略优化
+
+### 按需预加载(只查需要的字段)
+
+```go
+// ❌ 加载整个 Profile,浪费带宽
+db.Preload("Profile").Find(&users)
+
+// ✅ 只取需要的列
+db.Preload("Profile", "user_id, avatar_url").Find(&users)
+// SELECT * FROM users;
+// SELECT user_id, avatar_url FROM profiles WHERE user_id IN (...);
+```
+
+### 条件预加载
+
+```go
+// 只预加载状态为 active 的订单
+db.Preload("Orders", "status = ?", "active").Find(&users)
+
+// 复杂条件
+db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
+ return db.Where("amount > ?", 100).Order("created_at DESC").Limit(5)
+}).Find(&users)
+```
+
+### 嵌套预加载的深度控制
+
+```go
+// ⚠️ 四层嵌套:User → Orders → OrderItems → Product → Category
+// SQL 数量 = 1 + 4 = 5 条,但每次 IN 列表可能非常大
+// 当每层平均有 100 条记录时 → IN 列表可能有 10^6 个值!
+
+// 解决方案:分层查询而非一次性全部预加载
+func GetUserWithTopOrders(db *gorm.DB, id uint) (*User, error) {
+ var user User
+ if err := db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
+ return db.Order("created_at DESC").Limit(10)
+ }).First(&user, id).Error; err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+```
+
+> [!warning] IN 列表大小限制
+> 不同数据库对 IN 列表大小有限制:MySQL 默认 `max_allowed_packet` 约 4MB,超过会被截断或报错。对于大规模预加载,考虑分批查询:
+> ```go
+> func preloadInBatches(db *gorm.DB, ids []uint, dest any) error {
+> batch := 500
+> for i := 0; i < len(ids); i += batch {
+> end := i + batch
+> if end > len(ids) {
+> end = len(ids)
+> }
+> chunk := ids[i:end]
+> if err := db.Where("user_id IN ?", chunk).Find(dest).Error; err != nil {
+> return err
+> }
+> }
+> return nil
+> }
+> ```
+
+## 减少不必要的 SELECT
+
+### Select 白名单
+
+```go
+// ❌ 加载所有字段(包括不想暴露的密码、内部标记等)
+db.Find(&users)
+
+// ✅ 只选需要的字段
+db.Select("id", "name", "email").Find(&users)
+
+// ✅ 甚至可以用别名做字段转换
+db.Select("id AS user_id, name AS display_name").Find(&users)
+```
+
+### Scan 替代 Find
+
+```go
+// 只需要统计信息,没必要实例化整个模型
+type UserCount struct {
+ Count int64
+}
+
+var stat UserCount
+db.Model(&User{}).Select("COUNT(*) as count").Scan(&stat)
+```
+
+## 启用预编译语句缓存
+
+```go
+db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
+ PrepareStmt: true, // 开启预编译语句缓存
+})
+
+// 效果:相同 SQL 的第二次执行会使用 cached statement
+// 显著降低 SQL 解析和计划编译的开销
+```
+
+> [!note] 缓存机制
+> GORM 内置一个基于 `sync.Map` 的简单缓存,存储已经编译好的 SQL 语句。适合重复执行的固定模式查询(如根据 ID 查用户)。不适合大量参数不同的动态查询——缓存命中率低会浪费内存。
+
+## 索引优化建议
+
+### 在 tag 中声明索引
+
+```go
+type Article struct {
+ ID uint `gorm:"primaryKey"`
+ Title string `gorm:"size:128;not null;index:idx_title"` // 普通索引
+ Status string `gorm:"size:16;not null;index:idx_status"` // 普通索引
+ UserID uint `gorm:"index:idx_article_user"` // 外键索引
+ CreatedAt time.Time `gorm:"index:idx_created_at"` // 时间索引
+
+ // 复合索引
+ Slug string `gorm:"size:128;uniqueIndex:idx_slug_owner"` // 联合唯一索引
+
+ // 覆盖索引(PostgreSQL 支持 INCLUDE)
+ ViewCount int `gorm:"index:idx_status_views,type:int"`
+}
+```
+
+### 何时加索引
+
+| 场景 | 是否需索引 | 理由 |
+|------|-----------|------|
+| WHERE 高频查询的列 | ✅ | 避免全表扫描 |
+| JOIN 关联字段 | ✅ | 外键几乎都应该有索引 |
+| ORDER BY 排序字段 | ✅ | 避免 filesort |
+| 写频率高的字段 | ❌ 谨慎 | 每个索引增加写入成本(INSERT/UPDATE 要维护索引树) |
+| 低基数列(如 gender) | ❌ | 区分度太低,优化器不走索引 |
+| LIKE '%xxx' 前通配 | ❌ | 索引失效 |
+
+### 查看执行计划
+
+```go
+// MySQL EXPLAIN
+db.Debug().Where("status = ? AND created_at > ?", "active", cutoff).Find(&users)
+// 从日志中找到生成的 SQL,在 MySQL 客户端执行:
+// EXPLAIN SELECT * FROM users WHERE status = 'active' AND created_at > '...';
+
+// 关注点:
+// - type: 应该是 range 或 ref,不能是 ALL(全表扫描)
+// - rows: 预估扫描行数,越小越好
+// - Extra: 出现 Using filesort 说明需要额外排序;Using temporary 说明用了临时表
+```
+
+## 事务粒度优化
+
+```go
+// ❌ 事务范围过大 —— HTTP 请求也包在里面
+tx := db.Begin()
+defer tx.Rollback()
+
+user, _ := fetchFromAPI() // 网络请求 200ms
+if err := tx.Create(&user).Error; err != nil {
+ return err
+}
+
+order, _ := fetchFromAnotherAPI() // 另一个网络请求 300ms
+if err := tx.Create(&order).Error; err != nil {
+ return err
+}
+
+return tx.Commit().Error
+// 总耗时:200 + DB + 300 + DB ≈ 500ms+
+// 期间行锁一直持有,其他事务只能等待
+
+// ✅ 事务范围仅包含 DB 操作
+func SaveToDB(tx *gorm.DB, user, order any) error {
+ if err := tx.Create(user).Error; err != nil {
+ return err
+ }
+ return tx.Create(order).Error
+}
+
+// 外层只包数据库操作
+func HandleRequest() error {
+ user, _ := fetchFromAPI()
+ order, _ := fetchFromAnotherAPI()
+
+ return db.Transaction(func(tx *gorm.DB) error {
+ return SaveToDB(tx, user, order)
+ })
+}
+// 总耗时:DB + DB ≈ 几十 ms
+```
+
+> [!tip] Transaction API 简洁写法
+> GORM 提供了一个闭包形式的 `Transaction`,比手动的 Begin/Rollback/Commit 更安全:
+> ```go
+> err := db.Transaction(func(tx *gorm.DB) error {
+> // 返回非 nil 自动回滚
+> if err := tx.Create(&user).Error; err != nil {
+> return err
+> }
+> return tx.Create(&order).Error
+> })
+> ```
+
+## 性能优化检查清单
+
+```mermaid
+flowchart LR
+ A["性能瓶颈"] --> Q1{"SQL 数量过多?"}
+ Q1 -->|是| B["用 Preload / Joins 合并"]
+ Q1 -->|否| Q2{"结果集太大?"}
+ Q2 -->|是| C["加 Limit / 筛选字段"]
+ Q2 -->|否| Q3{"扫描行数多?"}
+ Q3 -->|是| D["检查索引"]
+ Q3 -->|否| Q4{"单条 SQL 太慢?"}
+ Q4 -->|是| E["EXPLAIN 分析执行计划"]
+ Q4 -->|否| F{"框架开销占比高?"}
+ F -->|是| G["启用 PrepareStmt / 改用 Raw SQL"]
+ F -->|否| H["已达标 ✓"]
+
+ style A fill:#EF4444,color:#fff
+ style H fill:#4FC08D,color:#fff
+```
+
+| ✅ 优化项 | 收益 | 复杂度 |
+|-----------|------|--------|
+| 消除 N+1(Preload) | ⭐⭐⭐⭐⭐ | 低 |
+| Select 白名单 | ⭐⭐⭐⭐ | 低 |
+| 添加索引 | ⭐⭐⭐⭐⭐ | 低 |
+| 缩小事务范围 | ⭐⭐⭐⭐ | 中 |
+| PrepareStmt 缓存 | ⭐⭐⭐ | 低 |
+| Raw SQL 替换热点查询 | ⭐⭐⭐⭐⭐ | 高 |
+| 批量操作替代循环 | ⭐⭐⭐⭐⭐ | 低 |
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| Preload 后接口极慢 | IN 列表过大 | 限制关联数量或用 Join 替代 |
+| 查询响应不稳定 | 没有索引导致偶发全表扫描 | 用 EXPLAIN 找出缺失索引 |
+| 插入速度慢 | 逐条插入 + 事务未复用 | CreateInBatches |
+| 连接池耗尽 | 事务内调用外部服务 | 将 IO 操作移出事务 |
+| 预编译缓存泄漏 | PrepareStmt=true 但 SQL 都是随机构造的 | 只对固定模式查询开启 |
+
+## 关联笔记
+
+- [[03-CRUD 操作]]
+- [[04-条件查询]]
+- [[05-关联查询]]
+- [[08-事务管理]]
+- [[11-批量操作]]
diff --git a/hhs/GORM/16-日志与调试.md b/hhs/GORM/16-日志与调试.md
new file mode 100644
index 0000000..22c1b59
--- /dev/null
+++ b/hhs/GORM/16-日志与调试.md
@@ -0,0 +1,266 @@
+---
+tags: [GORM, Go, ORM, 日志, Debug, SlowQueryThreshold, Logger]
+create time: 2026-04-28 00:00
+---
+
+# 日志与调试
+
+## 概述
+
+在生产环境中,你能看到的往往只有两条信息:**API 返回了什么**和**数据库执行了什么**。GORM 内置了灵活的日志系统,让你能够在不同环境下精准控制输出粒度,从「生产静默」到「开发透明」自由切换。
+
+```mermaid
+flowchart TD
+ A["你的代码"] --> B["GORM SQL 生成"]
+ B --> C{"Logger 配置"}
+
+ C --> |Panic| D["出错了才输出
生产默认值"]
+ C --> |Error| E["只记录错误 SQL"]
+ C --> |Warn| F["慢查询 + 错误"]
+ C --> |Info| G["所有 SQL + 耗时
开发推荐"]
+
+ style Start fill:#4FC08D,color:#fff
+ style Info fill:#3B82F6,color:#fff
+ style Panic fill:#EF4444,color:#fff
+```
+
+## 日志级别速览
+
+GORM 的 `logger.Interface` 定义了四个级别:
+
+| 级别 | 输出内容 | 适用环境 |
+|------|---------|---------|
+| `Silent` | 什么都不输出 | 压测、极端性能场景 |
+| `Error` | 仅错误 | 稳定运行的生产环境 |
+| `Warn` | 错误 + 慢查询 | 需要监控的生产环境 |
+| `Info` | 所有 SQL + 参数 + 耗时 | 开发 / 调试环境 |
+
+> [!tip] 默认级别是 `Panic`(等同于 Silent)
+> GORM **不会在运行时产生任何日志**,除非你显式配置。这既保护隐私也减少磁盘 IO,但也意味着出了问题时需要临时调高日志级别来排查。
+
+## 配置方式
+
+### 基础用法
+
+```go
+import (
+ "gorm.io/gorm/logger"
+ "time"
+)
+
+newLogger := logger.New(
+ logger.Config{
+ SlowThreshold: 200 * time.Millisecond, // 超过 200ms 视为慢查询
+ Colorful: true, // 终端彩色输出
+ IgnoreRecordNotFoundError: false, // 记录 ErrRecordNotFound
+ LogLevel: logger.Info, // 日志级别
+ },
+)
+
+db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
+ Logger: newLogger,
+})
+```
+
+### 自定义 Logger(对接 Zap / Logrus 等)
+
+```go
+// 用 Zap 替代默认日志
+type ZapLogger struct {
+ logger.Interface
+ zapLogger *zap.Logger
+}
+
+func (z ZapLogger) LogMode(level logger.Level) logger.Interface {
+ newLogger := z
+ newLogger.Interface = z.Interface.LogMode(level)
+ return newLogger
+}
+
+func (z ZapLogger) Info(ctx context.Context, msg string, data ...interface{}) {
+ z.zapLogger.Sugar().Infow(msg, data...)
+}
+
+func (z ZapLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
+ z.zapLogger.Sugar().Warnw(msg, data...)
+}
+
+func (z ZapLogger) Error(ctx context.Context, msg string, data ...interface{}) {
+ z.zapLogger.Sugar().Errorw(msg, data...)
+}
+
+// 使用
+zapLogger, _ := zap.NewProduction()
+db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
+ Logger: ZapLogger{Interface: logger.Default, zapLogger: zapLogger},
+})
+```
+
+## SQL 日志格式解析
+
+当启用 `Info` 级别时,你会看到类似这样的输出:
+
+```
+[info] ... [0.12ms] [rows:-] SELECT * FROM users WHERE id = 1
+
+│ │ │ │ │ │
+│ │ │ │ │ └── SQL 语句
+│ │ │ │ └── 影响行数 (- 表示查询不确定)
+│ │ │ └── 执行耗时
+│ │ └── 操作标签(SELECT/INSERT/UPDATE/DELETE)
+│ └── 文件位置: src/model/user.go:15
+└── 日志级别
+```
+
+### 关键指标解读
+
+| 字段 | 含义 | 关注点 |
+|------|------|--------|
+| `[0.12ms]` | 单次执行耗时 | 超过 SlowThreshold 会用 WARN 标记 |
+| `[rows:42]` | 影响/返回行数 | SELECT 时为正数,INSERT/UPDATE/DELETE 为影响行数 |
+| `[rows:-]` | 行数未知(通常是 SELECT) | 无法提前知道,需执行后统计 |
+
+## Debug — 临时开启详细日志
+
+不想改全局配置的情况下,可以用 `Debug()` 方法临时对单个查询开启全量日志:
+
+```go
+// 只对这一条 SQL 输出详细信息
+result := db.Debug().Where("status = ?", "active").Find(&users)
+// 输出:[info] ... [1.23ms] [rows:150] SELECT * FROM users WHERE status='active'
+
+// 对比:没有 Debug() 时可能完全看不到这条 SQL
+result = db.Where("status = ?", "active").Find(&users)
+// 无输出(如果 Logger 是 Silent/Error)
+```
+
+> [!tip] Debug 的实际用途
+> - **快速定位问题 SQL**:在一个复杂链式调用中,找到是哪一步生成的 SQL 不对
+> - **Code Review 时的验证**:确认 GORM 确实生成了预期的 SQL 语句
+> - **单元测试**:临时开日志但不修改全局配置
+
+## 慢查询监控
+
+### 设置慢查询阈值
+
+```go
+// 超过 100ms 的 SQL 会被标记为 WARN
+newLogger := logger.New(
+ logger.Config{
+ SlowThreshold: 100 * time.Millisecond,
+ Colorful: true,
+ LogLevel: logger.Warn,
+ },
+)
+```
+
+### 结合 Prometheus 做量化监控
+
+```go
+import "github.com/prometheus/client_golang/prometheus"
+
+var queryDuration = prometheus.NewHistogramVec(
+ prometheus.HistogramOpts{
+ Name: "gorm_query_duration_ms",
+ Help: "GORM query duration in milliseconds",
+ },
+ []string{"operation", "table"},
+)
+
+// 在每个请求结束后采集(简化示例)
+func RecordQueryDuration(op, table string, dur time.Duration) {
+ queryDuration.WithLabelValues(op, table).Observe(dur.Seconds() * 1000)
+}
+```
+
+> [!question] 思考题
+> 什么时候该关注慢查询?100ms、500ms 还是 1s?
+>
+> > **答案**:取决于业务 SLA。对于 API 网关场景,单条 SQL 建议控制在 **50ms 以内**;对于离线批处理任务,可以放宽到秒级。关键是建立基线——先跑一周看 P95/P99 数据,再设定阈值。
+
+## 常见调试技巧
+
+### 打印最终 SQL(不执行)
+
+```go
+// DryRun 模式:构建完整 SQL 并输出,但不实际执行
+db.Session(&gorm.Session{DryRun: true}).First(&user, 1)
+// 输出:[info] ... [rows:0] SELECT * FROM users WHERE id = 1 -- dry run
+// 此时你可以拿到完整的 SQL 去客户端手动验证
+```
+
+### 获取生成的 SQL 字符串
+
+```go
+// 使用 Statement 直接构造并获取 SQL
+stmt := db.Model(&User{}).Where("name = ?", "john").Statement
+db.Statement.Build(stmt.DB.Build("WHERE"))
+fmt.Println(stmt.SQL.String())
+// SELECT * FROM users WHERE name = 'john'
+```
+
+### 日志中加入 TraceID(链路追踪)
+
+```go
+func WithTraceID(db *gorm.DB, traceID string) *gorm.DB {
+ return db.Session(&gorm.Session{
+ Context: context.WithValue(context.Background(), "trace_id", traceID),
+ })
+}
+
+// 自定义日志输出中包含 trace_id
+type TracedLogger struct {
+ logger.Interface
+}
+
+func (t TracedLogger) Info(ctx context.Context, msg string, data ...interface{}) {
+ traceID, _ := ctx.Value("trace_id").(string)
+ if traceID != "" {
+ msg = fmt.Sprintf("[trace:%s] %s", traceID, msg)
+ }
+ t.Interface.Info(ctx, msg, data...)
+}
+```
+
+## 日志配置决策图
+
+```mermaid
+flowchart TD
+ Start["确定日志需求"] --> Env{"运行环境?"}
+
+ Env --> |开发环境| Dev["Info 级别 + Colorful + SlowThreshold=200ms"]
+ Env --> |测试环境| Test["Warn 级别 + SlowThreshold=100ms"]
+ Env --> |生产环境| ProdQ{"需要实时监控?"}
+
+ ProdQ --> |不需要| ProdSilent["Error 级别(默认)"]
+ ProdQ --> |需要| ProdMonitor["Warn 级别 + 接入监控体系"]
+
+ Dev --> Check{"特定查询需要调试?"}
+ Test --> Check
+ ProdMonitor --> Check
+
+ Check --> |是| DebugOn["db.Debug() 临时开启"]
+ Check --> |否| Done["配置完成 ✓"]
+
+ style Start fill:#4FC08D,color:#fff
+ style Dev fill:#3B82F6,color:#fff
+ style ProdMonitor fill:#EAB308,color:#fff
+ style Done fill:#A0AEC0,color:#fff
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 生产环境日志太多导致磁盘爆满 | Logger 级别设太高 | 生产用 Warn 或 Error |
+| 日志中没有 TraceID 难以定位 | 没传 Context | 用 Session + Context 传递 |
+| DryRun 拿到的 SQL 参数是 ? | 预编译语句的参数未展开 | 这是正常行为,参数化查询安全性的体现 |
+| Debug() 改了全局状态 | Debug 返回新的 *gorm.DB,不影响原实例 | 放心使用,它是纯函数式的 |
+| 慢查询阈值设太低 | 正常查询也被标记为 slow | 先收集基线数据再设定合理阈值 |
+
+## 关联笔记
+
+- [[01-安装与初始化]]
+- [[03-CRUD 操作]]
+- [[14-错误处理]]
+- [[15-性能优化]]
diff --git a/hhs/GORM/17-迁移工具.md b/hhs/GORM/17-迁移工具.md
new file mode 100644
index 0000000..4fbb04f
--- /dev/null
+++ b/hhs/GORM/17-迁移工具.md
@@ -0,0 +1,339 @@
+---
+tags: [GORM, Go, ORM, 迁移, AutoMigrate, Migrator, 版本化]
+create time: 2026-04-28 00:00
+---
+
+# 迁移工具
+
+## 概述
+
+数据库迁移是应用演进的基础设施——每次修改模型结构都需要将变更同步到数据库。GORM 提供了原生的 `AutoMigrate`,但对于生产环境来说,通常推荐使用**版本化的迁移脚本**来获得更细粒度的控制和回滚能力。
+
+```mermaid
+flowchart LR
+ Code["Go struct 变更"] --> AutoMigrate["AutoMigrate
自动同步 schema"]
+ Code --> MigrationFile["版本化 SQL 文件
goose / golang-migrate"]
+
+ AutoMigrate --> Dev{"使用场景?"}
+ MigrationFile --> Dev
+
+ Dev --> |快速原型/个人项目| Quick["✅ AutoMigrate 够用"]
+ Dev --> |团队协作/生产环境| Strict["✅ 版本化迁移方案"]
+
+ style Code fill:#4FC08D,color:#fff
+ style Quick fill:#3B82F6,color:#fff
+ style Strict fill:#EAB308,color:#fff
+```
+
+## AutoMigrate — 一键建表
+
+### 基本用法
+
+```go
+func migrate(db *gorm.DB) error {
+ return db.AutoMigrate(
+ &User{},
+ &Order{},
+ &Product{},
+ )
+}
+```
+
+AutoMigrate 会在以下情况下自动操作:
+- **表不存在** → CREATE TABLE
+- **列不存在** → ALTER TABLE ADD COLUMN
+- **列类型不匹配** → ALTER TABLE MODIFY COLUMN
+- **索引不存在** → CREATE INDEX
+
+### 增量更新
+
+```go
+// 第一次:只有 User 和 Order
+db.AutoMigrate(&User{}, &Order{})
+
+// 第二次:给 User 加了 Email 字段
+type User struct {
+ ID uint
+ Name string
+ Email string `gorm:"size:128;uniqueIndex"` // 新增字段
+}
+db.AutoMigrate(&User{})
+// 不会删除已有的字段,只会添加新列和索引
+```
+
+> [!warning] AutoMigrate 的限制
+> - **不能删除**不再出现在 struct 中的列(需要手动处理)
+> - **不能重命名**列(需要先删除再创建)
+> - **不能修改主键**定义
+> - MySQL 下 `ALTER TABLE MODIFY COLUMN` 可能会重建整张表(锁表!)
+> - 某些复杂的类型变更(如 varchar 改 int)可能不被支持
+
+### DryRun 预览变更
+
+```go
+// 查看 AutoMigrate 会做什么,但不执行
+err := db.Session(&gorm.Session{DryRun: true}).AutoMigrate(&User{})
+if err != nil {
+ log.Printf("AutoMigrate 将产生的 SQL 错误(仅 DryRun): %v", err)
+}
+```
+
+> [!tip] DryRun + AutoMigrate 的组合
+> DryRun 模式下的 AutoMigrate 会构建 SQL 语句但**不执行**。结合这个特性可以做迁移前校验——在 CI 中检查新模型是否会导致破坏性变更。
+
+## Migrator 接口 —— 细粒度控制
+
+GORM 提供了 `Migrator` 接口来访问底层数据库的迁移能力:
+
+```go
+migrator := db.Migrator()
+
+// 检查表是否存在
+if migrator.HasTable(&User{}) {
+ fmt.Println("users 表已存在")
+}
+
+// 检查列是否存在
+if migrator.HasColumn(&User{}, "email") {
+ fmt.Println("email 列已存在")
+}
+
+// 获取列的类型信息
+column, err := migrator.ColumnType(&User{}, "email")
+fmt.Printf("列名: %s, 类型: %s, 长度: %d\n",
+ column.Name(), column.DatabaseTypeName(), column.Length())
+
+// 重命名列(如果驱动支持)
+migrator.RenameColumn(&User{}, "name", "username")
+
+// 删除列
+migrator.DropColumn(&User{}, "old_field")
+
+// 创建/删除索引
+migrator.CreateIndex(&User{}, "IdxEmail")
+migrator.HasIndex(&User{}, "IdxEmail")
+migrator.DropIndex(&User{}, "IdxEmail")
+```
+
+> [!note] 不同驱动的 Migrator 实现
+> 每个驱动都有自己的 `Migrator` 实现。不是所有操作在所有数据库上都受支持:
+> ```go
+> // 检查某个驱动是否支持特定操作
+> if migrator, ok := db.Migrator().(*mysql.Migrator); ok {
+> // MySQL 特有的迁移功能
+> }
+> ```
+
+## 版本化迁移方案(生产推荐)
+
+在生产环境中,推荐使用专门的迁移工具配合 GORM:
+
+### 方案一:Goose
+
+```bash
+# 安装
+go install github.com/pressly/goose/v3/cmd/goose@latest
+
+# 创建迁移文件
+goose create add_user_email sql
+
+# 生成文件:20260101_001_add_user_email.sql
+-- +goose Up
+ALTER TABLE users ADD COLUMN email VARCHAR(128) UNIQUE;
+ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
+
+-- +goose Down
+ALTER TABLE users DROP COLUMN email;
+ALTER TABLE users DROP COLUMN email_verified;
+```
+
+与 GORM 集成:
+
+```go
+func migrateDB(db *gorm.DB) {
+ // 先让 GORM 做必要的表存在性检查和基础初始化
+ db.AutoMigrate(&User{})
+
+ // 然后用 goose 执行版本化迁移脚本
+ goose.Run("up", dsn)
+}
+```
+
+### 方案二:golang-migrate
+
+```bash
+# 安装
+go install github.com/golang-migrate/migrate/v4/cmd/migrate@latest
+
+# 创建迁移
+migrate create -ext sql -dir migrations -seq add_user_email
+
+# migrations/001_add_user_email.sql
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- PG 特有
+
+ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(128);
+ALTER TABLE users ADD CONSTRAINT unique_email UNIQUE (email);
+
+-- <向下迁移>
+ALTER TABLE users DROP COLUMN IF EXISTS email;
+```
+
+```go
+// 启动时执行迁移
+func initDB() {
+ m, err := migrate.New(
+ "migrations/file://./migrations",
+ dsn,
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if err := m.Up(); err != nil && err != migrate.ErrNoChange {
+ log.Fatal(err)
+ }
+
+ // 迁移成功后才连接 GORM
+ db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
+}
+```
+
+### 方案三:自建轻量级迁移表
+
+适合小型项目——用一个表记录当前迁移版本号:
+
+```go
+type MigrationRecord struct {
+ ID uint `gorm:"primaryKey"`
+ Version string `gorm:"size:32;uniqueIndex;not null"`
+ Applied time.Time
+}
+
+// 迁移脚本库
+var migrations = []struct {
+ Version string
+ SQL string
+}{
+ {"v0.1", "CREATE TABLE IF NOT EXISTS users (id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL)"},
+ {"v0.2", "ALTER TABLE users ADD COLUMN email VARCHAR(128) UNIQUE"},
+ {"v0.3", "ALTER TABLE users ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP"},
+}
+
+func ApplyMigrations(tx *gorm.DB) error {
+ var appliedVersions []string
+ tx.Model(&MigrationRecord{}).Pluck("version", &appliedVersions)
+
+ for _, m := range migrations {
+ if contains(appliedVersions, m.Version) {
+ continue // 已执行过,跳过
+ }
+
+ log.Printf("执行迁移: %s", m.Version)
+ if err := tx.Exec(m.SQL).Error; err != nil {
+ return fmt.Errorf("迁移 %s 失败: %w", m.Version, err)
+ }
+
+ tx.Create(&MigrationRecord{Version: m.Version, Applied: time.Now()})
+ }
+ return nil
+}
+```
+
+> [!question] 为什么生产环境不推荐只用 AutoMigrate?
+
+| 维度 | AutoMigrate | 版本化迁移 |
+|------|------------|-----------|
+| 可预测性 | GORM 内部决定执行顺序 | SQL 完全可控 |
+| 回滚能力 | ❌ 无内置回滚 | ✅ 有 Down 脚本 |
+| 团队协作 | 容易冲突(各自改了 model 就 auto) | 合并 SQL 文件 |
+| 数据库差异 | 自动处理方言 | 手写 SQL 需考虑目标 DB |
+| CI/CD 集成 | 难判断是否有未执行的变更 | migration_version 表可做 gate |
+| 审计追踪 | ❌ | ✅ 谁在什么时候改了什么 |
+
+## 迁移策略对比
+
+```mermaid
+flowchart TD
+ ProjectSize{"项目规模?"}
+
+ ProjectSize --> |个人项目/
快速原型| Small["AutoMigrate + DryRun 验证"]
+ ProjectSize --> |小型团队/
单一数据库| Medium["自建迁移版本表
+ AutoMigrate 兜底"]
+ ProjectSize --> |企业级/
多环境/多数据库| Large["专业迁移工具
goose / migrate"]
+
+ style Small fill:#A0AEC0,color:#fff
+ style Medium fill:#3B82F6,color:#fff
+ style Large fill:#4FC08D,color:#fff
+```
+
+## 迁移最佳实践
+
+### 1. 幂等性设计
+
+迁移脚本应该能够重复执行而不报错:
+
+```sql
+-- ✅ 幂等写法
+ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(20);
+CREATE INDEX IF NOT EXISTS idx_users_phone ON users(phone);
+
+-- ❌ 非幂等——重复执行会报 "column already exists" 错误
+ALTER TABLE users ADD COLUMN phone VARCHAR(20);
+```
+
+### 2. 大表加列的最佳方式
+
+对线上大表(百万级以上)直接 `ALTER TABLE ADD COLUMN` 会锁表导致服务不可用:
+
+```sql
+-- 推荐方式:分步骤进行
+-- Step 1: 添加可为 NULL 的新列(不阻塞写入)
+ALTER TABLE large_table ADD COLUMN new_column INT;
+
+-- Step 2: 后台任务分批回填数据
+-- UPDATE large_table SET new_column = default_val WHERE ... LIMIT 10000;
+-- (循环直到全部回填)
+
+-- Step 3: 加上约束(此时数据已经合规了)
+ALTER TABLE large_table MODIFY COLUMN new_column INT NOT NULL DEFAULT 0;
+ALTER TABLE large_table ADD INDEX idx_new_col (new_column);
+```
+
+### 3. CI Pipeline 中的迁移检查
+
+```yaml
+# .github/workflows/db-check.yml
+jobs:
+ check-migrations:
+ runs-on: ubuntu-latest
+ services:
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ ports:
+ - 3306:3306
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Check AutoMigrate consistency
+ run: |
+ # 创建一个空的临时 DB,运行 AutoMigrate,然后比较 schema
+ go run ./cmd/check-schema ${{ secrets.DB_DSN }}
+```
+
+## 常见坑点速查
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| AutoMigrate 删不掉旧字段 | GORM 设计上不支持 drop | 手动写迁移脚本或手动 DROP |
+| 大表 ALTER 导致服务中断 | InnoDB 全表重建 | 用 pt-online-schema-change 或 gh-ost |
+| 迁移脚本不幂等 | 重复部署报错 | 使用 IF NOT EXISTS 条件判断 |
+| 本地环境与生产环境不一致 | 不同数据库方言 | Docker 容器里跑相同版本的 DB |
+| 忘记执行 Down 脚本 | 无法回滚 | 建立迁移脚本审查流程 |
+
+## 关联笔记
+
+- [[01-安装与初始化]]
+- [[02-模型定义]]
+- [[08-事务管理]]
+- [[13-多数据库支持]]
diff --git a/hhs/GORM/README.md b/hhs/GORM/README.md
index 19eec1d..5c3cdae 100644
--- a/hhs/GORM/README.md
+++ b/hhs/GORM/README.md
@@ -19,26 +19,26 @@ GORM 是 Go 生态中最流行的 ORM 库,本知识库系统整理 GORM 的核
### 2. 查询篇
-- **[04-条件查询](./04-条件查询/)** — Where 链式调用、Between / In / Like / 原生 SQL
-- **[05-关联查询](./05-关联查询/)** — Preload / Joins、HasOne / HasMany / BelongsTo / ManyToMany
-- **[06-排序与分页](./06-排序与分页/)** — Order / Limit / Offset / Paginate
-- **[07-子查询与分组](./07-子查询与分组/)** — Group / Having / SubQuery / 嵌套查询
+- **[04-条件查询](./04-条件查询)** — Where 链式调用、Between / In / Like / 原生 SQL
+- **[05-关联查询](./05-关联查询)** — Preload / Joins、HasOne / HasMany / BelongsTo / ManyToMany
+- **[06-排序与分页](./06-排序与分页)** — Order / Limit / Offset / Paginate
+- **[07-子查询与分组](./07-子查询与分组)** — Group / Having / SubQuery / 嵌套查询
### 3. 进阶篇
-- **[08-事务管理](./08-事务管理/)** — Tx 对象、Commit / Rollback、嵌套事务
-- **[09-钩子函数](./09-钩子函数/)** — Before / After Save、Create、Update、Delete、Find
-- **[10-软删除](./10-软删除/)** — SoftDelete 原理、Unscoped 强制查询
-- **[11-批量操作](./11-批量操作/)** — Bulk Insert / Update、Callback 机制
-- **[12-自定义字段类型](./12-自定义字段类型/)** — Scanner / Valuer 接口、JSON 字段
-- **[13-多数据库支持](./13-多数据库支持/)** — MySQL / PostgreSQL / SQLite / SQL Server
+- **[08-事务管理](./08-事务管理)** — Tx 对象、Commit / Rollback、嵌套事务
+- **[09-钩子函数](./09-钩子函数)** — Before / After Save、Create、Update、Delete、Find
+- **[10-软删除](./10-软删除)** — SoftDelete 原理、Unscoped 强制查询
+- **[11-批量操作](./11-批量操作)** — Bulk Insert / Update、Callback 机制
+- **[12-自定义字段类型](./12-自定义字段类型)** — Scanner / Valuer 接口、JSON 字段
+- **[13-多数据库支持](./13-多数据库支持)** — MySQL / PostgreSQL / SQLite / SQL Server
### 4. 工程实践篇
-- **[14-错误处理](./14-错误处理/)** — RecordNotFound、ErrRelatedExists、自定义错误判断
-- **[15-性能优化](./15-性能优化/)** — N+1 问题排查、Preload 策略、预编译语句
-- **[16-日志与调试](./16-日志与调试/)** — 慢查询监控、SQL 日志格式化
-- **[17-迁移工具](./17-迁移工具/)** — AutoMigrate / Migrator、版本化迁移策略
+- **[14-错误处理](./14-错误处理)** — RecordNotFound、ErrRelatedExists、自定义错误判断
+- **[15-性能优化](./15-性能优化)** — N+1 问题排查、Preload 策略、预编译语句
+- **[16-日志与调试](./16-日志与调试)** — 慢查询监控、SQL 日志格式化
+- **[17-迁移工具](./17-迁移工具)** — AutoMigrate / Migrator、版本化迁移策略
## 核心架构图