471 lines
13 KiB
Markdown
471 lines
13 KiB
Markdown
|
|
---
|
|||
|
|
tags: [DEV, GO, golang, GORM, backend, ORM, database]
|
|||
|
|
create time: 2026-04-22 10:00
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# GORM - Go 语言 ORM 框架
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
GORM 是 Go 语言中最流行的 ORM(对象关系映射)框架,由 [@jinzhu](https://github.com/jinzhu) 创建。它基于 `database/sql` 构建,提供了链式 API、关联预加载、插件扩展等特性,让 Go 开发者以符合 Go 风格的方式高效操作数据库。
|
|||
|
|
|
|||
|
|
> **思考**:为什么在有了 `database/sql` 标准库之后,还需要 GORM 这样的 ORM?ORM 的价值是什么?它的代价又是什么?
|
|||
|
|
|
|||
|
|
ORM 的核心价值在于**将数据库表映射为结构体**,用面向对象的方式操作数据,减少样板 SQL 代码。代价是:理解 ORM 生成的 SQL 至关重要,复杂查询场景下仍需手写 SQL。
|
|||
|
|
|
|||
|
|
## 安装与初始化
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
go get -u gorm.io/gorm
|
|||
|
|
go get -u gorm.io/driver/mysql # MySQL 驱动
|
|||
|
|
go get -u gorm.io/driver/postgres # PostgreSQL 驱动
|
|||
|
|
go get -u gorm.io/driver/sqlite # SQLite 驱动
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
import (
|
|||
|
|
"gorm.io/gorm"
|
|||
|
|
"gorm.io/driver/mysql"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// MySQL 连接
|
|||
|
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
|||
|
|
if err != nil {
|
|||
|
|
panic("failed to connect database")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 定义模型
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type User struct {
|
|||
|
|
ID uint `gorm:"primaryKey"` // 主键,自动命名为 id
|
|||
|
|
Name string `gorm:"size:255;not null"` // 非空 + 字段长度
|
|||
|
|
Email string `gorm:"uniqueIndex"` // 唯一索引
|
|||
|
|
Age int `gorm:"default:0"` // 默认值
|
|||
|
|
Role string `gorm:"default:'user'"`
|
|||
|
|
CreatedAt time.Time // 自动管理 CreatedAt
|
|||
|
|
UpdatedAt time.Time // 自动管理 UpdatedAt
|
|||
|
|
DeletedAt gorm.DeletedAt // 软删除支持
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**GORM 约定**:
|
|||
|
|
|
|||
|
|
| 约定 | 说明 |
|
|||
|
|
|------|------|
|
|||
|
|
| 表名 | 结构体名的复数形式(`User` → `users`),可通过 `TableName()` 重写 |
|
|||
|
|
| 主键 | 名为 `ID` 的字段自动作为主键,类型推断为 `INT UNIQUE PRIMARY KEY` |
|
|||
|
|
| 字段名 | Go 字段名 → snake_case 的列名(`UserName` → `user_name`) |
|
|||
|
|
| CreatedAt/UpdatedAt | 自动填充时间戳(可通过 `DisableCreateTimeIndex` / `DisableUpdateTimeIndex` 关闭) |
|
|||
|
|
|
|||
|
|
## CRUD 操作
|
|||
|
|
|
|||
|
|
### 创建
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
user := User{Name: "Alice", Age: 25, Role: "admin"}
|
|||
|
|
result := db.Create(&user) // 返回 *gorm.Result
|
|||
|
|
|
|||
|
|
// 批量创建(一次 INSERT)
|
|||
|
|
users := []User{
|
|||
|
|
{Name: "Bob", Age: 30},
|
|||
|
|
{Name: "Carol", Age: 28},
|
|||
|
|
}
|
|||
|
|
db.Create(&users) // GORM 自动优化为批量 INSERT
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 查询
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 单一查询
|
|||
|
|
var user User
|
|||
|
|
db.First(&user, 10) // 主键查询
|
|||
|
|
db.First(&user, "name = ?", "Alice") // 条件查询
|
|||
|
|
db.Where("age > ?", 20).First(&user) // Where 链式
|
|||
|
|
|
|||
|
|
// 多结果查询
|
|||
|
|
var users []User
|
|||
|
|
db.Where("role = ?", "user").Find(&users)
|
|||
|
|
|
|||
|
|
// 链式条件
|
|||
|
|
db.Where("role = ?", "admin").
|
|||
|
|
Where("age >= ?", 25).
|
|||
|
|
Order("age desc").
|
|||
|
|
Limit(10).
|
|||
|
|
Offset(20).
|
|||
|
|
Find(&users)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**GORM 查询 API 速查**:
|
|||
|
|
|
|||
|
|
| 方法 | 等价 SQL |
|
|||
|
|
|------|----------|
|
|||
|
|
| `First` / `Take` | `LIMIT 1`,找第一条 |
|
|||
|
|
| `Last` | `ORDER BY PRIMARY KEY DESC LIMIT 1` |
|
|||
|
|
| `Find` | `SELECT * FROM ...` |
|
|||
|
|
| `Where` | `WHERE ...` |
|
|||
|
|
| `Or` | `OR ...` |
|
|||
|
|
| `Not` | `NOT ...` |
|
|||
|
|
| `Order` | `ORDER BY ...` |
|
|||
|
|
| `Select` | `SELECT col1, col2` |
|
|||
|
|
| `Group` / `Having` | `GROUP BY` / `HAVING` |
|
|||
|
|
| `Joins` | `JOIN ...` |
|
|||
|
|
| `Scan` | 扫描到自定义结构体 |
|
|||
|
|
|
|||
|
|
> **注意**:`First`、`Last` 在没有数据时返回 `gorm.ErrRecordNotFound`,务必处理此错误。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart LR
|
|||
|
|
A["db.Model(&User{})"] --> B["Where 条件过滤"]
|
|||
|
|
B --> C["Order 排序"]
|
|||
|
|
C --> D["Select 字段选择"]
|
|||
|
|
D --> E["Limit/Offset 分页"]
|
|||
|
|
E --> F["Group/Having 分组"]
|
|||
|
|
F --> G["Preload 预加载关联"]
|
|||
|
|
G --> H["Find/First/Last 执行"]
|
|||
|
|
H --> I["Scan 扫描到结构体"]
|
|||
|
|
|
|||
|
|
classDef start fill:#4285f4,color:#fff
|
|||
|
|
classDef step fill:#f1f3f4,stroke:#333
|
|||
|
|
classDef finish fill:#34a853,color:#fff
|
|||
|
|
class A start
|
|||
|
|
class I finish
|
|||
|
|
class B,C,D,E,F,G,H step
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> **链式 API 的本质**:每个方法返回 `*gorm.DB` 对象,不断累积查询条件,最终 `Find`/`First` 时才组装并执行 SQL。
|
|||
|
|
|
|||
|
|
### 更新
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 更新单个字段
|
|||
|
|
db.Model(&user).Update("name", "Alice Updated")
|
|||
|
|
|
|||
|
|
// 更新多个字段
|
|||
|
|
db.Model(&user).Updates(User{Name: "Alice V2", Age: 26, Role: "superadmin"})
|
|||
|
|
|
|||
|
|
// 带条件的更新
|
|||
|
|
db.Where("age > ?", 18).Model(&user).Update("role", "adult")
|
|||
|
|
|
|||
|
|
// 用 map 更新(会跳过零值字段,慎用)
|
|||
|
|
db.Model(&user).Updates(map[string]interface{}{"name": "New", "age": 30})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 删除
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 软删除(推荐)
|
|||
|
|
db.Delete(&user) // 设置 DeletedAt = now(),而非物理删除
|
|||
|
|
|
|||
|
|
// 硬删除
|
|||
|
|
db.Unscoped().Delete(&user) // 物理删除
|
|||
|
|
|
|||
|
|
// 批量删除
|
|||
|
|
db.Where("age < ?", 18).Delete(&User{})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 关联(Associations)
|
|||
|
|
|
|||
|
|
GORM 支持三种关联关系,通过标签定义。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type User struct {
|
|||
|
|
gorm.Model
|
|||
|
|
Name string
|
|||
|
|
Role string
|
|||
|
|
CreditCards []CreditCard `gorm:"foreignKey:UserID"` // HasMany
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type CreditCard struct {
|
|||
|
|
gorm.Model
|
|||
|
|
Number string
|
|||
|
|
UserID uint `gorm:"not null"` // 外键
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 三种关联类型
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph LR
|
|||
|
|
subgraph User 用户
|
|||
|
|
U1["ID\nName\nRole"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph CreditCard 信用卡
|
|||
|
|
C1["ID\nNumber\nUserID (FK)"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph Profile 个人资料
|
|||
|
|
P1["ID\nBio\nUserID (FK)"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph Language 语言[关联表: user_languages]
|
|||
|
|
L1["ID\nName"]
|
|||
|
|
UM["user_id"]
|
|||
|
|
LM["language_id"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
U1 -->|"1:N HasMany"| C1
|
|||
|
|
U1 -->|"1:1 HasOne"| P1
|
|||
|
|
U1 -.->|"N:M BelongsToMany"| L1
|
|||
|
|
|
|||
|
|
classDef user fill:#4285f4,stroke:#336,stroke-width:2px,color:#fff
|
|||
|
|
classDef card fill:#ea4335,stroke:#c33,stroke-width:2px,color:#fff
|
|||
|
|
classDef profile fill:#34a853,stroke:#264,stroke-width:2px,color:#fff
|
|||
|
|
classDef lang fill:#fbbc04,stroke:#cc8,stroke-width:2px,color:#333
|
|||
|
|
|
|||
|
|
class U1 user
|
|||
|
|
class C1 card
|
|||
|
|
class P1 profile
|
|||
|
|
class L1 lang
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> **关键理解**:标签写在**一端**,GORM 会根据外键位置自动判断关系方向。
|
|||
|
|
|
|||
|
|
- **HasOne**:外键在对方模型(`Profile.UserID`)→ 写在 `User` 上用 `hasOne`
|
|||
|
|
- **HasMany**:外键在对方模型(`CreditCard.UserID`)→ 写在 `User` 上用 `hasMany`
|
|||
|
|
- **BelongsTo**:外键在当前模型 → 写在「从属」一方
|
|||
|
|
- **BelongsToMany**:需要中间关联表(`user_languages`)
|
|||
|
|
|
|||
|
|
| 关联类型 | 标签方式 | 示例 |
|
|||
|
|
|----------|----------|------|
|
|||
|
|
| **HasOne** | 外键在对方模型 | `Profile` 包含 `UserID`,`User` 通过 `HasOne` 关联 |
|
|||
|
|
| **HasMany** | 外键在对方模型 | `CreditCards` 数组,`CreditCard` 包含 `UserID` |
|
|||
|
|
| **BelongsTo** | 外键在当前模型 | `CreditCard` 包含 `UserID`,`BelongsTo` 指向 `User` |
|
|||
|
|
| **BelongsToMany** | 需要通过关联表 | `User` ↔ `Languages`(多对多) |
|
|||
|
|
|
|||
|
|
### 预加载(Preloading)
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// N+1 问题:一次查询 N 条关联记录
|
|||
|
|
db.Preload("CreditCards").Find(&users)
|
|||
|
|
// 生成 2 条 SQL:SELECT * FROM users; SELECT * FROM credit_cards WHERE user_id IN (...)
|
|||
|
|
|
|||
|
|
// 嵌套预加载
|
|||
|
|
db.Preload("CreditCards").
|
|||
|
|
Preload("Profile").
|
|||
|
|
Find(&users)
|
|||
|
|
|
|||
|
|
// 带条件的预加载
|
|||
|
|
db.Preload("CreditCards", "type = ?", "premium").Find(&users)
|
|||
|
|
|
|||
|
|
// 使用 Joins 预加载(生成 INNER JOIN,减少查询次数)
|
|||
|
|
db.Joins("Profile").Find(&users)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> **核心原则**:批量查询时务必使用 `Preload`,否则会在循环中触发 N+1 查询问题。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart LR
|
|||
|
|
subgraph Bad ["❌ 不使用 Preload - N+1 问题"]
|
|||
|
|
direction TB
|
|||
|
|
S1["SQL 1: SELECT * FROM users"] --> Loop["循环 N 次"]
|
|||
|
|
Loop --> S2["SQL 2: SELECT * FROM credit_cards WHERE user_id=1"]
|
|||
|
|
Loop --> S3["SQL 3: SELECT * FROM credit_cards WHERE user_id=2"]
|
|||
|
|
Loop --> S4["SQL N: SELECT * FROM credit_cards WHERE user_id=N"]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph Good ["✅ 使用 Preload - 2 条 SQL"]
|
|||
|
|
direction TB
|
|||
|
|
G1["SQL 1: SELECT * FROM users"] --> G2["SQL 2: SELECT * FROM credit_cards WHERE user_id IN (1,2,...,N)"]
|
|||
|
|
end
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 多对多关联(BelongsToMany)
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type User struct {
|
|||
|
|
gorm.Model
|
|||
|
|
Name string
|
|||
|
|
Languages []Language `gorm:"many2many:user_languages;"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type Language struct {
|
|||
|
|
gorm.Model
|
|||
|
|
Name string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 自动创建关联表 user_languages(字段:user_id, language_id)
|
|||
|
|
// 插入关联数据
|
|||
|
|
db.Model(&user).Association("Languages").Append(&Language{Name: "Go"})
|
|||
|
|
|
|||
|
|
// 查询关联
|
|||
|
|
db.Preload("Languages").Find(&users)
|
|||
|
|
|
|||
|
|
// 替换关联
|
|||
|
|
db.Model(&user).Association("Languages").Replace(
|
|||
|
|
&Language{Name: "Python"}, &Language{Name: "Rust"},
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 高级查询技巧
|
|||
|
|
|
|||
|
|
### Raw SQL & 条件构造
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 原生 SQL
|
|||
|
|
db.Raw("SELECT name, SUM(amount) FROM orders WHERE status = ? GROUP BY name", "PAID").
|
|||
|
|
Scan(&results)
|
|||
|
|
|
|||
|
|
// 条件表达式
|
|||
|
|
db.Where(
|
|||
|
|
"name LIKE ? AND age BETWEEN ? AND ?", "%jon%", 20, 30,
|
|||
|
|
).Find(&users)
|
|||
|
|
|
|||
|
|
// IN 查询
|
|||
|
|
db.Where("id IN ?", []int{1, 2, 3}).Find(&users)
|
|||
|
|
|
|||
|
|
// 子查询
|
|||
|
|
subQuery := db.Model(&Order).Select("user_id").Where("amount > ?", 100)
|
|||
|
|
db.Where("id IN (?)", subQuery).Find(&users)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 事务
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
tx := db.Begin() // 注意:事务中需使用 tx 而非 db
|
|||
|
|
|
|||
|
|
defer func() {
|
|||
|
|
if r := recover(); r != nil {
|
|||
|
|
tx.Rollback()
|
|||
|
|
}
|
|||
|
|
}()
|
|||
|
|
|
|||
|
|
if err := tx.Create(&user).Error; err != nil {
|
|||
|
|
tx.Rollback()
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := tx.Create(&order).Error; err != nil {
|
|||
|
|
tx.Rollback()
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return tx.Commit().Error
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> **关键点**:事务内的所有操作必须使用事务句柄 `tx`,而非全局 `db`。
|
|||
|
|
|
|||
|
|
### 自定义表名与列名
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func (User) TableName() string {
|
|||
|
|
return "app_users" // 不使用复数形式
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 列名自定义
|
|||
|
|
type User struct {
|
|||
|
|
ID int `gorm:"column:uid"`
|
|||
|
|
Name string `gorm:"column:username"`
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Scope(可复用查询片段)
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func AmountGT(amount int) func(db *gorm.DB) *gorm.DB {
|
|||
|
|
return func(db *gorm.DB) *gorm.DB {
|
|||
|
|
return db.Where("amount > ?", amount)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func Active() func(db *gorm.DB) *gorm.DB {
|
|||
|
|
return func(db *gorm.DB) *gorm.DB {
|
|||
|
|
return db.Where("status = ?", "active")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 使用
|
|||
|
|
db.Scopes(AmountGT(100), Active()).Find(&orders)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 性能优化
|
|||
|
|
|
|||
|
|
### 1. Select 指定字段
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 只查询需要的字段,避免加载大字段(如 text/blob)
|
|||
|
|
db.Select("name", "age").Find(&users)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 批量操作
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 批量写入:1次 INSERT N行,而非 N次 INSERT
|
|||
|
|
db.Create(&users)
|
|||
|
|
|
|||
|
|
// 批量更新:1次 UPDATE
|
|||
|
|
db.Model(&user).Where("id IN ?", ids).Update("status", "active")
|
|||
|
|
|
|||
|
|
// 批量删除
|
|||
|
|
db.Delete(&User{}, ids)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3. 关闭预编译(极端性能场景)
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
|||
|
|
PrepareStmt: true, // 开启预编译,适合高并发同一 SQL
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
开启 `PrepareStmt` 后,GORM 会使用 `sql.DB.Prepare` + `sql.Stmt` 缓存预编译语句,适合大量相同 SQL 的执行场景。
|
|||
|
|
|
|||
|
|
## 企业级注意事项
|
|||
|
|
|
|||
|
|
### 🚫 严禁使用 AutoMigrate
|
|||
|
|
|
|||
|
|
在团队协作和企业级项目中,**禁止使用 `db.AutoMigrate()`**。原因:
|
|||
|
|
|
|||
|
|
1. **不可控的 DDL**:自动建表/改表可能导致生产数据丢失
|
|||
|
|
2. **DBA 审计要求**:所有表结构变更需经过 SQL 审查
|
|||
|
|
3. **版本管理**:数据库结构应通过迁移脚本(如 `golang-migrate`)管理
|
|||
|
|
|
|||
|
|
**推荐做法**:通过 SQL 脚本初始化数据库,与 [数据库设计](file:///home/wonder/Code/obsidian/金山办公作业/Week05/数据库设计.md) 文档保持一致。
|
|||
|
|
|
|||
|
|
### 事务与并发
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// ❌ 错误:多个 goroutine 共享同一个 db 连接
|
|||
|
|
// db.Create(&user1) // 并发不安全
|
|||
|
|
|
|||
|
|
// ✅ 正确:事务自带连接池,每个 goroutine 开启独立事务
|
|||
|
|
func saveUser(tx *gorm.DB, user *User) error {
|
|||
|
|
return tx.Create(user).Error
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
GORM 底层使用 `sql.DB` 连接池,本身是并发安全的,但 `db.Begin()` 返回的事务对象不可跨 goroutine 共享。
|
|||
|
|
|
|||
|
|
### 错误处理
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
result := db.Where("id = ?", id).First(&user)
|
|||
|
|
if result.Error != nil {
|
|||
|
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|||
|
|
// 记录不存在
|
|||
|
|
} else {
|
|||
|
|
// 其他数据库错误
|
|||
|
|
return result.Error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## GORM vs 原生 SQL
|
|||
|
|
|
|||
|
|
| 场景 | 推荐方案 | 原因 |
|
|||
|
|
|------|----------|------|
|
|||
|
|
| 简单 CRUD | GORM | 代码简洁,开发效率高 |
|
|||
|
|
| 复杂 JOIN | GORM `Joins` | GORM 链式写法比手写 SQL 更直观 |
|
|||
|
|
| 聚合查询(GROUP BY + HAVING) | GORM `Select/Group/Having` | 原生 SQL 也可,看团队偏好 |
|
|||
|
|
| 复杂子查询/CTE | 原生 SQL `Raw/Scan` | GORM 对复杂 SQL 支持有限 |
|
|||
|
|
| 高性能写入(大批量) | GORM 批量 | 或直接用 `sql.Batch` |
|
|||
|
|
| 数据库迁移 | SQL 脚本 / golang-migrate | GORM AutoMigrate 禁用于生产 |
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[DEV/GO/README]]
|
|||
|
|
- [[金山办公作业/Week05/数据库设计]]
|
|||
|
|
- [[金山办公作业/Week05/全栈开发实战:AI 智能单词本]]
|