diff --git a/plan/week01-go-basics.md b/plan/week01-go-basics.md deleted file mode 100644 index c52faa1..0000000 --- a/plan/week01-go-basics.md +++ /dev/null @@ -1,239 +0,0 @@ -# 转岗计划 · 第 1 周学习计划(Go 基础语法) - -> 所属阶段:第一段 Go 全栈基础(第 1~5 周)。总纲见同目录 `README.md`。 - -目标:一周内掌握 Go 的核心语法,最后用纯标准库写出一个能跑的命令行“任务清单”小程序,为第 2 周用 Gin 写接口打基础。 - -每天建议投入 1.5 到 2 小时:先看资料 30 分钟,再动手写代码。代码可以让 AI 辅助,但每个文件都要自己能讲清楚每一行在做什么。 - -参考资料: -- Go 官方教程(中文):https://tour.go-zh.org/ -- Go by Example(中文):https://gobyexample-cn.github.io/ -- 官方文档:https://go.dev/doc/ - ---- - -## 最终项目结构 - -一周结束时,你的练习目录应该长这样: - -``` -go-learning/ -├── go.mod -├── day1/ -│ └── main.go -├── day2/ -│ ├── main.go -│ └── calc.go -├── day3/ -│ ├── main.go -│ └── collections.go -├── day4/ -│ ├── main.go -│ └── task.go -├── day5/ -│ ├── main.go -│ ├── storage.go -│ └── errors.go -├── day6/ -│ ├── main.go -│ ├── worker.go -│ └── calc_test.go -└── todo/ # 第 7 天的周末小项目 - ├── go.mod - ├── main.go - ├── internal/ - │ ├── task/ - │ │ ├── task.go - │ │ └── task_test.go - │ └── store/ - │ └── file_store.go - └── tasks.json # 程序运行后自动生成 -``` - ---- - -## 第 1 天:环境搭建和第一个程序 - -**学习内容** -- 在 Ubuntu 上安装 Go:`sudo apt install golang-go`,或从 https://go.dev/dl/ 下载最新版。用 `go version` 确认安装成功。 -- 编辑器装好 Go 插件(VS Code 或 Cursor 里搜索 “Go” 官方插件)。 -- 理解 module(模块)、package(包)、`main` 函数。 -- 变量声明:`var`、`:=`,常量 `const`,基本类型 `int`、`float64`、`string`、`bool`。 -- `fmt.Println`、`fmt.Printf` 格式化输出。 - -**要建的文件** -- `go-learning/go.mod`:在 `go-learning` 目录里运行 `go mod init go-learning` 生成。 -- `day1/main.go`: - - 打印 “Hello, Go”。 - - 声明你的姓名、工作年限、技术栈(用变量和常量),格式化打印成一段自我介绍。 - - 试一下类型转换:把 `int` 转成 `float64` 计算平均值。 - -**运行方式**:`go run ./day1` - -**完成标准**:能说清楚 `var a int = 1` 和 `a := 1` 的区别,以及为什么 Go 里声明了没用的变量会编译报错。 - ---- - -## 第 2 天:流程控制和函数 - -**学习内容** -- `if`、`for`(Go 只有 `for`,没有 `while`)、`switch`。 -- 函数定义、多返回值、命名返回值。 -- 函数作为参数(和 JS 里的回调对比)、匿名函数、闭包。 -- `defer` 的执行顺序。 - -**要建的文件** -- `day2/calc.go`: - - `func Add(a, b int) int` - - `func Divide(a, b float64) (float64, error)`:除数为 0 时返回错误。 - - `func Apply(nums []int, fn func(int) int) []int`:对每个数执行传入的函数,类似 JS 的 `map`。 -- `day2/main.go`: - - 调用上面三个函数并打印结果。 - - 用 `for` 打印 1 到 100 的 FizzBuzz。 - - 写一个 `defer` 的例子,观察打印顺序。 - -**运行方式**:`go run ./day2` - -**完成标准**:理解 Go 为什么用“多返回值 + error”而不是 `try/catch` 来处理错误。 - ---- - -## 第 3 天:数组、切片和 map - -**学习内容** -- 数组和切片的区别,`make`、`append`、`len`、`cap`。 -- 切片的底层共享内存问题(这是面试常考点)。 -- `map` 的增删改查、判断 key 是否存在(`v, ok := m[key]`)。 -- `range` 遍历。 - -**要建的文件** -- `day3/collections.go`: - - `func Filter(nums []int, fn func(int) bool) []int`:类似 JS 的 `filter`。 - - `func Unique(items []string) []string`:用 map 给字符串切片去重。 - - `func WordCount(text string) map[string]int`:统计一段英文里每个单词出现的次数(用 `strings.Fields`)。 -- `day3/main.go`: - - 调用以上函数并打印结果。 - - 写一个例子演示:对一个切片截取子切片后修改元素,原切片也跟着变。 - -**运行方式**:`go run ./day3` - -**完成标准**:能解释 `append` 什么时候会重新分配底层数组。 - ---- - -## 第 4 天:结构体、方法和接口(最重要的一天) - -**学习内容** -- `struct` 定义、初始化,和 JS 对象、TS 的 `interface` 对比。 -- 方法:值接收者和指针接收者的区别。 -- 接口 `interface`:Go 的接口是隐式实现的,不需要写 `implements`。 -- 结构体标签 `json:"title"` 和 `encoding/json` 的序列化、反序列化。 - -**要建的文件** -- `day4/task.go`: - - 定义 `Task` 结构体:`ID int`、`Title string`、`Done bool`、`CreatedAt time.Time`,并加上 JSON 标签。 - - 方法 `func (t *Task) MarkDone()`:把任务标记为完成。 - - 方法 `func (t Task) String() string`:返回类似 `[x] 1. 学习 Go` 的文字。 - - 定义接口 `type Store interface { Add(t Task) error; List() ([]Task, error) }`。 - - 实现一个 `MemoryStore` 结构体,用切片在内存里存任务,满足 `Store` 接口。 -- `day4/main.go`: - - 创建几个任务,存入 `MemoryStore`,标记其中一个完成,打印列表。 - - 把任务列表用 `json.MarshalIndent` 转成 JSON 字符串打印出来。 - -**运行方式**:`go run ./day4` - -**完成标准**:能说清楚什么时候用指针接收者,以及“接口隐式实现”带来的好处。 - ---- - -## 第 5 天:错误处理和文件读写 - -**学习内容** -- `error` 接口、`errors.New`、`fmt.Errorf` 搭配 `%w` 包装错误。 -- `errors.Is`、`errors.As` 判断错误类型。 -- 自定义错误类型。 -- `os.ReadFile`、`os.WriteFile` 读写文件。 -- `panic` 和 `recover`(了解即可,业务代码里尽量不用)。 - -**要建的文件** -- `day5/errors.go`: - - 定义 `var ErrNotFound = errors.New("task not found")`。 - - 定义自定义错误 `ValidationError` 结构体,包含字段名和原因,实现 `Error()` 方法。 -- `day5/storage.go`: - - `func SaveTasks(path string, tasks []Task) error`:把任务列表写成 JSON 文件。 - - `func LoadTasks(path string) ([]Task, error)`:从 JSON 文件读取;文件不存在时返回空列表而不是报错。 - - `func FindTask(tasks []Task, id int) (Task, error)`:找不到时返回包装后的 `ErrNotFound`。 -- `day5/main.go`: - - 保存任务到 `tasks.json`,再读回来打印。 - - 查找一个不存在的 ID,用 `errors.Is` 判断是不是 `ErrNotFound`。 - - 标题为空时返回 `ValidationError`,用 `errors.As` 取出字段名打印。 - -**运行方式**:`go run ./day5` - -**完成标准**:习惯每个可能出错的调用后面都写 `if err != nil`,并给错误加上上下文信息。 - ---- - -## 第 6 天:并发入门和单元测试 - -**学习内容** -- `goroutine`:用 `go` 关键字启动并发任务。 -- `channel`:在 goroutine 之间传数据,有缓冲和无缓冲的区别。 -- `sync.WaitGroup` 等待一组任务完成,`sync.Mutex` 保护共享数据。 -- 用 `go test` 写单元测试,表格驱动测试的写法。 - -**要建的文件** -- `day6/worker.go`: - - `func FetchAll(urls []string) map[string]int`:并发请求多个网址(用 `net/http`),返回每个网址的 HTTP 状态码。用 `WaitGroup` 等待全部完成,用 `Mutex` 保护结果 map。 - - `func Pipeline(nums []int) <-chan int`:用 channel 写一个“生成数字,然后平方”的流水线。 -- `day6/main.go`:调用 `FetchAll` 请求 3 到 5 个网站,打印结果和总耗时,对比顺序请求的耗时。 -- `day6/calc_test.go`:把第 2 天的 `Add` 和 `Divide` 复制到 `day6` 里,写表格驱动测试,覆盖正常情况和除数为 0 的情况。 - -**运行方式**:`go run ./day6` 和 `go test ./day6 -v` - -**完成标准**:能解释 goroutine 和 JS 的 `Promise`/`async` 有什么不同。 - ---- - -## 第 7 天:周末小项目 · 命令行任务清单 - -把这周学的东西串起来,做一个能实际使用的命令行工具。 - -**功能** -- `go run . add "学习 Gin"`:添加任务。 -- `go run . list`:列出所有任务。 -- `go run . done 1`:把 1 号任务标记为完成。 -- `go run . delete 1`:删除 1 号任务。 -- 数据保存在 `tasks.json`,程序重启后还在。 - -**要建的文件** -- `todo/go.mod`:在 `todo` 目录运行 `go mod init todo`。 -- `todo/internal/task/task.go`:`Task` 结构体和它的方法(来自第 4 天),加上 `Validate()` 方法校验标题不能为空。 -- `todo/internal/task/task_test.go`:测试 `MarkDone` 和 `Validate`。 -- `todo/internal/store/file_store.go`:`FileStore` 结构体,实现 `Add`、`List`、`Done`、`Delete`,内部用 JSON 文件持久化(来自第 5 天),找不到任务时返回 `ErrNotFound`。 -- `todo/main.go`:用 `os.Args` 解析命令,调用 `FileStore`,出错时打印友好的提示并以非 0 状态码退出。 - -**运行方式**:在 `todo` 目录下运行 `go run . list`,测试用 `go test ./...` - -**完成标准** -- 四个命令都能正常使用,重启后数据不丢。 -- `go test ./...` 全部通过。 -- `go vet ./...` 没有警告,代码用 `gofmt` 格式化过。 -- 给 `todo` 写一个简单的 README,提交并推送到 `zhuangang` 仓库(路径 `go-learning/todo/`),这是你的第一个 Go 作品。以后整理作品集时可以再单独同步到 GitHub。 - ---- - -## 本周自查清单 - -- [ ] 能用 `:=` 和 `var` 声明变量,知道区别 -- [ ] 会写多返回值函数,并正确处理 `error` -- [ ] 理解切片和 map 的常见用法与坑 -- [ ] 会定义结构体、方法和接口 -- [ ] 会读写 JSON 文件 -- [ ] 会用 goroutine、channel、WaitGroup 写简单并发 -- [ ] 会写表格驱动的单元测试 -- [ ] 完成 `todo` 项目并推送到 `zhuangang` 仓库 -- [ ] 在总纲 `README.md` 的进度记录里勾掉第 1 周 - -下周预告:第 2 周用 Gin 把这个任务清单改造成 HTTP 接口,前端用你熟悉的 React 或 Vue 接上去。 diff --git a/plan/week01-go-gin.md b/plan/week01-go-gin.md new file mode 100644 index 0000000..6a1a9a6 --- /dev/null +++ b/plan/week01-go-gin.md @@ -0,0 +1,1229 @@ +# 第 1 周:Go 基础 + Gin 写 REST 接口 + +> 第一段“Go 全栈基础”(第 1~3 周)的第 1 周。总纲见 `README.md`,下周 `week02-db-auth.md`。 +> Go 在本计划里是**加分项**,只学到“能独立写出一个带数据库、鉴权、缓存、部署的后端”为止,不追求深挖。 + +**本周目标**:掌握 Go 的核心语法,用 Gin 写出“任务清单”的增删改查接口(数据先放内存),并用你熟悉的前端接上。 + +**每天投入**:1.5~2 小时。代码可以让 AI 生成,但每一行你都要能讲清楚。 + +**参考资料** +- Go 官方教程(中文):https://tour.go-zh.org/ +- Go by Example(中文):https://gobyexample-cn.github.io/ +- Gin 文档(中文):https://gin-gonic.com/zh-cn/docs/ + +## 本周最终目录 + +```text +go-learning/ +├── go.mod +├── day1/main.go +├── day2/main.go +├── day3/ +│ ├── main.go +│ └── calc_test.go +todo-api/ +├── backend/ +│ ├── go.mod +│ ├── main.go # 入口:读配置、组装依赖、优雅关机 +│ ├── api.http # 手动测试接口 +│ └── internal/ +│ ├── model/task.go # Task 结构体和校验 +│ ├── store/store.go # TaskStore 接口 + ErrNotFound +│ ├── store/memory_store.go # 内存实现(并发安全) +│ ├── store/memory_store_test.go +│ ├── handler/response.go # 统一响应格式 +│ ├── handler/task_handler.go +│ ├── handler/task_handler_test.go +│ ├── middleware/logger.go +│ ├── middleware/cors.go +│ └── router/router.go +└── frontend/ # Vite + Vue 或 React(第 7 周还会系统查漏) +``` + +--- + +## 第 1 天:环境、变量、函数和错误返回 + +### 学习内容 + +**1. 安装和模块** + +- 说明:Go 用“模块(module)”管理依赖,`go.mod` 相当于前端的 `package.json`。每个目录是一个包(package),能运行的程序必须是 `package main` 并有 `main()` 函数。 +- 案例:新建练习目录,初始化模块。 + +```bash +# Ubuntu:建议从 https://go.dev/dl/ 下载最新版,apt 源里的版本可能偏旧 +go version +mkdir -p go-learning/day1 && cd go-learning +go mod init go-learning +``` + +**2. 变量、常量、基本类型、格式化输出** + +- 说明:`var` 显式声明,`:=` 在函数内声明并推断类型;声明了却没使用的变量会**编译报错**(Go 不允许“死代码”)。类型之间不会自动转换,`int` 和 `float64` 相加要手动转。 +- 案例:打印一段自我介绍,并计算平均工作时长。 + +```go +package main + +import "fmt" + +const city = "上海" + +func main() { + var name string = "王士奎" + years := 7 + stack := []string{"Vue", "React", "TypeScript"} + + totalHours := 7 * 2000 + avg := float64(totalHours) / float64(years) // 必须显式转换 + + fmt.Printf("我是%s,在%s,前端 %d 年,技术栈 %v\n", name, city, years, stack) + fmt.Printf("平均每年 %.1f 小时\n", avg) +} +``` + +- 常见坑:`:=` 只能在函数里用;在 `if` 块里用 `:=` 会声明一个新变量,遮住外面的同名变量。 + +**3. 函数、多返回值和 error** + +- 说明:Go 没有 `try/catch`,可能失败的函数把 `error` 作为最后一个返回值,调用方立刻检查。这让“哪里会出错”一眼可见。 +- 案例:除法函数,除数为 0 时返回错误。 + +```go +package main + +import ( + "errors" + "fmt" +) + +func divide(a, b float64) (float64, error) { + if b == 0 { + return 0, errors.New("除数不能为 0") + } + return a / b, nil +} + +func main() { + for _, b := range []float64{2, 0} { + v, err := divide(10, b) + if err != nil { + fmt.Println("出错:", err) + continue + } + fmt.Println("结果:", v) + } +} +``` + +- 面试常问:“Go 为什么不用异常?”——错误是普通值,必须显式处理,控制流清楚;真正不可恢复的情况才用 `panic`。 + +**4. defer 和闭包** + +- 说明:`defer` 把调用推迟到函数返回前执行,多个 `defer` 后进先出,常用来关闭文件、释放锁。闭包和 JS 一样能捕获外部变量。 +- 案例:计时器。 + +```go +package main + +import ( + "fmt" + "time" +) + +func timer(name string) func() { + start := time.Now() + return func() { fmt.Printf("%s 用时 %v\n", name, time.Since(start)) } +} + +func work() { + defer timer("work")() // 注意最后的 ():立刻拿到返回的函数,推迟执行它 + time.Sleep(50 * time.Millisecond) +} + +func main() { + defer fmt.Println("第 3 个打印") + defer fmt.Println("第 2 个打印") + work() +} +``` + +### 要建的文件 + +- `go-learning/go.mod` +- `go-learning/day1/main.go`:合并上面 2~4 的示例,再加一个 FizzBuzz(1 到 30)。 + +### 运行方式 + +```bash +cd go-learning && go run ./day1 +``` + +### 完成标准 + +- 能说清 `var a int = 1` 和 `a := 1` 的区别,以及为什么没用的变量会编译报错。 +- 能说清 Go 的错误处理和 JS `try/catch` 的差别。 + +--- + +## 第 2 天:切片、map、结构体、方法、接口 + +### 学习内容 + +**1. 切片和 map** + +- 说明:切片(slice)是对底层数组的“视图”,包含指针、长度、容量;`append` 超过容量时会分配新数组。map 是哈希表,读不存在的 key 返回零值,用 `v, ok := m[k]` 判断是否存在。 +- 案例:统计单词出现次数、演示子切片共享底层数组。 + +```go +package main + +import ( + "fmt" + "strings" +) + +func wordCount(text string) map[string]int { + counts := make(map[string]int) + for _, w := range strings.Fields(strings.ToLower(text)) { + counts[w]++ + } + return counts +} + +func main() { + fmt.Println(wordCount("Go is fun and go is fast")) + + nums := []int{1, 2, 3, 4, 5} + sub := nums[1:3] // [2 3],和 nums 共用底层数组 + sub[0] = 99 + fmt.Println(nums) // [1 99 3 4 5] + + safe := append([]int(nil), nums[1:3]...) // 复制一份,互不影响 + safe[0] = 0 + fmt.Println(nums, safe) + + if _, ok := wordCount("a b")["c"]; !ok { + fmt.Println("c 不存在") + } +} +``` + +- 面试常问:切片的底层结构;`append` 什么时候扩容;为什么函数里 `append` 后要把结果返回。 + +**2. 结构体和方法** + +- 说明:结构体类似 TS 的 `interface` + 数据;方法是带“接收者”的函数。要修改结构体自身用**指针接收者** `(t *Task)`,只读可以用值接收者。结构体标签 `json:"title"` 控制 JSON 字段名。 +- 案例:任务结构体,标记完成并转成 JSON。 + +```go +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +type Task struct { + ID int `json:"id"` + Title string `json:"title"` + Done bool `json:"done"` + CreatedAt time.Time `json:"createdAt"` +} + +func (t *Task) MarkDone() { t.Done = true } + +func (t Task) String() string { + mark := " " + if t.Done { + mark = "x" + } + return fmt.Sprintf("[%s] %d. %s", mark, t.ID, t.Title) +} + +func main() { + t := Task{ID: 1, Title: "学习 Go", CreatedAt: time.Now()} + t.MarkDone() + fmt.Println(t) + b, _ := json.MarshalIndent(t, "", " ") + fmt.Println(string(b)) +} +``` + +**3. 接口(隐式实现)** + +- 说明:Go 的接口不需要写 `implements`,只要方法集对得上就算实现。好处:存储层可以先用内存实现,以后换数据库实现,调用方代码不动。 +- 案例:`Store` 接口和内存实现。 + +```go +package main + +import "fmt" + +type Store interface { + Add(title string) int + List() []string +} + +type MemoryStore struct{ items []string } + +func (m *MemoryStore) Add(title string) int { + m.items = append(m.items, title) + return len(m.items) +} + +func (m *MemoryStore) List() []string { return m.items } + +func printAll(s Store) { + for i, it := range s.List() { + fmt.Println(i+1, it) + } +} + +func main() { + var s Store = &MemoryStore{} // *MemoryStore 实现了 Store + s.Add("学 Gin") + s.Add("学 PostgreSQL") + printAll(s) +} +``` + +- 常见坑:方法是指针接收者时,只有指针类型 `*MemoryStore` 实现接口,写成 `MemoryStore{}` 会编译报错。 + +### 要建的文件 + +- `go-learning/day2/main.go`:合并以上示例,再写一个 `Filter(nums []int, fn func(int) bool) []int`(类似 JS 的 `filter`)。 + +### 运行方式 + +```bash +go run ./day2 +``` + +### 完成标准 + +- 能解释切片共享底层数组的现象,以及什么时候用指针接收者。 + +--- + +## 第 3 天:错误包装、并发、单元测试 + +### 学习内容 + +**1. 错误包装和判断** + +- 说明:用 `fmt.Errorf("...: %w", err)` 给错误加上下文并保留原错误;`errors.Is` 判断是不是某个错误,`errors.As` 取出特定类型的错误。 +- 案例:查找任务,找不到时返回包装后的 `ErrNotFound`,上层据此返回 404。 + +```go +package main + +import ( + "errors" + "fmt" +) + +var ErrNotFound = errors.New("not found") + +type ValidationError struct{ Field, Reason string } + +func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason } + +func findTask(id int) error { + if id <= 0 { + return &ValidationError{Field: "id", Reason: "必须大于 0"} + } + return fmt.Errorf("查找任务 %d: %w", id, ErrNotFound) +} + +func main() { + err := findTask(42) + fmt.Println(err, errors.Is(err, ErrNotFound)) // true + + var ve *ValidationError + if errors.As(findTask(-1), &ve) { + fmt.Println("参数错误,字段:", ve.Field) + } +} +``` + +**2. goroutine、channel、WaitGroup、Mutex** + +- 说明:`go f()` 启动一个轻量级协程;`channel` 在协程之间传数据;`sync.WaitGroup` 等一组协程结束;`sync.Mutex` 保护共享数据。和 JS 的区别:JS 是单线程事件循环,Go 的协程会**真正并行**,共享数据必须加锁。 +- 案例:并发“抓取”多个网址(用 sleep 模拟),汇总结果。 + +```go +package main + +import ( + "fmt" + "sync" + "time" +) + +func fetch(url string) int { + time.Sleep(100 * time.Millisecond) // 模拟网络请求 + return len(url) +} + +func main() { + urls := []string{"a.com", "bb.com", "ccc.com"} + results := make(map[string]int) + var mu sync.Mutex + var wg sync.WaitGroup + + start := time.Now() + for _, u := range urls { + wg.Add(1) + go func(u string) { + defer wg.Done() + n := fetch(u) + mu.Lock() + results[u] = n + mu.Unlock() + }(u) + } + wg.Wait() + fmt.Println(results, "耗时约", time.Since(start).Round(10*time.Millisecond)) + + // channel 版:生产者 → 消费者 + ch := make(chan int) + go func() { + defer close(ch) + for i := 1; i <= 3; i++ { + ch <- i * i + } + }() + for v := range ch { + fmt.Println("收到", v) + } +} +``` + +- 面试常问:goroutine 和线程的区别;无缓冲和有缓冲 channel 的区别;什么是数据竞争,怎么用 `go run -race` 发现。 + +**3. 表格驱动测试** + +- 说明:`go test` 运行 `_test.go` 文件里的 `TestXxx` 函数。“表格驱动”把多组输入和期望写成切片循环测试,是 Go 社区的惯例。 +- 案例:测试除法函数。 + +```go +// 文件:day3/calc.go +package main + +import "errors" + +func Divide(a, b float64) (float64, error) { + if b == 0 { + return 0, errors.New("division by zero") + } + return a / b, nil +} +``` + +```go +// 文件:day3/calc_test.go +package main + +import "testing" + +func TestDivide(t *testing.T) { + cases := []struct { + name string + a, b float64 + want float64 + wantErr bool + }{ + {"正常", 10, 2, 5, false}, + {"除数为 0", 1, 0, 0, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := Divide(c.a, c.b) + if (err != nil) != c.wantErr { + t.Fatalf("err = %v, wantErr %v", err, c.wantErr) + } + if got != c.want { + t.Errorf("got %v, want %v", got, c.want) + } + }) + } +} +``` + +### 要建的文件 + +- `go-learning/day3/main.go`:错误包装和并发示例。 +- `go-learning/day3/calc.go`、`calc_test.go`。 + +### 运行方式 + +```bash +go run -race ./day3 +go test ./day3 -v +``` + +### 完成标准 + +- `go test` 通过,`-race` 没有警告;能说出 goroutine 和 JS `Promise` 的区别。 + +--- + +## 第 4 天:从 net/http 到 Gin + +### 学习内容 + +**1. 标准库 HTTP 服务** + +- 说明:Go 标准库就能写 HTTP 服务。理解 `Handler`、`ResponseWriter`、`Request` 之后,再看框架就知道它省了什么。 +- 案例:最小服务器。 + +```go +package main + +import ( + "encoding/json" + "net/http" +) + +func main() { + http.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + http.ListenAndServe(":8080", nil) +} +``` + +(Go 1.22 起,路由模式支持写方法和路径参数,如 `"GET /api/tasks/{id}"`。) + +**2. Gin 路由、参数、请求体绑定** + +- 说明:Gin 提供路由分组、参数解析、请求体绑定和校验(`binding:"required"`)、中间件。招聘里 Go 框架没有统一要求,Gin 是国内用得多、概念少的一个。 +- 案例:任务接口的骨架。 + +```bash +cd todo-api/backend && go mod init todo-api && go get github.com/gin-gonic/gin +``` + +```go +package main + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type CreateTaskRequest struct { + Title string `json:"title" binding:"required,max=100"` +} + +func main() { + r := gin.Default() + api := r.Group("/api") + + api.GET("/tasks/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "id 必须是数字"}) + return + } + done := c.Query("done") // 查询参数 ?done=true + c.JSON(http.StatusOK, gin.H{"id": id, "doneFilter": done}) + }) + + api.POST("/tasks", func(c *gin.Context) { + var req CreateTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"title": req.Title}) + }) + + r.Run(":8080") +} +``` + +- 常见坑:处理完错误后忘了 `return`,会继续往下执行并写第二次响应。 + +**3. REST 设计和状态码** + +- 说明:资源用名词复数(`/tasks`),动作用 HTTP 方法;常用状态码:200 成功、201 已创建、204 无内容、400 参数错、401 未登录、403 无权限、404 不存在、409 冲突、429 请求过多、500 服务器错误。 +- 案例:本周要实现的接口: + +| 方法 | 路径 | 说明 | 成功状态码 | +| --- | --- | --- | --- | +| GET | `/api/health` | 健康检查 | 200 | +| GET | `/api/tasks?done=true` | 列表,可过滤 | 200 | +| GET | `/api/tasks/:id` | 详情 | 200 | +| POST | `/api/tasks` | 新增 | 201 | +| PATCH | `/api/tasks/:id` | 修改标题或完成状态 | 200 | +| DELETE | `/api/tasks/:id` | 删除 | 204 | + +**易错点 / 面试常问(补充)** + +- 易错点:Gin 的 `c.JSON` 之后要 `return`,否则后面的代码还会继续执行并可能再次写响应(日志里会出现 `headers were already written`)。 +- 易错点:`ShouldBindJSON` 校验失败要自己返回 400;`BindJSON` 会自动写 400,但之后不能再改状态码,所以一般统一用 `ShouldBind*`。 +- 易错点:路由参数 `c.Param("id")` 是字符串,要用 `strconv.Atoi` 转换并处理错误。 +- 面试常问:`net/http` 和 Gin 的关系(Gin 基于标准库的 `http.Handler`,增加了路由树、中间件链、参数绑定);RESTful 的资源命名和状态码怎么选(创建 201、删除 204、参数错 400、未登录 401、无权限 403、不存在 404、冲突 409)。 + +### 要建的文件 + +- `todo-api/backend/main.go`:先写上面的骨架跑通。 +- `todo-api/backend/api.http`:每个接口一条请求(VS Code / Cursor 装 REST Client 插件即可点击发送)。 + +```http +### 健康检查 +GET http://localhost:8080/api/health + +### 新建任务 +POST http://localhost:8080/api/tasks +Content-Type: application/json + +{"title": "学习 Gin"} +``` + +### 运行方式 + +```bash +go run . +curl -s localhost:8080/api/tasks/1?done=true +``` + +### 完成标准 + +- 能说清一个请求从进入 Gin 到返回经过了哪些步骤。 + +--- + +## 第 5 天:分层:model / store / handler + +### 学习内容 + +**1. 为什么分层** + +- 说明:handler 只管“解析请求、返回响应”;store 只管“数据存哪”;model 放数据结构和校验。下周把内存存储换成数据库时,只需要新写一个 store 实现。 +- 案例:`TaskStore` 接口 + 并发安全的内存实现。 + +```go +// 文件:internal/model/task.go +package model + +import ( + "errors" + "time" + "unicode/utf8" +) + +type Task struct { + ID int64 `json:"id"` + Title string `json:"title"` + Done bool `json:"done"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (t Task) Validate() error { + n := utf8.RuneCountInString(t.Title) + if n == 0 || n > 100 { + return errors.New("标题长度必须在 1~100 之间") + } + return nil +} +``` + +```go +// 文件:internal/store/store.go +package store + +import ( + "context" + "errors" + + "todo-api/internal/model" +) + +var ErrNotFound = errors.New("task not found") + +// 每个方法第一个参数都是 context:请求被取消或超时时,下游(比如数据库查询)能及时停下。 +// 内存实现用不到它,但下周换成数据库时就很重要,所以现在就把接口定好。 +type TaskStore interface { + List(ctx context.Context, done *bool) ([]model.Task, error) + Get(ctx context.Context, id int64) (model.Task, error) + Create(ctx context.Context, t model.Task) (model.Task, error) + Update(ctx context.Context, t model.Task) (model.Task, error) + Delete(ctx context.Context, id int64) error +} +``` + +```go +// 文件:internal/store/memory_store.go +package store + +import ( + "context" + "sort" + "sync" + "time" + + "todo-api/internal/model" +) + +type MemoryStore struct { + mu sync.RWMutex + nextID int64 + tasks map[int64]model.Task +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{nextID: 1, tasks: map[int64]model.Task{}} +} + +func (s *MemoryStore) List(_ context.Context, done *bool) ([]model.Task, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]model.Task, 0, len(s.tasks)) + for _, t := range s.tasks { + if done == nil || t.Done == *done { + out = append(out, t) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (s *MemoryStore) Get(_ context.Context, id int64) (model.Task, error) { + s.mu.RLock() + defer s.mu.RUnlock() + t, ok := s.tasks[id] + if !ok { + return model.Task{}, ErrNotFound + } + return t, nil +} + +func (s *MemoryStore) Create(_ context.Context, t model.Task) (model.Task, error) { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + t.ID, t.CreatedAt, t.UpdatedAt = s.nextID, now, now + s.nextID++ + s.tasks[t.ID] = t + return t, nil +} + +func (s *MemoryStore) Update(_ context.Context, t model.Task) (model.Task, error) { + s.mu.Lock() + defer s.mu.Unlock() + old, ok := s.tasks[t.ID] + if !ok { + return model.Task{}, ErrNotFound + } + t.CreatedAt, t.UpdatedAt = old.CreatedAt, time.Now() + s.tasks[t.ID] = t + return t, nil +} + +func (s *MemoryStore) Delete(_ context.Context, id int64) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.tasks[id]; !ok { + return ErrNotFound + } + delete(s.tasks, id) + return nil +} +``` + +- 面试常问:`Mutex` 和 `RWMutex` 的区别(读多写少用读写锁);`context` 是干什么的(传递取消信号、超时和请求范围的值)。 + +**2. 统一响应格式和 handler** + +- 说明:前后端约定统一结构 `{code, message, data}`,前端只需一处处理错误。`PATCH` 请求体里的字段用指针,才能区分“没传”和“传了 false/空串”。 +- 案例:任务 handler 和路由(路由里用到的中间件第 6 天写;第 5 天可以先把 `r.Use(...)` 那行注释掉)。 + +```go +// 文件:internal/handler/response.go +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type Resp struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` +} + +func OK(c *gin.Context, data any) { c.JSON(http.StatusOK, Resp{0, "ok", data}) } +func Created(c *gin.Context, data any) { c.JSON(http.StatusCreated, Resp{0, "ok", data}) } +func Fail(c *gin.Context, status, code int, msg string) { + c.AbortWithStatusJSON(status, Resp{code, msg, nil}) +} +``` + +```go +// 文件:internal/handler/task_handler.go +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "todo-api/internal/model" + "todo-api/internal/store" +) + +type TaskHandler struct{ Store store.TaskStore } + +type updateReq struct { + Title *string `json:"title"` + Done *bool `json:"done"` +} + +func (h *TaskHandler) Create(c *gin.Context) { + var req struct { + Title string `json:"title"` + } + if err := c.ShouldBindJSON(&req); err != nil { + Fail(c, http.StatusBadRequest, 40001, "请求体格式错误") + return + } + t := model.Task{Title: req.Title} + if err := t.Validate(); err != nil { + Fail(c, http.StatusBadRequest, 40002, err.Error()) + return + } + created, err := h.Store.Create(c.Request.Context(), t) + if err != nil { + Fail(c, http.StatusInternalServerError, 50000, "保存失败") + return + } + Created(c, created) +} + +func (h *TaskHandler) Update(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + Fail(c, http.StatusBadRequest, 40003, "id 不合法") + return + } + var req updateReq + if err := c.ShouldBindJSON(&req); err != nil { + Fail(c, http.StatusBadRequest, 40001, "请求体格式错误") + return + } + t, err := h.Store.Get(c.Request.Context(), id) + if errors.Is(err, store.ErrNotFound) { + Fail(c, http.StatusNotFound, 40401, "任务不存在") + return + } + if req.Title != nil { + t.Title = *req.Title + } + if req.Done != nil { + t.Done = *req.Done + } + if err := t.Validate(); err != nil { + Fail(c, http.StatusBadRequest, 40002, err.Error()) + return + } + updated, err := h.Store.Update(c.Request.Context(), t) + if err != nil { + Fail(c, http.StatusInternalServerError, 50000, "保存失败") + return + } + OK(c, updated) +} + +func (h *TaskHandler) List(c *gin.Context) { + var done *bool + if v := c.Query("done"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + Fail(c, http.StatusBadRequest, 40004, "done 只能是 true 或 false") + return + } + done = &b + } + tasks, err := h.Store.List(c.Request.Context(), done) + if err != nil { + Fail(c, http.StatusInternalServerError, 50000, "查询失败") + return + } + OK(c, tasks) +} + +func (h *TaskHandler) Get(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + Fail(c, http.StatusBadRequest, 40003, "id 不合法") + return + } + t, err := h.Store.Get(c.Request.Context(), id) + if errors.Is(err, store.ErrNotFound) { + Fail(c, http.StatusNotFound, 40401, "任务不存在") + return + } + OK(c, t) +} + +func (h *TaskHandler) Delete(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + Fail(c, http.StatusBadRequest, 40003, "id 不合法") + return + } + if err := h.Store.Delete(c.Request.Context(), id); errors.Is(err, store.ErrNotFound) { + Fail(c, http.StatusNotFound, 40401, "任务不存在") + return + } + c.Status(http.StatusNoContent) +} +``` + +```go +// 文件:internal/router/router.go +package router + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "todo-api/internal/handler" + "todo-api/internal/middleware" +) + +func New(h *handler.TaskHandler) *gin.Engine { + r := gin.New() + r.Use(gin.Recovery(), middleware.Logger(), middleware.CORS("http://localhost:5173")) + api := r.Group("/api") + api.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + api.GET("/tasks", h.List) + api.GET("/tasks/:id", h.Get) + api.POST("/tasks", h.Create) + api.PATCH("/tasks/:id", h.Update) + api.DELETE("/tasks/:id", h.Delete) + return r +} +``` + +### 要建的文件 + +- `internal/model/task.go`、`internal/store/store.go`、`internal/store/memory_store.go` +- `internal/store/memory_store_test.go`: + +```go +// 文件:internal/store/memory_store_test.go +package store + +import ( + "context" + "sync" + "testing" + + "todo-api/internal/model" +) + +func TestConcurrentCreate(t *testing.T) { + s := NewMemoryStore() + ctx := context.Background() + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s.Create(ctx, model.Task{Title: "x"}) + }() + } + wg.Wait() + all, _ := s.List(ctx, nil) + if len(all) != 100 { + t.Fatalf("got %d tasks, want 100", len(all)) + } +} +``` +- `internal/handler/response.go`、`internal/handler/task_handler.go`、`internal/router/router.go`:先自己写,再对照示例。 + +### 运行方式 + +```bash +go test ./... -race +go run . +``` + +### 完成标准 + +- `api.http` 里每个接口的正常和出错情况(空标题、不存在的 id)都测过,状态码正确。 + +--- + +## 第 6 天:中间件和接口测试 + +### 学习内容 + +**1. 中间件** + +- 说明:中间件是包在 handler 外面的一层函数,`c.Next()` 之前的代码在请求进入时执行,之后的代码在响应返回时执行。日志、跨域、鉴权、限流、捕获 panic 都用它。 +- 案例:请求日志 + 跨域。 + +```go +// 文件:internal/middleware/logger.go +package middleware + +import ( + "log/slog" + "time" + + "github.com/gin-gonic/gin" +) + +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + slog.Info("request", + "method", c.Request.Method, + "path", c.FullPath(), + "status", c.Writer.Status(), + "cost", time.Since(start)) + } +} +``` + +```go +// 文件:internal/middleware/cors.go +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func CORS(allowOrigin string) gin.HandlerFunc { + return func(c *gin.Context) { + h := c.Writer.Header() + h.Set("Access-Control-Allow-Origin", allowOrigin) + h.Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS") + h.Set("Access-Control-Allow-Headers", "Content-Type,Authorization") + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) // 预检请求直接返回 + return + } + c.Next() + } +} +``` + +- 面试常问:跨域为什么会发生(浏览器同源策略)、什么是预检请求。 + +**2. 用 httptest 测接口** + +- 说明:`httptest.NewRecorder` 不用真正启动端口就能测试路由,速度快,可以放进 CI。 +- 案例:创建任务返回 201,空标题返回 400。 + +```go +// 文件:internal/handler/task_handler_test.go +package handler_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "todo-api/internal/handler" + "todo-api/internal/router" + "todo-api/internal/store" +) + +func setup() *gin.Engine { + gin.SetMode(gin.TestMode) + return router.New(&handler.TaskHandler{Store: store.NewMemoryStore()}) +} + +func TestCreateTask(t *testing.T) { + r := setup() + cases := []struct { + body string + want int + }{ + {`{"title":"学 Gin"}`, http.StatusCreated}, + {`{"title":""}`, http.StatusBadRequest}, + {`not json`, http.StatusBadRequest}, + } + for _, c := range cases { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/tasks", strings.NewReader(c.body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != c.want { + t.Errorf("body=%s got %d want %d", c.body, w.Code, c.want) + } + } +} +``` + +### 要建的文件 + +- `internal/middleware/logger.go`、`cors.go`;把 `router.go` 里 `r.Use(...)` 那行打开。 +- `internal/handler/task_handler_test.go`:再补“获取不存在返回 404”“删除后再获取 404”“按 done 过滤”。 + +### 运行方式 + +```bash +go test ./... -v -race +``` + +### 完成标准 + +- 测试全部通过;故意在某个接口里 `panic`,服务不挂,返回 500。 + +--- + +## 第 7 天:配置、优雅关机、接前端 + +### 学习内容 + +**1. 环境变量配置和优雅关机** + +- 说明:配置从环境变量读,不写死在代码里(部署时用 Docker 注入)。优雅关机:收到 `Ctrl+C` 或 `SIGTERM` 后不再接新请求,等处理中的请求完成再退出,避免部署时请求被掐断。 +- 案例:`main.go` 完整版。 + +```go +// 文件:main.go +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "todo-api/internal/handler" + "todo-api/internal/router" + "todo-api/internal/store" +) + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func main() { + h := &handler.TaskHandler{Store: store.NewMemoryStore()} + srv := &http.Server{Addr: ":" + getenv("PORT", "8080"), Handler: router.New(h)} + + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("listen", "err", err) + os.Exit(1) + } + }() + slog.Info("started", "addr", srv.Addr) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + <-ctx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("shutdown", "err", err) + } + slog.Info("bye") +} +``` + +**2. 前端接入(快速版,第 7 周系统查漏)** + +- 说明:前后端分离,前端用环境变量配置接口地址;统一封装请求,`code != 0` 时抛错。 +- 案例:TypeScript 请求封装。 + +```ts +// 文件:frontend/src/api/tasks.ts +export interface Task { id: number; title: string; done: boolean; createdAt: string; updatedAt: string } +interface Resp { code: number; message: string; data: T } + +const BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8080/api' + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(BASE + path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) + if (res.status === 204) return undefined as T + const json = (await res.json()) as Resp + if (json.code !== 0) throw new Error(json.message) + return json.data +} + +export const listTasks = (done?: boolean) => + request('GET', '/tasks' + (done === undefined ? '' : `?done=${done}`)) +export const createTask = (title: string) => request('POST', '/tasks', { title }) +export const updateTask = (id: number, patch: Partial>) => + request('PATCH', `/tasks/${id}`, patch) +export const deleteTask = (id: number) => request('DELETE', `/tasks/${id}`) +``` + +**易错点 / 面试常问(补充)** + +- 易错点:优雅关机要先停止接收新请求(`srv.Shutdown(ctx)`),再关数据库等资源;顺序反了会让正在处理的请求报错。`Shutdown` 要带超时 `context`,否则可能一直等。 +- 易错点:配置只从环境变量读,默认值只用于开发;不要把 `.env` 提交到仓库。 +- 易错点:前端开发时用 Vite 的 `server.proxy` 转发 `/api`,避免跨域;生产用 Nginx 同域转发。 +- 面试常问:`SIGTERM` 和 `SIGKILL` 的区别(前者可以被捕获做清理,后者不能,`kill -9` 就是 `SIGKILL`);容器停止时 Docker 先发 `SIGTERM`,超时后再 `SIGKILL`。 + +### 要建的文件 + +- 完成 `main.go`。 +- `npm create vite@latest frontend -- --template vue-ts`(或 `react-ts`),写 `src/api/tasks.ts` 和一个任务列表页面(新增、勾选、删除、过滤)。 +- `todo-api/README.md`:功能、接口表、运行步骤。 +- 提交并推送到 `zhuangang` 仓库。 + +### 运行方式 + +```bash +cd backend && go run . +cd frontend && npm install && npm run dev +``` + +### 完成标准 + +- 页面上能完成增删改查;`Ctrl+C` 关闭后端时日志里先打印 `bye` 再退出。 +- `go vet ./...` 无警告,`gofmt -l .` 无输出。 + +--- + +## 本周自查清单 + +- [ ] 会用 `:=`、切片、map、结构体、方法、接口 +- [ ] 会用 `%w` 包装错误,用 `errors.Is/As` 判断 +- [ ] 会用 goroutine、channel、WaitGroup、Mutex,会用 `-race` +- [ ] 会写表格驱动测试和 `httptest` 接口测试 +- [ ] 会用 Gin 写 REST 接口、统一响应、中间件 +- [ ] 理解为什么存储层要用接口隔离 +- [ ] 会读环境变量、做优雅关机 +- [ ] 在总纲进度记录里勾掉第 1 周 diff --git a/plan/week02-db-auth.md b/plan/week02-db-auth.md new file mode 100644 index 0000000..e347e9b --- /dev/null +++ b/plan/week02-db-auth.md @@ -0,0 +1,1273 @@ +# 第 2 周:数据库(PostgreSQL 为主,兼顾 MySQL)+ 注册登录与 JWT + +> 第一段“Go 全栈基础”的第 2 周。上周 `week01-go-gin.md`,下周 `week03-project1.md`。 + +**本周目标** +1. 把任务清单的存储从内存换成 PostgreSQL:会设计表、写常用 SQL、看懂执行计划、用迁移管理表结构、在 Go 里写事务。 +2. 能说清 MySQL 和 PostgreSQL 的主要差异(招聘里 MySQL 出现得更多),包括索引、`EXPLAIN`、事务隔离级别。 +3. 给服务加上注册、登录和 JWT 鉴权。 + +**为什么用 PostgreSQL 学、却要兼顾 MySQL**:两者的常用 SQL 大部分相同;PostgreSQL 的 pgvector 扩展在第 5~6 周 RAG 项目里可以当向量库用;而招聘写 MySQL 的更多,所以每个知识点都附上 MySQL 的写法和区别,面试时能讲清差异即可。 + +**参考资料** +- PostgreSQL 中文文档:http://www.postgres.cn/docs/current/ +- MySQL 8 参考手册:https://dev.mysql.com/doc/refman/8.0/en/ +- pgx:https://github.com/jackc/pgx +- golang-migrate:https://github.com/golang-migrate/migrate +- golang-jwt:https://github.com/golang-jwt/jwt + +## 本周最终目录(在上周 `todo-api/backend` 基础上新增) + +```text +todo-api/backend/ +├── docker-compose.dev.yml # postgres(和可选的 mysql) +├── Makefile +├── .env.example +├── db/migrations/ +│ ├── 000001_create_tasks.up.sql / .down.sql +│ ├── 000002_add_tasks_index.up.sql / .down.sql +│ └── 000003_users_and_tags.up.sql / .down.sql +├── sql/practice/ # 练习 SQL,不参与运行 +│ ├── day1_crud.sql +│ ├── day2_queries.sql +│ └── mysql_vs_pg.sql +└── internal/ + ├── config/config.go + ├── model/user.go + ├── store/pg_store.go # 任务的 PostgreSQL 实现 + ├── store/pg_store_test.go # 集成测试(没有数据库时自动跳过) + ├── store/user_store.go # 用户存储接口 + 内存实现 + ├── store/pg_user_store.go + ├── auth/password.go + ├── auth/jwt.go + ├── auth/jwt_test.go + ├── handler/auth_handler.go + ├── middleware/auth.go + └── router/router.go # 改为接收依赖集合 +``` + +--- + +## 第 1 天:用 Docker 跑 PostgreSQL,写基础 SQL 和表设计 + +### 学习内容 + +**1. 用 Docker 启动数据库** + +- 说明:本地开发不直接往系统装数据库,用 Docker 起一个容器,删掉重来也不怕;数据放在具名卷里,容器删了数据还在。 +- 案例:开发用的 Compose 文件。 + +```yaml +# 文件:docker-compose.dev.yml +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: todo + POSTGRES_PASSWORD: todo + POSTGRES_DB: todo + ports: ["5432:5432"] + volumes: [pgdata:/var/lib/postgresql/data] + mysql: # 只在做 MySQL 对比练习时启动:docker compose -f docker-compose.dev.yml up -d mysql + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: todo + ports: ["3306:3306"] +volumes: + pgdata: +``` + +```bash +# Ubuntu 没装 Docker 的话:sudo apt install docker.io docker-compose-v2,然后把自己加进 docker 组 +docker compose -f docker-compose.dev.yml up -d postgres +docker compose -f docker-compose.dev.yml exec postgres psql -U todo -d todo +# psql 常用:\dt 列出表 \d tasks 看表结构 \q 退出 +``` + +**2. 建表和约束** + +- 说明:主键、`NOT NULL`、`UNIQUE`、`CHECK`、外键这些约束让数据库帮你守住规则,比只在代码里校验更可靠(多个服务、手工改数据时也不会写进脏数据)。 +- 案例:用户、任务(一对多)、标签(多对多)。 + +```sql +-- 文件:sql/practice/day1_crud.sql +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE practice_tasks ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- 一对多 + title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 100), + done BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE tags (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE task_tags ( -- 多对多的关联表 + task_id BIGINT REFERENCES practice_tasks(id) ON DELETE CASCADE, + tag_id BIGINT REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (task_id, tag_id) +); + +INSERT INTO users (email, name) VALUES ('a@example.com', '小王'), ('b@example.com', '小李'); +INSERT INTO practice_tasks (user_id, title) VALUES (1, '学 SQL'), (1, '学 Gin'), (2, '写周报'); +UPDATE practice_tasks SET done = true WHERE id = 1; +SELECT id, title, done FROM practice_tasks WHERE user_id = 1 ORDER BY created_at DESC LIMIT 10; +DELETE FROM practice_tasks WHERE id = 3; +``` + +- 常见坑:`UPDATE` / `DELETE` 忘写 `WHERE` 会改掉整张表;生产环境操作前先用同样的条件 `SELECT` 一遍。 +- 面试常问:外键的 `ON DELETE CASCADE` 和 `RESTRICT` 各适合什么场景(订单明细跟着订单删 → CASCADE;有订单的用户不能删 → RESTRICT)。 + +**3. MySQL 写法对照** + +- 说明:同样的表在 MySQL 里要注意:自增用 `AUTO_INCREMENT`;字符集用 `utf8mb4`(否则存不了表情);存储引擎用 InnoDB(支持事务和外键,MySQL 8 默认就是)。 +- 案例: + +```sql +-- 文件:sql/practice/mysql_vs_pg.sql(在 MySQL 里执行) +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- PostgreSQL: INSERT ... RETURNING id; MySQL 没有 RETURNING: +INSERT INTO users (email, name) VALUES ('a@example.com', '小王'); +SELECT LAST_INSERT_ID(); + +-- “存在就更新”:PostgreSQL 用 ON CONFLICT (email) DO UPDATE,MySQL 用: +INSERT INTO users (email, name) VALUES ('a@example.com', '王') + ON DUPLICATE KEY UPDATE name = VALUES(name); +``` + +| 方面 | PostgreSQL | MySQL 8 | +| --- | --- | --- | +| 自增主键 | `BIGSERIAL` / `GENERATED AS IDENTITY` | `AUTO_INCREMENT` | +| 插入后取回 | `RETURNING` | `LAST_INSERT_ID()` | +| 存在就更新 | `ON CONFLICT ... DO UPDATE` | `ON DUPLICATE KEY UPDATE` | +| 时间 | `TIMESTAMPTZ`(带时区) | `DATETIME`(不带时区,一般统一存 UTC) | +| JSON | `JSONB`,可直接建索引 | `JSON`,索引要借助生成列 | +| 字符串比较 | 默认区分大小写 | 常用排序规则默认不区分大小写 | +| 默认隔离级别 | 读已提交 | 可重复读 | +| Go 驱动占位符 | `$1, $2` | `?` | + +### 要建的文件 + +- `docker-compose.dev.yml`、`sql/practice/day1_crud.sql`、`sql/practice/mysql_vs_pg.sql` + +### 运行方式 + +```bash +docker compose -f docker-compose.dev.yml exec -T postgres psql -U todo -d todo < sql/practice/day1_crud.sql +``` + +### 完成标准 + +- 不看资料能写出建表、增删改查;能说出 3 条以上 MySQL 和 PostgreSQL 的差异。 + +--- + +## 第 2 天:多表查询、索引、EXPLAIN + +### 学习内容 + +**1. JOIN、GROUP BY、HAVING** + +- 说明:`JOIN` 把多张表按关系连起来;`LEFT JOIN` 保留左表所有行(右边没有就是 NULL);`GROUP BY` 分组统计,`HAVING` 对分组结果过滤。 +- 案例:任务统计报表。 + +```sql +-- 文件:sql/practice/day2_queries.sql +-- 每个用户的任务数和完成数(没有任务的用户也要列出来) +SELECT u.name, + count(t.id) AS total, + count(t.id) FILTER (WHERE t.done) AS done -- MySQL 写法:SUM(t.done = 1) +FROM users u +LEFT JOIN practice_tasks t ON t.user_id = u.id +GROUP BY u.id, u.name +ORDER BY total DESC; + +-- 带某个标签的任务(经过关联表的两次 JOIN) +SELECT t.id, t.title +FROM practice_tasks t +JOIN task_tags tt ON tt.task_id = t.id +JOIN tags g ON g.id = tt.tag_id +WHERE g.name = '学习'; + +-- 任务数超过 2 的用户 +SELECT user_id, count(*) FROM practice_tasks GROUP BY user_id HAVING count(*) > 2; +``` + +**2. 索引和执行计划** + +- 说明:索引(B+ 树 / B 树)让查询不用扫全表,但会让写入变慢、占空间,所以只给常用的查询条件和排序字段建。组合索引遵循“最左前缀”:`(user_id, created_at)` 能用于 `WHERE user_id = ?` 和 `WHERE user_id = ? ORDER BY created_at`,不能单独用于 `WHERE created_at > ?`。 +- 案例:造 10 万条数据,对比加索引前后的执行计划。 + +```sql +-- 造数据(PostgreSQL 的 generate_series) +INSERT INTO practice_tasks (user_id, title, done) +SELECT 1 + (random() * 1)::int, '任务 ' || g, random() < 0.3 +FROM generate_series(1, 100000) g; +ANALYZE practice_tasks; + +EXPLAIN ANALYZE +SELECT id, title FROM practice_tasks WHERE user_id = 1 AND NOT done ORDER BY created_at DESC LIMIT 20; +-- 看到 Seq Scan(全表扫描)和较长的执行时间 + +CREATE INDEX idx_tasks_user_created ON practice_tasks (user_id, created_at DESC); + +EXPLAIN ANALYZE +SELECT id, title FROM practice_tasks WHERE user_id = 1 AND NOT done ORDER BY created_at DESC LIMIT 20; +-- 变成 Index Scan,时间明显下降(具体数字以你机器上为准,记到练习文件的注释里) +``` + +- MySQL 对照:`EXPLAIN SELECT ...` 重点看 `type`(`ALL` 是全表扫描,`ref`/`range` 用到了索引)、`key`(实际用的索引)、`rows`(估计扫描行数)、`Extra`(`Using filesort` 说明排序没走索引)。MySQL 8.0.18 起也支持 `EXPLAIN ANALYZE`。 +- 面试常问: + - 索引为什么用 B+ 树(矮、范围查询友好); + - 索引失效的常见情况:对字段做函数运算(`WHERE date(created_at) = ...`)、隐式类型转换、`LIKE '%xx'` 前缀模糊、违反最左前缀; + - MySQL InnoDB 的聚簇索引和二级索引“回表”,以及“覆盖索引”如何避免回表。 + +### 要建的文件 + +- `sql/practice/day2_queries.sql`:上面的查询 + 你自己的 `EXPLAIN` 结果注释。 +- `db/migrations/000002_add_tasks_index.up.sql`:给正式的 `tasks` 表加 `(done, created_at DESC)` 索引(第 3 天建迁移体系后再执行)。 + +### 运行方式 + +在 `psql` 里执行;MySQL 部分在 `docker compose ... exec mysql mysql -uroot -proot todo` 里执行。 + +### 完成标准 + +- 能读懂执行计划里最关键的两三行,能说出 3 种索引失效的情况。 + +--- + +## 第 3 天:迁移和 Go 访问数据库(pgx) + +### 学习内容 + +**1. 数据库迁移** + +- 说明:表结构变更也要像代码一样进 Git、按顺序执行。每次变更一对文件:`up`(执行)和 `down`(回滚)。上线顺序一般是“先跑迁移,再发新代码”,避免“代码升级了、表还是旧的”导致接口 500。 +- 案例: + +```sql +-- 文件:db/migrations/000001_create_tasks.up.sql +CREATE TABLE tasks ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 100), + done BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +```sql +-- 文件:db/migrations/000001_create_tasks.down.sql +DROP TABLE IF EXISTS tasks; +``` + +```makefile +# 文件:Makefile(缩进必须是 Tab) +DATABASE_URL ?= postgres://todo:todo@localhost:5432/todo?sslmode=disable + +db-up: + docker compose -f docker-compose.dev.yml up -d postgres +migrate-up: + migrate -path db/migrations -database "$(DATABASE_URL)" up +migrate-down: + migrate -path db/migrations -database "$(DATABASE_URL)" down 1 +migrate-new: + migrate create -ext sql -dir db/migrations -seq $(name) +``` + +```bash +go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest +make db-up && make migrate-up +make migrate-down && make migrate-up # 确认能来回执行 +``` + +**2. 用 pgx 实现 TaskStore** + +- 说明:pgx 是 Go 里常用的 PostgreSQL 驱动,`pgxpool` 是连接池。因为上周把存储层定义成了接口,这里只要新写一个实现。SQL 用 `$1` 占位符传参,**永远不要拼接字符串**(SQL 注入)。 +- 另一种常见做法是 sqlc(写 SQL 生成 Go 代码)或 GORM(ORM)。本计划用 pgx 直接写,SQL 看得最清楚;了解另外两种的取舍即可。 +- 案例: + +```go +// 文件:internal/store/pg_store.go +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "todo-api/internal/model" +) + +type PgStore struct{ pool *pgxpool.Pool } + +func NewPgStore(pool *pgxpool.Pool) *PgStore { return &PgStore{pool: pool} } + +const taskCols = "id, title, done, created_at, updated_at" + +func (s *PgStore) List(ctx context.Context, done *bool) ([]model.Task, error) { + rows, err := s.pool.Query(ctx, + `SELECT `+taskCols+` FROM tasks WHERE ($1::boolean IS NULL OR done = $1) ORDER BY id`, done) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, pgx.RowToStructByPos[model.Task]) // 按列顺序填充结构体 +} + +func (s *PgStore) Get(ctx context.Context, id int64) (model.Task, error) { + var t model.Task + err := s.pool.QueryRow(ctx, `SELECT `+taskCols+` FROM tasks WHERE id = $1`, id). + Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt, &t.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return t, ErrNotFound + } + return t, err +} + +func (s *PgStore) Create(ctx context.Context, t model.Task) (model.Task, error) { + err := s.pool.QueryRow(ctx, + `INSERT INTO tasks (title, done) VALUES ($1, $2) RETURNING `+taskCols, t.Title, t.Done). + Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt, &t.UpdatedAt) + return t, err +} + +func (s *PgStore) Update(ctx context.Context, t model.Task) (model.Task, error) { + err := s.pool.QueryRow(ctx, + `UPDATE tasks SET title = $2, done = $3, updated_at = now() WHERE id = $1 RETURNING `+taskCols, + t.ID, t.Title, t.Done). + Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt, &t.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return t, ErrNotFound + } + return t, err +} + +func (s *PgStore) Delete(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} +``` + +- 常见坑:`RowToStructByPos` 要求结构体字段顺序和 `SELECT` 的列顺序一致;用 MySQL 时驱动是 `github.com/go-sql-driver/mysql` + 标准库 `database/sql`,占位符是 `?`。 + +**易错点 / 面试常问(补充)** + +- 易错点:迁移文件一旦在别的环境执行过就不要再改,要改就新建一个迁移;`down` 文件要能真正回滚 `up` 的改动。 +- 易错点:pgx 查询后要处理 `rows.Err()`,并 `defer rows.Close()`;查单行没结果时返回的是 `pgx.ErrNoRows`,要转成业务的“不存在”。 +- 易错点:SQL 一律用参数占位符(PostgreSQL 是 `$1`,MySQL 是 `?`),不要拼接字符串,否则有 SQL 注入。 +- 面试常问:连接池为什么必要、参数怎么设(最大连接数受数据库 `max_connections` 限制);应用升级和数据库迁移谁先谁后(先做向后兼容的迁移,再发新代码)。 + +### 要建的文件 + +- `db/migrations/000001_*`、`000002_*`、`Makefile`、`internal/store/pg_store.go` +- `.env.example`:`DATABASE_URL=postgres://todo:todo@localhost:5432/todo?sslmode=disable` + +### 运行方式 + +```bash +go get github.com/jackc/pgx/v5 +go build ./... +``` + +### 完成标准 + +- 能说出“为什么先跑迁移再发代码”,能说出 SQL 注入是什么、怎么避免。 + +--- + +## 第 4 天:事务、隔离级别、幂等写入 + +### 学习内容 + +**1. 事务** + +- 说明:事务保证一组写操作“要么全成功,要么全失败”(原子性),并发时互不干扰(隔离性)。ACID:原子性、一致性、隔离性、持久性。 +- 案例:给任务打标签:插入标签(已存在就复用)+ 写关联表,必须在一个事务里。 + +```sql +-- 文件:db/migrations/000003_users_and_tags.up.sql +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE tags (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE); +CREATE TABLE task_tags ( + task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE, + tag_id BIGINT REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (task_id, tag_id) +); +``` + +```go +// 文件:internal/store/pg_tx.go +package store + +import ( + "context" + + "github.com/jackc/pgx/v5" +) + +// withTx 开启事务执行 fn:fn 返回错误就回滚,否则提交。 +func (s *PgStore) withTx(ctx context.Context, fn func(tx pgx.Tx) error) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) // 已经提交后再调用 Rollback 不会有任何影响 + if err := fn(tx); err != nil { + return err + } + return tx.Commit(ctx) +} + +// AddTags 给任务打标签;标签不存在就创建(幂等:重复调用结果一样)。 +func (s *PgStore) AddTags(ctx context.Context, taskID int64, names []string) error { + return s.withTx(ctx, func(tx pgx.Tx) error { + for _, name := range names { + var tagID int64 + err := tx.QueryRow(ctx, + `INSERT INTO tags (name) VALUES ($1) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id`, name).Scan(&tagID) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, + `INSERT INTO task_tags (task_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + taskID, tagID); err != nil { + return err + } + } + return nil + }) +} +``` + +- 常见坑:事务里用 `s.pool` 而不是 `tx` 执行语句,那条语句就不在事务里了。 +- 小实验:在循环里故意 `return errors.New("boom")`,确认标签和关联都没写进去。 + +**2. 隔离级别(面试高频)** + +- 说明:并发事务可能出现三类问题:**脏读**(读到别人没提交的数据)、**不可重复读**(同一事务里两次读同一行结果不同)、**幻读**(两次范围查询行数不同)。四个隔离级别从低到高:读未提交、读已提交、可重复读、串行化。PostgreSQL 默认读已提交,MySQL InnoDB 默认可重复读(通过 MVCC 多版本 + 间隙锁在很大程度上避免幻读)。 +- 案例:开两个 `psql` 窗口亲手观察“不可重复读”。 + +```sql +-- 窗口 A +BEGIN; -- 默认读已提交 +SELECT title FROM tasks WHERE id = 1; -- 看到旧标题 +-- 窗口 B +UPDATE tasks SET title = '被 B 改了' WHERE id = 1; -- 自动提交 +-- 窗口 A +SELECT title FROM tasks WHERE id = 1; -- 看到新标题:不可重复读 +COMMIT; + +-- 再来一次,A 改用可重复读 +BEGIN ISOLATION LEVEL REPEATABLE READ; +SELECT title FROM tasks WHERE id = 1; +-- B 再改一次后,A 再查:仍是事务开始时的值 +COMMIT; +``` + +- 面试常问:MVCC 是什么(每行保留多个版本,读不加锁);乐观锁(版本号字段 `UPDATE ... WHERE version = ?`)和悲观锁(`SELECT ... FOR UPDATE`)各适合什么场景。 + +### 要建的文件 + +- `db/migrations/000003_users_and_tags.up.sql` / `.down.sql`、`internal/store/pg_tx.go` + +### 运行方式 + +```bash +make migrate-up && go build ./... +``` + +### 完成标准 + +- 亲手复现过不可重复读;能讲清四个隔离级别和 MySQL、PostgreSQL 的默认值。 + +--- + +## 第 5 天:密码哈希和 JWT + +### 学习内容 + +**1. 密码只存哈希** + +- 说明:数据库泄露时,明文密码会直接暴露。bcrypt 是“故意很慢”且自带随机盐的哈希算法,暴力破解成本高;MD5、SHA256 太快,不适合存密码。 +- 案例: + +```go +// 文件:internal/auth/password.go +package auth + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(plain string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + return string(b), err +} + +func CheckPassword(hash, plain string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil +} +``` + +**2. JWT** + +- 说明:JWT 是“头部.载荷.签名”三段 Base64 字符串。服务端用密钥签名,之后只要验签就知道是谁、是否过期,不用查会话表。注意:载荷**只是编码不是加密**,不能放敏感信息;签发后在过期前很难作废,所以过期时间要短(配合刷新令牌,本周不实现)。 +- 案例:签发和解析。 + +```go +// 文件:internal/auth/jwt.go +package auth + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID int64 `json:"uid"` + jwt.RegisteredClaims +} + +type TokenManager struct { + secret []byte + ttl time.Duration +} + +func NewTokenManager(secret string, ttl time.Duration) *TokenManager { + return &TokenManager{secret: []byte(secret), ttl: ttl} +} + +func (m *TokenManager) Issue(userID int64) (string, error) { + now := time.Now() + claims := Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(m.ttl)), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret) +} + +func (m *TokenManager) Parse(token string) (int64, error) { + var c Claims + _, err := jwt.ParseWithClaims(token, &c, + func(*jwt.Token) (any, error) { return m.secret, nil }, + jwt.WithValidMethods([]string{"HS256"}), // 只接受 HS256,防止“算法替换”攻击 + ) + if err != nil { + return 0, err + } + return c.UserID, nil +} +``` + +```go +// 文件:internal/auth/jwt_test.go +package auth + +import ( + "testing" + "time" +) + +func TestToken(t *testing.T) { + m := NewTokenManager("a-very-long-secret-for-testing-only-123", time.Hour) + tok, _ := m.Issue(42) + if id, err := m.Parse(tok); err != nil || id != 42 { + t.Fatalf("parse: id=%d err=%v", id, err) + } + + expired := NewTokenManager("a-very-long-secret-for-testing-only-123", -time.Minute) + tok2, _ := expired.Issue(1) + if _, err := m.Parse(tok2); err == nil { + t.Fatal("过期令牌应该解析失败") + } + + other := NewTokenManager("another-secret-another-secret-12345678", time.Hour) + tok3, _ := other.Issue(1) + if _, err := m.Parse(tok3); err == nil { + t.Fatal("别的密钥签的令牌应该解析失败") + } +} + +func TestPassword(t *testing.T) { + h, _ := HashPassword("secret123") + if !CheckPassword(h, "secret123") || CheckPassword(h, "wrong") { + t.Fatal("密码校验不对") + } +} +``` + +- 面试常问:JWT 和服务端 Session 的优缺点;令牌放 `localStorage`(怕 XSS)还是 `httpOnly` Cookie(要防 CSRF)。 + +### 要建的文件 + +- `internal/auth/password.go`、`jwt.go`、`jwt_test.go` + +### 运行方式 + +```bash +go get golang.org/x/crypto/bcrypt github.com/golang-jwt/jwt/v5 +go test ./internal/auth -v +``` + +### 完成标准 + +- 测试通过;能说清 JWT 三段分别是什么,为什么载荷不能放密码。 + +--- + +## 第 6 天:注册、登录、鉴权中间件 + +### 学习内容 + +**1. 用户存储和注册登录** + +- 说明:用户存储同样先定义接口;邮箱唯一冲突转成 409;登录时“邮箱不存在”和“密码错误”返回**同一个**错误,避免别人试探哪些邮箱注册过。 +- 案例: + +```go +// 文件:internal/model/user.go +package model + +import "time" + +type User struct { + ID int64 `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + PasswordHash string `json:"-"` // 永远不输出到 JSON + CreatedAt time.Time `json:"createdAt"` +} +``` + +```go +// 文件:internal/store/user_store.go +package store + +import ( + "context" + "errors" + "sync" + "time" + + "todo-api/internal/model" +) + +var ErrEmailTaken = errors.New("email already registered") + +type UserStore interface { + CreateUser(ctx context.Context, u model.User) (model.User, error) + GetUserByEmail(ctx context.Context, email string) (model.User, error) + GetUser(ctx context.Context, id int64) (model.User, error) +} + +type MemoryUserStore struct { + mu sync.Mutex + nextID int64 + byID map[int64]model.User +} + +func NewMemoryUserStore() *MemoryUserStore { + return &MemoryUserStore{nextID: 1, byID: map[int64]model.User{}} +} + +func (s *MemoryUserStore) CreateUser(_ context.Context, u model.User) (model.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, x := range s.byID { + if x.Email == u.Email { + return model.User{}, ErrEmailTaken + } + } + u.ID, u.CreatedAt = s.nextID, time.Now() + s.nextID++ + s.byID[u.ID] = u + return u, nil +} + +func (s *MemoryUserStore) GetUserByEmail(_ context.Context, email string) (model.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, x := range s.byID { + if x.Email == email { + return x, nil + } + } + return model.User{}, ErrNotFound +} + +func (s *MemoryUserStore) GetUser(_ context.Context, id int64) (model.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[id] + if !ok { + return model.User{}, ErrNotFound + } + return u, nil +} +``` + +```go +// 文件:internal/store/pg_user_store.go +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "todo-api/internal/model" +) + +type PgUserStore struct{ pool *pgxpool.Pool } + +func NewPgUserStore(pool *pgxpool.Pool) *PgUserStore { return &PgUserStore{pool: pool} } + +const userCols = "id, email, name, password_hash, created_at" + +func scanUser(row pgx.Row) (model.User, error) { + var u model.User + err := row.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return u, ErrNotFound + } + return u, err +} + +func (s *PgUserStore) CreateUser(ctx context.Context, u model.User) (model.User, error) { + created, err := scanUser(s.pool.QueryRow(ctx, + `INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING `+userCols, + u.Email, u.Name, u.PasswordHash)) + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { // 唯一约束冲突 + return model.User{}, ErrEmailTaken + } + return created, err +} + +func (s *PgUserStore) GetUserByEmail(ctx context.Context, email string) (model.User, error) { + return scanUser(s.pool.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE email = $1`, email)) +} + +func (s *PgUserStore) GetUser(ctx context.Context, id int64) (model.User, error) { + return scanUser(s.pool.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE id = $1`, id)) +} +``` + +```go +// 文件:internal/handler/auth_handler.go +package handler + +import ( + "errors" + "net/http" + "net/mail" + "strings" + + "github.com/gin-gonic/gin" + + "todo-api/internal/auth" + "todo-api/internal/middleware" + "todo-api/internal/model" + "todo-api/internal/store" +) + +type AuthHandler struct { + Users store.UserStore + Tokens *auth.TokenManager +} + +type credentials struct { + Email string `json:"email"` + Name string `json:"name"` + Password string `json:"password"` +} + +func (h *AuthHandler) Register(c *gin.Context) { + var req credentials + if err := c.ShouldBindJSON(&req); err != nil { + Fail(c, http.StatusBadRequest, 40001, "请求体格式错误") + return + } + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(req.Email); err != nil || len(req.Password) < 8 || req.Name == "" { + Fail(c, http.StatusBadRequest, 40002, "邮箱格式不对、名字为空或密码少于 8 位") + return + } + hash, err := auth.HashPassword(req.Password) + if err != nil { + Fail(c, http.StatusInternalServerError, 50000, "服务器错误") + return + } + u, err := h.Users.CreateUser(c.Request.Context(), model.User{Email: req.Email, Name: req.Name, PasswordHash: hash}) + if errors.Is(err, store.ErrEmailTaken) { + Fail(c, http.StatusConflict, 40901, "邮箱已注册") + return + } + if err != nil { + Fail(c, http.StatusInternalServerError, 50000, "服务器错误") + return + } + Created(c, u) +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req credentials + if err := c.ShouldBindJSON(&req); err != nil { + Fail(c, http.StatusBadRequest, 40001, "请求体格式错误") + return + } + u, err := h.Users.GetUserByEmail(c.Request.Context(), strings.ToLower(strings.TrimSpace(req.Email))) + if err != nil || !auth.CheckPassword(u.PasswordHash, req.Password) { + Fail(c, http.StatusUnauthorized, 40101, "邮箱或密码错误") // 两种情况同一个提示 + return + } + token, err := h.Tokens.Issue(u.ID) + if err != nil { + Fail(c, http.StatusInternalServerError, 50000, "服务器错误") + return + } + OK(c, gin.H{"token": token, "user": u}) +} + +func (h *AuthHandler) Me(c *gin.Context) { + u, err := h.Users.GetUser(c.Request.Context(), middleware.CurrentUserID(c)) + if err != nil { + Fail(c, http.StatusNotFound, 40402, "用户不存在") + return + } + OK(c, u) +} +``` + +**2. 鉴权中间件** + +- 说明:从 `Authorization: Bearer ` 取令牌并校验,把用户 ID 放进上下文;失败返回 401 并中止。401 表示“你是谁我不知道”,403 表示“我知道你是谁,但你没权限”。 +- 案例: + +```go +// 文件:internal/middleware/auth.go +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "todo-api/internal/auth" +) + +const userIDKey = "userID" + +func RequireAuth(tm *auth.TokenManager) gin.HandlerFunc { + return func(c *gin.Context) { + raw, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ") + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40100, "message": "未登录"}) + return + } + uid, err := tm.Parse(raw) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40100, "message": "登录已失效"}) + return + } + c.Set(userIDKey, uid) + c.Next() + } +} + +func CurrentUserID(c *gin.Context) int64 { return c.GetInt64(userIDKey) } +``` + +```go +// 文件:internal/router/router.go +package router + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "todo-api/internal/auth" + "todo-api/internal/handler" + "todo-api/internal/middleware" +) + +type Deps struct { + Tasks *handler.TaskHandler + Auth *handler.AuthHandler + Tokens *auth.TokenManager +} + +func New(d Deps) *gin.Engine { + r := gin.New() + r.Use(gin.Recovery(), middleware.Logger(), middleware.CORS("http://localhost:5173")) + api := r.Group("/api") + api.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + api.POST("/auth/register", d.Auth.Register) + api.POST("/auth/login", d.Auth.Login) + + authed := api.Group("", middleware.RequireAuth(d.Tokens)) + authed.GET("/me", d.Auth.Me) + authed.GET("/tasks", d.Tasks.List) + authed.GET("/tasks/:id", d.Tasks.Get) + authed.POST("/tasks", d.Tasks.Create) + authed.PATCH("/tasks/:id", d.Tasks.Update) + authed.DELETE("/tasks/:id", d.Tasks.Delete) + return r +} +``` + +**易错点 / 面试常问(补充)** + +- 易错点:登录失败不要区分“用户不存在”和“密码错误”,统一返回“用户名或密码错误”,防止被枚举账号。 +- 易错点:鉴权中间件解析令牌时要指定允许的签名算法,否则可能被“改算法”攻击;令牌过期时间不要太长。 +- 易错点:注册接口的邮箱唯一靠数据库唯一索引保证,不要只靠“先查再插”(并发时会重复),捕获唯一约束错误后返回 409。 +- 面试常问:401 和 403 的区别(没登录 / 登录了但没权限);令牌被盗怎么办(短有效期 + 刷新令牌 + 服务端黑名单或版本号)。 + +### 要建的文件 + +- 上面 6 个文件;更新 `api.http`:注册 → 登录 → 把令牌存成变量 → 带令牌访问任务接口。 + +### 运行方式 + +```bash +go build ./... && go test ./... +``` + +### 完成标准 + +- 不带令牌访问 `/api/tasks` 返回 401;登录后带令牌返回 200。 + +--- + +## 第 7 天:配置切换、接口测试、集成测试、收尾 + +### 学习内容 + +**1. 配置和依赖组装** + +- 说明:用环境变量选择存储实现(`STORE=memory|postgres`),本地没装数据库也能跑;JWT 密钥必须从环境变量读,长度不够就拒绝启动。 +- 案例: + +```go +// 文件:internal/config/config.go +package config + +import ( + "errors" + "os" + "time" +) + +type Config struct { + Port string + Store string // memory 或 postgres + DatabaseURL string + JWTSecret string + JWTTTL time.Duration +} + +func Load() (Config, error) { + c := Config{ + Port: getenv("PORT", "8080"), + Store: getenv("STORE", "memory"), + DatabaseURL: os.Getenv("DATABASE_URL"), + JWTSecret: os.Getenv("JWT_SECRET"), + JWTTTL: 2 * time.Hour, + } + if len(c.JWTSecret) < 32 { + return c, errors.New("JWT_SECRET 至少 32 个字符") + } + if c.Store == "postgres" && c.DatabaseURL == "" { + return c, errors.New("STORE=postgres 时必须设置 DATABASE_URL") + } + return c, nil +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} +``` + +```go +// 文件:main.go +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "todo-api/internal/auth" + "todo-api/internal/config" + "todo-api/internal/handler" + "todo-api/internal/router" + "todo-api/internal/store" +) + +func main() { + cfg, err := config.Load() + if err != nil { + slog.Error("config", "err", err) + os.Exit(1) + } + + var tasks store.TaskStore = store.NewMemoryStore() + var users store.UserStore = store.NewMemoryUserStore() + if cfg.Store == "postgres" { + pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL) + if err == nil { + err = pool.Ping(context.Background()) + } + if err != nil { + slog.Error("database", "err", err) + os.Exit(1) + } + defer pool.Close() + tasks, users = store.NewPgStore(pool), store.NewPgUserStore(pool) + } + + tm := auth.NewTokenManager(cfg.JWTSecret, cfg.JWTTTL) + r := router.New(router.Deps{ + Tasks: &handler.TaskHandler{Store: tasks}, + Auth: &handler.AuthHandler{Users: users, Tokens: tm}, + Tokens: tm, + }) + srv := &http.Server{Addr: ":" + cfg.Port, Handler: r} + + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("listen", "err", err) + os.Exit(1) + } + }() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) +} +``` + +**2. 接口测试改为带登录** + +- 说明:路由加了鉴权,上周的测试要先拿到令牌。测试里直接用 `TokenManager` 签一个,比走注册登录更快。 +- 案例: + +```go +// 文件:internal/handler/task_handler_test.go +package handler_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "todo-api/internal/auth" + "todo-api/internal/handler" + "todo-api/internal/router" + "todo-api/internal/store" +) + +func setup() (*gin.Engine, string) { + gin.SetMode(gin.TestMode) + tm := auth.NewTokenManager("test-secret-test-secret-test-secret!!", time.Hour) + r := router.New(router.Deps{ + Tasks: &handler.TaskHandler{Store: store.NewMemoryStore()}, + Auth: &handler.AuthHandler{Users: store.NewMemoryUserStore(), Tokens: tm}, + Tokens: tm, + }) + token, _ := tm.Issue(1) + return r, token +} + +func do(r *gin.Engine, method, path, token, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + r.ServeHTTP(w, req) + return w +} + +func TestTasksRequireAuth(t *testing.T) { + r, token := setup() + if w := do(r, http.MethodGet, "/api/tasks", "", ""); w.Code != http.StatusUnauthorized { + t.Fatalf("无令牌 got %d", w.Code) + } + if w := do(r, http.MethodPost, "/api/tasks", token, `{"title":"学 SQL"}`); w.Code != http.StatusCreated { + t.Fatalf("创建 got %d", w.Code) + } + if w := do(r, http.MethodGet, "/api/tasks/999", token, ""); w.Code != http.StatusNotFound { + t.Fatalf("不存在 got %d", w.Code) + } +} + +func TestRegisterLogin(t *testing.T) { + r, _ := setup() + body := `{"email":"a@example.com","name":"小王","password":"secret123"}` + if w := do(r, http.MethodPost, "/api/auth/register", "", body); w.Code != http.StatusCreated { + t.Fatalf("注册 got %d", w.Code) + } + if w := do(r, http.MethodPost, "/api/auth/register", "", body); w.Code != http.StatusConflict { + t.Fatalf("重复注册 got %d", w.Code) + } + if w := do(r, http.MethodPost, "/api/auth/login", "", `{"email":"a@example.com","password":"bad"}`); w.Code != http.StatusUnauthorized { + t.Fatalf("错误密码 got %d", w.Code) + } +} +``` + +**3. 集成测试(连真数据库)** + +- 说明:内存版测试快,但发现不了 SQL 写错;集成测试连真数据库。没设置 `TEST_DATABASE_URL` 时自动跳过,这样没有数据库的环境也能跑 `go test`。 +- 案例: + +```go +// 文件:internal/store/pg_store_test.go +package store + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "todo-api/internal/model" +) + +func setupPg(t *testing.T) *PgStore { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("未设置 TEST_DATABASE_URL,跳过集成测试") + } + pool, err := pgxpool.New(context.Background(), url) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(context.Background(), "TRUNCATE tasks RESTART IDENTITY CASCADE"); err != nil { + t.Fatal(err) + } + return NewPgStore(pool) +} + +func TestPgStoreCRUD(t *testing.T) { + s := setupPg(t) + ctx := context.Background() + created, err := s.Create(ctx, model.Task{Title: "集成测试"}) + if err != nil { + t.Fatal(err) + } + created.Done = true + if _, err := s.Update(ctx, created); err != nil { + t.Fatal(err) + } + done := true + list, _ := s.List(ctx, &done) + if len(list) != 1 { + t.Fatalf("got %d", len(list)) + } + if err := s.AddTags(ctx, created.ID, []string{"学习", "学习"}); err != nil { + t.Fatal(err) + } + if err := s.Delete(ctx, created.ID); err != nil { + t.Fatal(err) + } + if _, err := s.Get(ctx, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } +} +``` + +**易错点 / 面试常问(补充)** + +- 易错点:集成测试每次开始前要清理数据(本周用 `TRUNCATE ... RESTART IDENTITY CASCADE`),否则测试之间互相影响、结果不稳定。 +- 易错点:`go test ./... -race` 能发现数据竞争,但只在测试实际跑到的代码路径上。 +- 面试常问:单元测试、集成测试、端到端测试的区别和比例;为什么用接口(`Store`)隔离存储层——测试可以换成内存实现,业务代码不用改。 + +### 要建的文件 + +- `internal/config/config.go`、更新 `main.go`、更新 `internal/handler/task_handler_test.go`、`internal/store/pg_store_test.go` +- `todo-api/README.md` 补充:数据库启动、迁移、切换存储、鉴权说明。 +- 提交推送(确认 `.env` 没提交)。 + +### 运行方式 + +```bash +export JWT_SECRET=$(openssl rand -hex 32) +STORE=postgres DATABASE_URL=postgres://todo:todo@localhost:5432/todo?sslmode=disable go run . +TEST_DATABASE_URL=postgres://todo:todo@localhost:5432/todo?sslmode=disable go test ./... -race +``` + +### 完成标准 + +- 切换 `STORE` 后接口行为一致,重启后数据还在;全部测试通过;`go vet` 无警告。 + +--- + +## 本周自查清单 + +- [ ] 会建表、加约束,会设计一对多和多对多 +- [ ] 会写 `JOIN`、`GROUP BY`、`HAVING`,会看 PostgreSQL 和 MySQL 的执行计划 +- [ ] 能说出索引失效的常见情况、B+ 树、回表和覆盖索引 +- [ ] 会写迁移,理解“先迁移再发代码” +- [ ] 会用 pgx 写参数化查询和事务,会用 `ON CONFLICT` 做幂等写入 +- [ ] 能讲清四个隔离级别、MVCC、乐观锁和悲观锁 +- [ ] 能说出至少 5 条 MySQL 和 PostgreSQL 的差异 +- [ ] 会用 bcrypt 存密码,会签发和校验 JWT,分得清 401 和 403 +- [ ] 在总纲进度记录里勾掉第 2 周 diff --git a/plan/week02-gin-api.md b/plan/week02-gin-api.md deleted file mode 100644 index eeaa6bd..0000000 --- a/plan/week02-gin-api.md +++ /dev/null @@ -1,229 +0,0 @@ -# 转岗计划 · 第 2 周学习计划(Gin 写 HTTP 接口 + 接前端) - -> 所属阶段:第一段 Go 全栈基础(第 1~5 周)。总纲见同目录 `README.md`,下周计划见 `week03-postgresql.md`。 - -目标:把第 1 周的命令行任务清单改造成一个 Web 应用。后端用 Gin 提供 REST 接口,前端用你熟悉的 React 或 Vue 调用接口。本周数据先存在内存里,第 3 周再换成 PostgreSQL。 - -前置条件:第 1 周的 `todo` 项目已完成,`go test ./...` 能通过。 - -参考资料: -- Gin 官方文档(中文):https://gin-gonic.com/zh-cn/docs/ -- Go 标准库 `net/http`:https://pkg.go.dev/net/http - ---- - -## 最终项目结构 - -``` -todo-api/ -├── backend/ -│ ├── go.mod -│ ├── main.go # 程序入口:读配置、组装依赖、启动服务 -│ ├── internal/ -│ │ ├── model/ -│ │ │ └── task.go # Task 结构体和校验 -│ │ ├── store/ -│ │ │ ├── store.go # TaskStore 接口定义 -│ │ │ └── memory_store.go # 内存实现(并发安全) -│ │ ├── handler/ -│ │ │ ├── task_handler.go # 任务相关接口 -│ │ │ ├── task_handler_test.go # 接口测试 -│ │ │ └── response.go # 统一响应格式 -│ │ ├── middleware/ -│ │ │ ├── logger.go # 请求日志 -│ │ │ ├── cors.go # 跨域 -│ │ │ └── recovery.go # 捕获 panic -│ │ └── router/ -│ │ └── router.go # 路由注册 -│ └── api.http # 手动测试接口用的请求文件 -└── frontend/ - ├── package.json - ├── .env.development # 接口地址配置 - └── src/ - ├── api/ - │ └── tasks.ts # 封装所有任务接口请求 - ├── types/ - │ └── task.ts # Task 类型,和后端保持一致 - ├── components/ - │ ├── TaskForm.tsx # 新增任务表单 - │ ├── TaskList.tsx # 任务列表 - │ └── TaskItem.tsx # 单条任务(完成、删除、编辑) - └── App.tsx -``` - -如果前端用 Vue,把 `.tsx` 换成 `.vue`,结构不变。 - -## 接口设计(本周要实现的全部接口) - -| 方法 | 路径 | 说明 | 成功状态码 | -| --- | --- | --- | --- | -| GET | `/api/health` | 健康检查 | 200 | -| GET | `/api/tasks` | 获取任务列表,支持 `?done=true/false` 过滤 | 200 | -| GET | `/api/tasks/:id` | 获取单个任务 | 200 | -| POST | `/api/tasks` | 新增任务,请求体 `{"title": "..."}` | 201 | -| PATCH | `/api/tasks/:id` | 修改标题或完成状态 | 200 | -| DELETE | `/api/tasks/:id` | 删除任务 | 204 | - -统一响应格式: - -```json -{ "code": 0, "message": "ok", "data": { } } -``` - -出错时 `code` 为非 0,`message` 写清楚原因,例如 `{"code": 40401, "message": "task not found", "data": null}`。 - ---- - -## 第 1 天:用标准库理解 HTTP,再上手 Gin - -**学习内容** -- 先用标准库 `net/http` 写一个最简单的服务器,理解 `http.Handler`、`ResponseWriter`、`Request`。 -- 再用 Gin 改写,对比两者差别,理解 Gin 帮你省了什么。 -- 安装 Gin:`go get github.com/gin-gonic/gin`。 - -**要建的文件** -- `backend/go.mod`:运行 `go mod init todo-api`。 -- `backend/main.go`: - - 先写标准库版本:`/hello` 返回 `Hello, HTTP`,跑通后注释掉。 - - 再写 Gin 版本:`GET /api/health` 返回 `{"status": "ok"}`,监听 `8080` 端口。 -- `backend/api.http`:写好 `GET http://localhost:8080/api/health` 请求,用编辑器的 REST Client 插件或 `curl` 测试。 - -**运行方式**:`cd backend && go run .`,另开终端运行 `curl localhost:8080/api/health` - -**完成标准**:能说清楚一个 HTTP 请求从进入 Gin 到返回响应经过了哪些步骤。 - ---- - -## 第 2 天:数据模型和存储层 - -**学习内容** -- 用接口隔离存储层,让接口代码不关心数据存在哪儿(下周换数据库时只换实现)。 -- 用 `sync.RWMutex` 让内存存储在并发请求下安全。 - -**要建的文件** -- `internal/model/task.go`: - - `Task` 结构体:`ID int64`、`Title string`、`Done bool`、`CreatedAt time.Time`、`UpdatedAt time.Time`,带 JSON 标签。 - - `func (t Task) Validate() error`:标题不能为空,长度不超过 100 字。 -- `internal/store/store.go`: - - `var ErrNotFound = errors.New("task not found")` - - 定义 `TaskStore` 接口:`List(done *bool) ([]model.Task, error)`、`Get(id int64)`、`Create(t model.Task)`、`Update(t model.Task)`、`Delete(id int64)`。 -- `internal/store/memory_store.go`: - - `MemoryStore` 结构体:内部用 `map[int64]model.Task` 存数据,用自增 ID,用 `sync.RWMutex` 加锁。 - - 实现 `TaskStore` 接口的全部方法,找不到时返回 `ErrNotFound`。 - -**完成标准**:写一个小测试,启动 100 个 goroutine 同时调用 `Create`,最后任务数正好是 100,用 `go test -race ./...` 检查没有数据竞争。 - ---- - -## 第 3 天:任务接口(增删改查) - -**学习内容** -- Gin 的路由分组、路径参数 `c.Param`、查询参数 `c.Query`。 -- 请求体绑定 `c.ShouldBindJSON` 和参数校验标签 `binding:"required"`。 -- 正确使用 HTTP 状态码。 - -**要建的文件** -- `internal/handler/response.go`: - - `func OK(c *gin.Context, data any)`、`func Created(c *gin.Context, data any)`、`func Fail(c *gin.Context, httpStatus int, code int, msg string)`,统一响应格式。 -- `internal/handler/task_handler.go`: - - `TaskHandler` 结构体,字段是 `store.TaskStore` 接口。 - - 方法 `List`、`Get`、`Create`、`Update`、`Delete`,对应上面的接口表。 - - 请求结构体 `CreateTaskRequest`、`UpdateTaskRequest`(`Update` 里的字段用指针,区分“没传”和“传了空值”)。 - - `store.ErrNotFound` 转成 404,校验失败转成 400。 -- `internal/router/router.go`: - - `func New(h *handler.TaskHandler) *gin.Engine`,注册 `/api` 路由组下的全部接口。 -- 更新 `main.go`:创建 `MemoryStore`、`TaskHandler`、路由,启动服务。 -- 更新 `api.http`:为 6 个接口都写好测试请求,包括错误情况(空标题、不存在的 ID)。 - -**完成标准**:用 `api.http` 或 `curl` 把每个接口的正常和出错情况都测一遍,状态码和响应格式都正确。 - ---- - -## 第 4 天:中间件 - -**学习内容** -- Gin 中间件原理:`c.Next()` 前后分别执行什么。 -- 跨域(CORS)为什么会出现,前端开发时怎么处理。 - -**要建的文件** -- `internal/middleware/logger.go`:记录每个请求的方法、路径、状态码、耗时,用标准库 `log/slog` 输出。 -- `internal/middleware/cors.go`:允许前端开发地址(如 `http://localhost:5173`)跨域访问,处理 `OPTIONS` 预检请求。 -- `internal/middleware/recovery.go`:捕获 panic,返回统一的 500 错误响应,并记录日志。 -- 更新 `router.go`:用 `gin.New()` 替代 `gin.Default()`,手动挂上这三个中间件。 - -**完成标准**:故意在一个接口里写 `panic`,服务不会挂掉,返回 500 和统一格式;日志里能看到每个请求的耗时。 - ---- - -## 第 5 天:接口测试 - -**学习内容** -- 用 `net/http/httptest` 在不启动服务的情况下测试接口。 -- 表格驱动测试覆盖多种请求。 - -**要建的文件** -- `internal/handler/task_handler_test.go`: - - 写一个 `setupRouter()` 辅助函数,用 `MemoryStore` 组装路由。 - - 测试用例至少包括:创建成功返回 201、空标题返回 400、获取不存在的任务返回 404、修改完成状态、删除后再获取返回 404、按 `done` 过滤列表。 - -**运行方式**:`go test ./... -v -race` - -**完成标准**:全部测试通过,没有数据竞争警告。 - ---- - -## 第 6 天:前端接入 - -**学习内容** -- 前后端分离的联调方式:开发时用环境变量配置接口地址,或用 Vite 代理。 -- 前端类型和后端 JSON 保持一致。 - -**要建的文件** -- 用 Vite 创建前端:`npm create vite@latest frontend -- --template react-ts`(Vue 用 `vue-ts`)。 -- `frontend/.env.development`:`VITE_API_BASE=http://localhost:8080/api` -- `src/types/task.ts`:`Task` 类型,字段名和后端 JSON 标签一致。 -- `src/api/tasks.ts`:封装 `listTasks`、`createTask`、`updateTask`、`deleteTask`,统一处理响应格式,`code` 非 0 时抛出错误。 -- `src/components/TaskForm.tsx`:输入标题,回车或点按钮新增。 -- `src/components/TaskItem.tsx`:显示单条任务,可勾选完成、删除、双击编辑标题。 -- `src/components/TaskList.tsx`:显示列表,带“全部 / 未完成 / 已完成”过滤。 -- `src/App.tsx`:组合以上组件,处理加载状态和错误提示。 - -**运行方式**:后端 `go run .`,前端 `npm run dev`,浏览器打开前端地址。 - -**完成标准**:在页面上能完成增删改查和过滤,后端日志能看到对应请求。 - ---- - -## 第 7 天:配置、优雅关机和收尾 - -**学习内容** -- 用环境变量配置端口等参数,不把配置写死在代码里。 -- 优雅关机:收到 `Ctrl+C` 时,等正在处理的请求完成再退出。 - -**要建的文件和修改** -- 更新 `main.go`: - - 从环境变量 `PORT` 读端口,没设置时默认 `8080`。 - - 用 `http.Server` 包装 Gin,监听 `os.Interrupt` 和 `syscall.SIGTERM` 信号,调用 `server.Shutdown` 优雅关机,超时 5 秒。 -- `todo-api/README.md`:写清楚项目功能、技术栈、接口列表、本地运行步骤。 -- 提交并推送到 `zhuangang` 仓库(路径 `todo-api/`)。 - -**完成标准** -- `go vet ./...` 没有警告,`gofmt -l .` 没有输出。 -- `go test ./... -race` 全部通过。 -- 前后端联调正常,README 别人照着能跑起来。 -- 在总纲 `README.md` 的进度记录里勾掉第 2 周。 - ---- - -## 本周自查清单 - -- [ ] 能说清楚 Gin 处理一个请求的流程 -- [ ] 理解为什么存储层要用接口隔离 -- [ ] 会用 `RWMutex` 写并发安全的结构 -- [ ] 会写 REST 接口,状态码使用正确 -- [ ] 会写自定义中间件 -- [ ] 会用 `httptest` 写接口测试 -- [ ] 前后端联调完成 -- [ ] 会做配置读取和优雅关机 - -下周预告:第 3 周把 `MemoryStore` 换成 PostgreSQL,学表结构设计、SQL、索引和事务,用 sqlc 生成数据访问代码。因为本周用接口隔离了存储层,下周只需要新写一个实现,接口代码基本不用动。 diff --git a/plan/week03-postgresql.md b/plan/week03-postgresql.md deleted file mode 100644 index 6fcb72a..0000000 --- a/plan/week03-postgresql.md +++ /dev/null @@ -1,319 +0,0 @@ -# 转岗计划 · 第 3 周学习计划(PostgreSQL + SQL + sqlc) - -> 所属阶段:第一段 Go 全栈基础(第 1~5 周)。总纲见同目录 `README.md`,上周计划 `week02-gin-api.md`,下周计划 `week04-auth-project1.md`。 - -目标:把第 2 周任务清单里的 `MemoryStore` 换成 PostgreSQL。学会表结构设计、常用 SQL、索引、事务和数据库迁移,用 sqlc 生成类型安全的数据访问代码。因为上周用接口隔离了存储层,本周主要是新写一个 `PgStore`,接口代码基本不用动。 - -前置条件:第 2 周的 `todo-api` 已完成,`go test ./... -race` 能通过,前端能联调。 - -要求:SQL 自己写,AI 只帮你检查和解释。表结构设计、索引、事务是所有后端岗位的基本功,后面 RAG 和 Agent 项目设计状态表也靠它。 - -**为什么学 PostgreSQL 而招聘里 MySQL 更多**:招聘里写 MySQL 的明显更多,但两者的常用 SQL 大部分相同。本周用 PostgreSQL 学(它的 pgvector 扩展在 RAG 里也能用),文末附“MySQL 差异速查”和一个半天的小练习,面试时能说清差异就够了。 - -参考资料: -- PostgreSQL 官方文档(中文):http://www.postgres.cn/docs/current/ -- sqlc 文档:https://docs.sqlc.dev/ -- golang-migrate:https://github.com/golang-migrate/migrate -- pgx:https://github.com/jackc/pgx - ---- - -## 最终项目结构 - -只列出本周新增或修改的文件,其他保持第 2 周的样子。 - -``` -todo-api/ -└── backend/ - ├── docker-compose.yml # 本地 PostgreSQL - ├── Makefile # 常用命令:db-up、migrate-up、sqlc 等 - ├── .env.example # DATABASE_URL 示例(.env 本身不提交) - ├── sqlc.yaml # sqlc 配置 - ├── db/ - │ ├── migrations/ - │ │ ├── 000001_create_tasks.up.sql - │ │ ├── 000001_create_tasks.down.sql - │ │ ├── 000002_add_tasks_indexes.up.sql - │ │ ├── 000002_add_tasks_indexes.down.sql - │ │ ├── 000003_create_tags.up.sql - │ │ └── 000003_create_tags.down.sql - │ └── queries/ - │ ├── tasks.sql # 带 sqlc 注释的查询 - │ └── tags.sql - ├── sql/ - │ └── practice/ # 练习用 SQL,不参与程序运行 - │ ├── day1_crud.sql - │ ├── day2_schema.sql - │ ├── day3_queries.sql - │ └── seed.sql - ├── internal/ - │ ├── config/ - │ │ └── config.go # 读环境变量 - │ ├── db/ # sqlc 生成的代码(不要手改) - │ │ ├── db.go - │ │ ├── models.go - │ │ ├── tasks.sql.go - │ │ └── tags.sql.go - │ ├── model/ - │ │ └── task.go # 新增 Tags 字段 - │ ├── store/ - │ │ ├── store.go # 接口方法加上 context.Context - │ │ ├── memory_store.go # 同步改签名 - │ │ ├── pg_store.go # PostgreSQL 实现 - │ │ ├── pg_tx.go # 事务辅助函数 - │ │ └── pg_store_test.go # 集成测试(需要数据库) - │ └── handler/ - │ └── task_handler.go # 传入 c.Request.Context(),新增分页和标签参数 - └── main.go # 按配置选择 memory 或 postgres -``` - -## 本周数据库表 - -``` -tasks - id BIGSERIAL PRIMARY KEY - title TEXT NOT NULL (长度 1~100,用 CHECK 约束) - done BOOLEAN NOT NULL DEFAULT false - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - -tags - id BIGSERIAL PRIMARY KEY - name TEXT NOT NULL UNIQUE - -task_tags(多对多关联表) - task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE - tag_id BIGINT REFERENCES tags(id) ON DELETE CASCADE - PRIMARY KEY (task_id, tag_id) -``` - ---- - -## 第 1 天:装 PostgreSQL,用 psql 写增删改查 - -**学习内容** -- 用 Docker 跑 PostgreSQL(不用往系统里直接装)。没有 Docker 的话先装:`sudo apt install docker.io docker-compose-v2`。 -- `psql` 常用命令:`\l` 列数据库、`\dt` 列表、`\d 表名` 看表结构、`\q` 退出。 -- 基本 SQL:`CREATE TABLE`、`INSERT`、`SELECT ... WHERE ... ORDER BY ... LIMIT`、`UPDATE`、`DELETE`。 -- 常用类型:`BIGSERIAL`、`TEXT`、`BOOLEAN`、`TIMESTAMPTZ`、`JSONB`(先知道有这个,Agent 项目会用到类似的思路)。 - -**要建的文件** -- `backend/docker-compose.yml`: - - 一个 `postgres:16` 服务,端口 `5432:5432`,环境变量 `POSTGRES_USER=todo`、`POSTGRES_PASSWORD=todo`、`POSTGRES_DB=todo`。 - - 用具名卷 `pgdata` 保存数据,容器删了数据还在。 -- `backend/.env.example`:`DATABASE_URL=postgres://todo:todo@localhost:5432/todo?sslmode=disable` -- `backend/sql/practice/day1_crud.sql`: - - 建一张练习表 `practice_tasks`。 - - 插入 5 条数据,查询未完成的、按创建时间倒序、只取前 3 条,更新一条,删除一条。 - - 每条语句上面写一行注释说明它在做什么。 - -**运行方式** -```bash -cd todo-api/backend -docker compose up -d -docker compose exec -T postgres psql -U todo -d todo < sql/practice/day1_crud.sql -docker compose exec postgres psql -U todo -d todo # 进入交互模式 -``` - -**完成标准**:不看资料能写出带 `WHERE`、`ORDER BY`、`LIMIT` 的查询,以及 `UPDATE ... WHERE id = ...`,并能说出漏写 `WHERE` 的后果。 - ---- - -## 第 2 天:表结构设计和数据库迁移 - -**学习内容** -- 主键、外键、`NOT NULL`、`UNIQUE`、`CHECK`、`DEFAULT` 约束:能交给数据库保证的规则,就不要只靠代码保证。 -- 一对多(一个用户多个任务)和多对多(任务和标签)怎么建表。 -- 为什么要用迁移文件:表结构变更也要进 Git,每个环境按顺序执行,避免“代码升级了、库表没升级”导致接口报错。 -- 安装 golang-migrate:`go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest`。 - -**要建的文件** -- `backend/sql/practice/day2_schema.sql`: - - 练习设计一套 `users`、`practice_tasks`(带 `user_id` 外键)、`tags`、`task_tags` 表。 - - 写注释回答:删除用户时,他的任务该怎么处理?`ON DELETE CASCADE` 和 `ON DELETE RESTRICT` 各适合什么场景? -- `backend/db/migrations/000001_create_tasks.up.sql`:按上面“本周数据库表”建 `tasks` 表,`title` 加 `CHECK (char_length(title) BETWEEN 1 AND 100)`。 -- `backend/db/migrations/000001_create_tasks.down.sql`:`DROP TABLE IF EXISTS tasks;` -- `backend/Makefile`: - - `db-up`:`docker compose up -d` - - `migrate-up`:`migrate -path db/migrations -database "$(DATABASE_URL)" up` - - `migrate-down`:回退一步 `down 1` - - `migrate-new`:`migrate create -ext sql -dir db/migrations -seq $(name)` - - `psql`:进入数据库 - -**运行方式**:`export $(cat .env | xargs) && make migrate-up`,再 `make psql` 用 `\d tasks` 看表结构;`make migrate-down` 再 `make migrate-up`,确认能来回执行。 - -**完成标准**:能说清楚“为什么上线时先跑迁移再发新代码”,以及 down 迁移的作用。 - ---- - -## 第 3 天:多表查询、分组统计和索引 - -**学习内容** -- `JOIN`(内连接)和 `LEFT JOIN` 的区别。 -- `GROUP BY` + `COUNT` / `SUM`、`HAVING`。 -- 子查询和 `EXISTS`。 -- 索引:B-tree 索引适合什么查询;用 `EXPLAIN ANALYZE` 看查询计划,分清 `Seq Scan` 和 `Index Scan`。 -- 索引的代价:写入变慢、占空间,不是越多越好。 - -**要建的文件** -- `backend/sql/practice/seed.sql`:用 `generate_series` 往 `practice_tasks` 插入 10 万条测试数据,随机分配给 100 个用户和若干标签。 -- `backend/sql/practice/day3_queries.sql`,至少包含: - - 每个用户的任务总数和已完成数(`GROUP BY` + `COUNT(*) FILTER (WHERE done)`)。 - - 查出没有任何任务的用户(`LEFT JOIN ... WHERE ... IS NULL`)。 - - 查出带某个标签的任务(两次 `JOIN` 经过关联表)。 - - 任务数超过 1000 的用户(`HAVING`)。 - - 对“按用户查未完成任务并按时间倒序”的查询,加索引前后各跑一次 `EXPLAIN ANALYZE`,把耗时和计划写在注释里。 -- `backend/db/migrations/000002_add_tasks_indexes.up.sql`:给 `tasks` 加 `(done, created_at DESC)` 的组合索引。 -- `backend/db/migrations/000002_add_tasks_indexes.down.sql`:删除这个索引。 - -**运行方式**:在 `psql` 里执行 `\i` 或用第 1 天的方式执行 SQL 文件。 - -**完成标准**:能解释组合索引的“最左前缀”规则,能看懂 `EXPLAIN ANALYZE` 输出里最关键的两三行。 - ---- - -## 第 4 天:sqlc 生成代码,写 PgStore - -**学习内容** -- sqlc 的思路:你写 SQL,它生成 Go 结构体和函数,编译期就能发现字段写错。 -- pgx 连接池 `pgxpool`。 -- `context.Context`:数据库调用都要带上下文,请求取消或超时时能停止查询。 -- 安装 sqlc:`go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest`;添加依赖 `go get github.com/jackc/pgx/v5`。 - -**要建的文件** -- `backend/sqlc.yaml`:引擎 `postgresql`,`schema` 指向 `db/migrations`,`queries` 指向 `db/queries`,输出包 `db` 到 `internal/db`,`sql_package: "pgx/v5"`。 -- `backend/db/queries/tasks.sql`: - - `-- name: ListTasks :many`(支持按 `done` 过滤,用 `sqlc.narg('done')` 处理“可选参数”) - - `-- name: GetTask :one` - - `-- name: CreateTask :one`(`RETURNING *`) - - `-- name: UpdateTask :one`(同时更新 `updated_at = now()`) - - `-- name: DeleteTask :execrows`(根据影响行数判断是否存在) -- 运行 `sqlc generate`,生成 `internal/db/` 下的代码。读一遍生成的 `tasks.sql.go`,看懂它在做什么。 -- 修改 `internal/store/store.go`:`TaskStore` 每个方法第一个参数加 `ctx context.Context`。 -- 修改 `internal/store/memory_store.go` 和 `internal/handler/task_handler.go`:同步改签名,handler 里传 `c.Request.Context()`。 -- `internal/store/pg_store.go`: - - `type PgStore struct { pool *pgxpool.Pool; q *db.Queries }` - - `func NewPgStore(pool *pgxpool.Pool) *PgStore` - - 实现 `TaskStore` 全部方法;`pgx.ErrNoRows` 和影响行数为 0 时返回 `store.ErrNotFound`。 - - `func toModel(t db.Task) model.Task`:把生成的结构体转成自己的 `model.Task`,不让生成代码泄露到 handler 层。 -- `Makefile` 增加 `sqlc` 目标:`sqlc generate`。 - -**运行方式**:`make sqlc && go build ./...`;`go test ./... -race`(内存版测试要继续通过)。 - -**完成标准**:能说清楚 `model.Task` 和 `db.Task` 为什么分开,以及给存储层加 `context` 的意义。 - ---- - -## 第 5 天:事务和标签功能 - -**学习内容** -- 事务的作用:几条写操作要么全成功、要么全失败。 -- `BEGIN` / `COMMIT` / `ROLLBACK`;在 Go 里用 `pool.Begin(ctx)`,`defer tx.Rollback(ctx)`,最后 `tx.Commit(ctx)`。 -- 隔离级别先了解“读已提交”(PostgreSQL 默认)就够,知道还有更严格的级别。 -- `INSERT ... ON CONFLICT DO NOTHING / DO UPDATE`(“存在就跳过或更新”,Agent 项目里做幂等写入会用到)。 - -**要建的文件** -- `backend/db/migrations/000003_create_tags.up.sql` / `.down.sql`:建 `tags`、`task_tags` 表。 -- `backend/db/queries/tags.sql`: - - `-- name: UpsertTag :one`(`INSERT ... ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`) - - `-- name: AddTaskTag :exec` - - `-- name: ListTagsByTaskIDs :many`(一次查多个任务的标签,避免“查 N 个任务就发 N 次查询”) -- `internal/store/pg_tx.go`: - - `func (s *PgStore) withTx(ctx context.Context, fn func(q *db.Queries) error) error`:开启事务、执行函数、出错回滚、成功提交。 -- `internal/model/task.go`:`Task` 增加 `Tags []string` 字段,`Validate()` 校验标签数量不超过 5 个、每个不超过 20 字。 -- `internal/store/pg_store.go`: - - `Create` 改成在事务里:插入任务 → 逐个 `UpsertTag` → `AddTaskTag`。 - - `List` 查出任务后,用 `ListTagsByTaskIDs` 一次性补上标签。 -- `MemoryStore` 同步支持 `Tags`(简单存在结构体里即可),保证两种实现行为一致。 -- 更新 `api.http`:新增带标签的创建请求。 - -**完成标准**:在 `Create` 里故意在插入标签后返回一个错误,确认任务也没有写进数据库(回滚生效),验证完再删掉这段代码。 - ---- - -## 第 6 天:配置切换和集成测试 - -**学习内容** -- 用环境变量选择存储实现,开发时也能随时切回内存版。 -- 集成测试和单元测试的区别:集成测试连真数据库,跑得慢,但能发现 SQL 写错。 -- 测试之间的数据隔离:每个测试前清空表(`TRUNCATE ... RESTART IDENTITY CASCADE`)。 - -**要建的文件** -- `internal/config/config.go`: - - `type Config struct { Port string; Store string; DatabaseURL string }` - - `func Load() (Config, error)`:`STORE` 只能是 `memory` 或 `postgres`;选了 `postgres` 但没有 `DATABASE_URL` 时返回错误。 -- 修改 `main.go`: - - 调用 `config.Load()`。 - - `postgres` 模式下创建 `pgxpool`,启动时 `Ping`,失败就打日志退出;优雅关机时关闭连接池。 - - `/api/health` 增加数据库检查:数据库不通时返回 503。 -- `internal/store/pg_store_test.go`: - - 没有设置 `TEST_DATABASE_URL` 时调用 `t.Skip`,这样没装数据库的环境也能跑 `go test`。 - - `setupPg(t)`:连接测试库、清空表,`t.Cleanup` 里关闭连接。 - - 测试:创建 → 获取 → 更新 → 按 `done` 过滤 → 删除 → 再获取返回 `ErrNotFound`;带标签创建后列表里能看到标签;重复标签名不会重复插入。 -- `Makefile` 增加 `test-integration`:设置 `TEST_DATABASE_URL` 后跑 `go test ./internal/store -v`。 - -**运行方式**:`STORE=postgres go run .`,前端不用改,直接联调;`make test-integration`。 - -**完成标准**:切换 `STORE` 环境变量,前端功能完全一样;重启后端后数据还在。 - ---- - -## 第 7 天:分页、慢查询和收尾 - -**学习内容** -- 两种分页:`LIMIT/OFFSET`(简单,但页数越往后越慢)和“游标分页”(`WHERE id < 上一页最后一个 id`)。 -- 慢查询排查的一般步骤:先找出慢的 SQL → `EXPLAIN ANALYZE` → 看是不是缺索引或者查了太多数据 → 改完再测。 - -**要建的文件和修改** -- `db/queries/tasks.sql`:新增 `-- name: ListTasksPage :many`,用游标分页(参数 `before_id`、`limit`)。 -- `internal/handler/task_handler.go`:`GET /api/tasks` 支持 `?limit=20&before_id=xxx`,返回 `{ items, next_before_id }`;`limit` 默认 20、最大 100。 -- 前端 `src/api/tasks.ts` 和 `TaskList`:加“加载更多”按钮。 -- `todo-api/README.md`:补充数据库部分:如何启动 PostgreSQL、跑迁移、切换存储、跑集成测试;画一张表关系图(mermaid `erDiagram`)。 -- 提交并推送到 `zhuangang` 仓库(路径 `todo-api/`)。**确认 `.env` 没有被提交**。 - -**完成标准** -- `go vet ./...` 没有警告,`gofmt -l .` 没有输出。 -- `go test ./... -race` 和 `make test-integration` 都通过。 -- 新电脑按 README 能从零跑起来:`docker compose up -d` → `make migrate-up` → `STORE=postgres go run .`。 -- 在总纲 `README.md` 的进度记录里勾掉第 3 周。 - ---- - -## 本周自查清单 - -- [ ] 会用 Docker 启动 PostgreSQL,会用 psql -- [ ] 能独立写增删改查、`JOIN`、`GROUP BY`、`HAVING` -- [ ] 会设计一对多、多对多的表,知道外键删除策略怎么选 -- [ ] 会写迁移文件,理解为什么表结构要版本化 -- [ ] 能用 `EXPLAIN ANALYZE` 判断有没有走索引 -- [ ] 会用 sqlc 生成代码,知道生成代码和业务模型为什么分开 -- [ ] 会在 Go 里写事务,并验证过回滚 -- [ ] 会用 `ON CONFLICT` 做“存在就跳过或更新” -- [ ] 写了能自动跳过的集成测试 -- [ ] 会做游标分页 -- [ ] 看过文末 MySQL 差异速查,能说出 3 个以上的差异 - -## 附:MySQL 差异速查(第 7 天有余力时做,或放到第 5 周的缓冲时间) - -招聘里 MySQL 出现得多,会 PostgreSQL 后只需要知道这些差异: - -| 方面 | PostgreSQL | MySQL 8 | -| --- | --- | --- | -| 自增主键 | `BIGSERIAL` 或 `GENERATED ... AS IDENTITY` | `BIGINT AUTO_INCREMENT` | -| 插入后拿回数据 | `INSERT ... RETURNING *` | 不支持 `RETURNING`,用 `LAST_INSERT_ID()` 再查一次 | -| 存在就更新 | `ON CONFLICT (...) DO UPDATE` | `ON DUPLICATE KEY UPDATE` | -| 布尔 | `BOOLEAN` | `TINYINT(1)`(`BOOLEAN` 是它的别名) | -| 时间 | `TIMESTAMPTZ` 带时区 | `DATETIME` 不带时区,`TIMESTAMP` 有范围限制;一般统一存 UTC | -| JSON | `JSONB`,可建索引 | `JSON`,索引要借助生成列 | -| 字符集 | 默认 UTF-8 | 建库时指定 `utf8mb4`,否则存不了表情符号 | -| 字符串比较 | 默认区分大小写 | 默认排序规则一般不区分大小写 | -| 存储引擎 | 只有一种 | 用 InnoDB(支持事务和外键) | -| 占位符(Go 驱动) | `$1, $2` | `?` | - -**小练习(半天)** -- 在 `docker-compose.yml` 里加一个 `mysql:8` 服务(端口 `3306`)。 -- `sql/practice/mysql_schema.sql`:把 `000001` 和 `000003` 迁移改写成 MySQL 语法,跑通。 -- `sql/practice/mysql_notes.md`:记下改写时遇到的每一处差异。 -- 了解即可:sqlc 也支持 MySQL(`engine: mysql`),GORM 两种数据库都支持,换库主要改的是 SQL 方言和驱动。 - -下周预告:第 4 周在这套基础上做项目一“团队任务看板”:注册登录、JWT、看板成员权限、按状态分列的任务,前后端完整联调。 diff --git a/plan/week03-project1.md b/plan/week03-project1.md new file mode 100644 index 0000000..d201cb6 --- /dev/null +++ b/plan/week03-project1.md @@ -0,0 +1,846 @@ +# 第 3 周:项目一“团队任务看板”:权限 + Redis + 消息队列 + Docker 部署 + +> 第一段“Go 全栈基础”的最后一周。上周 `week02-db-auth.md`,下周 `week04-python.md`。 +> 前端界面放到第 7 周“前端工程化查漏”里用 Vue 3 / React 完整做,本周只做后端,并用 `api.http` 和测试验证。 + +**本周目标**:在 `todo-api` 的基础上做出能写进简历的后端项目:多人看板 + 成员权限;再补上招聘里常见的 Redis(缓存、限流、分布式锁)、消息队列(Redis Streams 入门,了解 RabbitMQ / Kafka)、Docker + Nginx 部署和 CI。 + +**原则**:Redis、消息队列、Docker 都“够用就好”,Go 部分到本周结束,之后主线是 Python 和 AI 应用。 + +**参考资料** +- go-redis:https://redis.uptrace.dev/zh/ +- Redis 命令(中文):https://redis.com.cn/commands.html +- Redis Streams:https://redis.io/docs/latest/develop/data-types/streams/ +- Docker 入门:https://docs.docker.com/get-started/ +- RabbitMQ 教程:https://www.rabbitmq.com/tutorials ;Kafka 介绍:https://kafka.apache.org/intro + +## 本周最终目录 + +```text +task-board/ # 把 todo-api/backend 复制过来改名,go.mod 改为 module task-board +├── docker-compose.yml # postgres、redis、migrate、api、worker、nginx +├── docker-compose.dev.yml +├── Dockerfile +├── deploy/nginx.conf +├── deploy/deploy.md +├── .github/workflows/ci.yml # 或你的托管平台对应的 CI 目录 +├── cmd/server/main.go +├── cmd/worker/main.go +├── db/migrations/000004_boards.up.sql / .down.sql +└── internal/ + ├── service/ + │ ├── errors.go + │ ├── permission.go # 权限判断 + │ └── permission_test.go + ├── cache/board_cache.go # 旁路缓存 + singleflight + 降级 + ├── ratelimit/limiter.go # Lua 固定窗口限流 + ├── lock/lock.go # Redis 分布式锁 + ├── queue/stream.go # Redis Streams 生产和消费 + ├── queue/stream_test.go + └── ...(沿用上周的 auth、handler、middleware、store) +``` + +--- + +## 第 1 天:看板建模和权限 + +### 学习内容 + +**1. 从需求到表结构** + +- 说明:先列“谁、有什么、能做什么”:用户可以建多个看板;看板有成员,成员分 owner 和 member;任务属于看板,分“待办 / 进行中 / 已完成”三列,可以指派给成员。用户和看板是多对多,**角色放在关联表上**。 +- 案例: + +```sql +-- 文件:db/migrations/000004_boards.up.sql +CREATE TABLE boards ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + owner_id BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE board_members ( + board_id BIGINT REFERENCES boards(id) ON DELETE CASCADE, + user_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), + PRIMARY KEY (board_id, user_id) +); +CREATE INDEX idx_members_user ON board_members(user_id); + +CREATE TABLE board_tasks ( + id BIGSERIAL PRIMARY KEY, + board_id BIGINT NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 100), + status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'doing', 'done')), + position INT NOT NULL DEFAULT 0, + assignee_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_by BIGINT NOT NULL REFERENCES users(id), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_board_tasks ON board_tasks(board_id, status, position); +``` + +**2. service 层和权限判断** + +- 说明:权限判断必须在**后端**做(前端隐藏按钮只是体验)。放在 service 层,handler 只负责解析和响应。非成员访问看板返回 404 而不是 403,不暴露“这个看板存在”。 +- 案例:权限函数 + 表格驱动测试(用假的仓储,不需要数据库)。 + +```go +// 文件:internal/service/errors.go +package service + +import "errors" + +var ( + ErrNotFound = errors.New("not found") + ErrForbidden = errors.New("forbidden") +) +``` + +```go +// 文件:internal/service/permission.go +package service + +import ( + "context" + "errors" +) + +type Role string + +const ( + RoleOwner Role = "owner" + RoleMember Role = "member" +) + +// MemberRepo 由数据库实现:SELECT role FROM board_members WHERE board_id=$1 AND user_id=$2 +// 查不到时返回 ErrNotFound。 +type MemberRepo interface { + GetRole(ctx context.Context, boardID, userID int64) (Role, error) +} + +func RequireRole(ctx context.Context, repo MemberRepo, boardID, userID int64, need Role) error { + role, err := repo.GetRole(ctx, boardID, userID) + if errors.Is(err, ErrNotFound) { + return ErrNotFound // 不是成员:当作看板不存在 + } + if err != nil { + return err + } + if need == RoleOwner && role != RoleOwner { + return ErrForbidden + } + return nil +} +``` + +```go +// 文件:internal/service/permission_test.go +package service + +import ( + "context" + "errors" + "testing" +) + +type fakeRepo map[[2]int64]Role + +func (f fakeRepo) GetRole(_ context.Context, b, u int64) (Role, error) { + if r, ok := f[[2]int64{b, u}]; ok { + return r, nil + } + return "", ErrNotFound +} + +func TestRequireRole(t *testing.T) { + repo := fakeRepo{{1, 10}: RoleOwner, {1, 20}: RoleMember} + cases := []struct { + user int64 + need Role + want error + }{ + {10, RoleOwner, nil}, + {20, RoleMember, nil}, + {20, RoleOwner, ErrForbidden}, + {30, RoleMember, ErrNotFound}, + } + for _, c := range cases { + got := RequireRole(context.Background(), repo, 1, c.user, c.need) + if !errors.Is(got, c.want) && got != c.want { + t.Errorf("user=%d need=%s got %v want %v", c.user, c.need, got, c.want) + } + } +} +``` + +**易错点 / 面试常问(补充)** + +- 易错点:权限判断要放在服务层(或中间件)统一做,不能只靠前端隐藏按钮;每个接口都要校验“当前用户是不是这个看板的成员、角色够不够”。 +- 易错点:查不到看板和没有权限时,对外可以都返回 404,避免泄露“这个看板存在”。 +- 面试常问:RBAC(基于角色的权限控制)是什么;多租户数据隔离怎么做(每条查询都带上 `board_id` / 租户条件)。 + +### 要建的文件 + +- 复制 `todo-api/backend` 为 `task-board/`,改模块名;`000004_boards` 迁移;`internal/service/*`。 + +### 运行方式 + +```bash +make migrate-up && go test ./internal/service -v +``` + +### 完成标准 + +- 能说清角色为什么放在关联表上、为什么非成员返回 404。 + +--- + +## 第 2 天:看板和任务接口、拖动排序的事务 + +### 学习内容 + +**1. 接口设计** + +| 方法 | 路径 | 权限 | +| --- | --- | --- | +| GET / POST | `/api/boards` | 登录 | +| GET | `/api/boards/:boardId` | 成员 | +| POST / DELETE | `/api/boards/:boardId/members[/:userId]` | owner | +| GET / POST | `/api/boards/:boardId/tasks` | 成员 | +| PATCH / DELETE | `/api/boards/:boardId/tasks/:taskId` | 成员(删除:创建者或 owner) | +| POST | `/api/boards/:boardId/tasks/:taskId/move` | 成员 | + +- 说明:所有任务查询都**同时带上 `board_id` 条件**,防止用 A 看板的权限去改 B 看板的任务(越权)。 + +**2. 拖动排序用事务** + +- 说明:把任务移动到某列的第 n 个位置,需要“目标位置之后的任务整体后移一位”+“更新这个任务”两步,必须在一个事务里,否则并发时位置会错乱。 +- 案例: + +```sql +-- move 的两条 SQL(在 Go 里用 withTx 包起来) +UPDATE board_tasks SET position = position + 1 +WHERE board_id = $1 AND status = $2 AND position >= $3 AND id <> $4; + +UPDATE board_tasks SET status = $2, position = $3, updated_at = now() +WHERE id = $4 AND board_id = $1 +RETURNING id, status, position; +``` + +- 创建看板也要事务:插入看板 + 把自己加为 owner。 + +**易错点 / 面试常问(补充)** + +- 易错点:只用事务还不够。PostgreSQL 默认“读已提交”隔离级别下,两个人同时往同一列拖动,仍可能得到重复的位置。可以在事务开头执行 `SELECT id FROM boards WHERE id = $1 FOR UPDATE` 锁住看板这一行,让同一看板的移动排队执行(建议作为本天的加练)。 +- 易错点:事务里出错要回滚,Go 里常见写法是 `defer tx.Rollback(ctx)`,提交成功后再调用回滚是无害的。 +- 面试常问:排序字段的几种设计(连续整数、间隔整数、小数 / 字符串排序键)和各自的代价;乐观更新失败时前端怎么回滚。 + +### 要建的文件 + +- `internal/store/board_store.go`:`CreateBoard`(事务)、`ListBoardsByUser`、`GetRole`、`AddMember`、`RemoveMember`、`ListTasks`、`CreateTask`、`MoveTask`(事务)、`DeleteTask`。 +- `internal/service/board_service.go`、`task_service.go`:每个方法先 `RequireRole`。 +- `internal/handler/board_handler.go`、`board_task_handler.go`;`handler/errors.go` 把 `service.ErrNotFound/ErrForbidden` 映射到 404/403。 +- 接口测试:未登录 401;非成员 404;member 加成员 403;跨看板用别的 `taskId` 返回 404。 + +### 运行方式 + +```bash +go test ./... -race +``` + +### 完成标准 + +- 两个账号在 `api.http` 里走通:A 建看板 → B 看不到 → A 加 B → B 能看到 → B 加成员得到 403。 + +--- + +## 第 3 天:Redis 基础和缓存 + +### 学习内容 + +**1. Redis 是什么、五种常用结构** + +- 说明:内存里的键值数据库,单线程执行命令(6.0 起网络 IO 可多线程),速度快。常用结构和典型用途:String(缓存、计数器)、Hash(对象字段)、List(简单队列、最近 N 条)、Set(去重、标签)、Sorted Set(排行榜、延时队列)。持久化有 RDB(快照)和 AOF(追加日志)两种。 +- 案例:用 `redis-cli` 体验。 + +```bash +docker compose -f docker-compose.dev.yml up -d redis # compose 里加 redis:7 服务,端口 6379 +docker compose -f docker-compose.dev.yml exec redis redis-cli +``` + +```text +SET board:1:name "转岗看板" EX 60 # 60 秒后过期 +TTL board:1:name +INCR page:views +HSET user:1 name 小王 city 上海 +LPUSH recent:1 task3 task2 task1 +LRANGE recent:1 0 9 +SADD board:1:tags 学习 工作 学习 # 自动去重 +ZADD rank 90 小王 85 小李 +ZREVRANGE rank 0 9 WITHSCORES +``` + +**2. 旁路缓存(Cache Aside)** + +- 说明:读:先查缓存,没有再查数据库并写回;写:**先更新数据库,再删除缓存**(而不是更新缓存,避免并发写导致缓存是旧值)。三个经典问题: + - 缓存穿透:查不存在的数据,每次都打到数据库 → 缓存空值 / 布隆过滤器; + - 缓存击穿:热点 key 过期瞬间大量请求 → 互斥锁或 singleflight 合并请求; + - 缓存雪崩:大量 key 同时过期 → 过期时间加随机值。 +- 另外要**降级**:Redis 挂了不能让接口跟着挂。 +- 案例:看板任务列表缓存。 + +```go +// 文件:internal/cache/board_cache.go +package cache + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/rand/v2" + "time" + + "github.com/redis/go-redis/v9" + "golang.org/x/sync/singleflight" +) + +type BoardCache struct { + rdb *redis.Client + ttl time.Duration + sf singleflight.Group +} + +func NewBoardCache(rdb *redis.Client, ttl time.Duration) *BoardCache { + return &BoardCache{rdb: rdb, ttl: ttl} +} + +func key(boardID int64) string { return fmt.Sprintf("board:%d:tasks", boardID) } + +// GetOrLoad 返回看板任务列表的 JSON;load 负责从数据库查询并序列化。 +func (c *BoardCache) GetOrLoad(ctx context.Context, boardID int64, load func(context.Context) ([]byte, error)) ([]byte, error) { + k := key(boardID) + b, err := c.rdb.Get(ctx, k).Bytes() + if err == nil { + return b, nil // 命中 + } + if !errors.Is(err, redis.Nil) { + slog.Warn("redis 不可用,降级查数据库", "err", err) + return load(ctx) + } + // 未命中:同一个看板的并发请求只查一次数据库(防击穿) + v, err, _ := c.sf.Do(k, func() (any, error) { + data, err := load(ctx) + if err != nil { + return nil, err + } + jitter := time.Duration(rand.Int64N(int64(30 * time.Second))) // 防雪崩 + if err := c.rdb.Set(ctx, k, data, c.ttl+jitter).Err(); err != nil { + slog.Warn("写缓存失败", "err", err) + } + return data, nil + }) + if err != nil { + return nil, err + } + return v.([]byte), nil +} + +// Invalidate 在任务增删改、移动成功后调用。 +func (c *BoardCache) Invalidate(ctx context.Context, boardID int64) error { + return c.rdb.Del(ctx, key(boardID)).Err() +} +``` + +- 面试常问:为什么删缓存而不是更新缓存;“先删缓存再更新数据库”会有什么问题(并发读把旧值写回缓存);延迟双删是什么。 + +### 要建的文件 + +- `internal/cache/board_cache.go`;`task_service` 的 `List` 走缓存,写操作后 `Invalidate`。 + +### 运行方式 + +```bash +go get github.com/redis/go-redis/v9 golang.org/x/sync/singleflight +go build ./... +``` + +### 完成标准 + +- 第二次请求命中缓存(日志能看出来);停掉 Redis 后接口照常返回。 + +--- + +## 第 4 天:限流和分布式锁 + +### 学习内容 + +**1. 固定窗口限流(Lua 保证原子性)** + +- 说明:防暴力破解登录、防刷接口、保护下游(以后调大模型接口也要限流,有额度和费用)。“计数 +1”和“首次设置过期”必须原子执行,否则进程在两步之间崩溃会留下永不过期的 key;用 Lua 脚本在 Redis 里一次执行。其他算法:滑动窗口(更平滑)、令牌桶(允许一定突发),能讲清思路即可。 +- 案例: + +```go +// 文件:internal/ratelimit/limiter.go +package ratelimit + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" +) + +var fixedWindow = redis.NewScript(` +local n = redis.call('INCR', KEYS[1]) +if n == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end +return n`) + +type Limiter struct{ rdb *redis.Client } + +func New(rdb *redis.Client) *Limiter { return &Limiter{rdb: rdb} } + +// Allow 返回本次是否放行。Redis 出错时放行(fail-open),是否这样做要按业务决定。 +func (l *Limiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, error) { + n, err := fixedWindow.Run(ctx, l.rdb, []string{"rl:" + key}, window.Milliseconds()).Int() + if err != nil { + return true, err + } + return n <= limit, nil +} +``` + +- 用法:登录接口按“IP + 邮箱”每分钟 5 次,超出返回 429 和 `Retry-After` 响应头(写一个 Gin 中间件包装它)。 + +**2. 分布式锁** + +- 说明:多个进程(比如部署了两个后端实例)要保证同一时间只有一个在做某件事(比如每天生成一次统计报表)。Redis 实现要点:`SET key 随机值 NX PX 过期时间` 加锁(不存在才设置,带过期防止死锁);释放时**只删自己的锁**(比较随机值后再删,也要用 Lua 保证原子)。业务执行时间可能超过过期时间,需要续期(看门狗)或保证任务本身幂等。 +- 案例: + +```go +// 文件:internal/lock/lock.go +package lock + +import ( + "context" + "crypto/rand" + "encoding/hex" + "time" + + "github.com/redis/go-redis/v9" +) + +var unlockScript = redis.NewScript(` +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0`) + +type Lock struct { + rdb *redis.Client + key string + token string +} + +// TryLock 拿到锁返回 (lock, true);被别人持有返回 (nil, false)。 +func TryLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, bool, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return nil, false, err + } + token := hex.EncodeToString(b) + ok, err := rdb.SetNX(ctx, "lock:"+key, token, ttl).Result() + if err != nil || !ok { + return nil, false, err + } + return &Lock{rdb: rdb, key: "lock:" + key, token: token}, true, nil +} + +func (l *Lock) Unlock(ctx context.Context) error { + return unlockScript.Run(ctx, l.rdb, []string{l.key}, l.token).Err() +} +``` + +- 面试常问:为什么释放锁要比较随机值;锁过期了业务还没做完怎么办;Redis 主从切换时锁可能丢失(了解 Redlock 的争议即可),强一致需求可以用数据库行锁或 etcd / ZooKeeper。 + +### 要建的文件 + +- `internal/ratelimit/limiter.go`、`internal/middleware/ratelimit.go`、`internal/lock/lock.go` + +### 运行方式 + +```bash +go build ./... +``` + +### 完成标准 + +- 连续错误登录 6 次,第 6 次返回 429;两个终端同时调用 `TryLock`,只有一个成功。 + +--- + +## 第 5 天:消息队列入门(Redis Streams),了解 RabbitMQ / Kafka + +### 学习内容 + +**1. 为什么要消息队列** + +- 说明:**解耦**(指派任务的接口不关心通知怎么发)、**异步**(接口快速返回,耗时工作交给后台)、**削峰**(高峰时先排队)。代价:系统更复杂,消息可能重复、可能延迟。 +- 核心概念:生产者、消费者、消费者组、确认(ACK)、重试、死信队列。大多数队列提供“至少一次投递”,所以**消费者必须幂等**(同一条消息处理两次结果一样,常用唯一约束或“已处理 ID”记录)。 + +**2. Redis Streams 实战** + +- 说明:Redis 5 起的 Stream 类型支持消费者组和 ACK,小项目不用额外部署中间件。`XADD` 发消息,`XREADGROUP` 按组读取,`XACK` 确认;没确认的消息留在待处理列表(PEL)里,消费者挂了可以用 `XAUTOCLAIM` 认领。 +- 案例:“任务被指派”事件 → worker 写通知。 + +```go +// 文件:internal/queue/stream.go +package queue + +import ( + "context" + "errors" + "log/slog" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +func Publish(ctx context.Context, rdb *redis.Client, stream string, body []byte) (string, error) { + return rdb.XAdd(ctx, &redis.XAddArgs{Stream: stream, Values: map[string]any{"body": body}}).Result() +} + +type Handler func(ctx context.Context, id string, body []byte) error + +// Consume 持续消费,直到 ctx 被取消。处理成功才 ACK;失败的消息留在 PEL 里等待重试。 +func Consume(ctx context.Context, rdb *redis.Client, stream, group, consumer string, h Handler) error { + err := rdb.XGroupCreateMkStream(ctx, stream, group, "0").Err() + if err != nil && !strings.Contains(err.Error(), "BUSYGROUP") { // 组已存在不算错 + return err + } + for ctx.Err() == nil { + res, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{ + Group: group, Consumer: consumer, + Streams: []string{stream, ">"}, // ">" 表示只读从未投递过的新消息 + Count: 10, Block: time.Second, + }).Result() + if errors.Is(err, redis.Nil) { + continue // 超时没有新消息 + } + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + for _, s := range res { + for _, m := range s.Messages { + body, _ := m.Values["body"].(string) + if err := h(ctx, m.ID, []byte(body)); err != nil { + slog.Warn("处理失败,稍后重试", "id", m.ID, "err", err) + continue + } + rdb.XAck(ctx, stream, group, m.ID) + } + } + } + return nil +} +``` + +```go +// 文件:internal/queue/stream_test.go +package queue + +import ( + "context" + "os" + "sync" + "testing" + "time" + + "github.com/redis/go-redis/v9" +) + +func TestPublishConsumeIdempotent(t *testing.T) { + url := os.Getenv("REDIS_URL") + if url == "" { + t.Skip("未设置 REDIS_URL,跳过") + } + opt, _ := redis.ParseURL(url) + rdb := redis.NewClient(opt) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stream := "test:assigned:" + time.Now().Format("150405.000") + defer rdb.Del(context.Background(), stream) + + // 同一个事件发两次,模拟“至少一次投递”造成的重复 + for i := 0; i < 2; i++ { + if _, err := Publish(ctx, rdb, stream, []byte(`{"eventId":"e1","taskId":7}`)); err != nil { + t.Fatal(err) + } + } + + var mu sync.Mutex + processed := map[string]bool{} // 真实项目里用数据库唯一约束 event_id 实现幂等 + count := 0 + go Consume(ctx, rdb, stream, "notify", "c1", func(_ context.Context, _ string, body []byte) error { + mu.Lock() + defer mu.Unlock() + count++ + processed[string(body)] = true + if count == 2 { + cancel() + } + return nil + }) + <-ctx.Done() + if count != 2 || len(processed) != 1 { + t.Fatalf("收到 %d 条,去重后 %d 条", count, len(processed)) + } +} +``` + +**3. RabbitMQ 和 Kafka(只学概念)** + +| | Redis Streams | RabbitMQ | Kafka | +| --- | --- | --- | --- | +| 定位 | 轻量,复用已有 Redis | 通用消息中间件,路由灵活 | 高吞吐的分布式日志,适合事件流和大数据 | +| 核心概念 | stream、消费者组、PEL | 交换机(direct/topic/fanout)、队列、路由键、ACK、死信 | topic、分区、偏移量、消费者组、副本 | +| 消息保留 | 按长度或手动裁剪 | 消费确认后删除 | 按时间或大小保留,可回放 | +| 适合 | 小项目、简单异步任务 | 业务解耦、复杂路由、延迟和重试 | 日志收集、埋点、海量事件 | + +- 面试常问:如何保证消息不丢(生产者确认、持久化、消费成功再 ACK);如何处理重复消费(幂等);如何保证顺序(同一业务 key 发到同一分区 / 队列)。 + +**4. gRPC(只学概念)** + +- 说明:微服务之间常用 gRPC:用 `.proto` 文件定义接口和消息,生成多语言代码,基于 HTTP/2 和 Protobuf 二进制编码,比 JSON 接口更快、类型更严格;对外给浏览器的接口一般仍用 REST。读一遍官方 Go 快速入门(https://grpc.io/docs/languages/go/quickstart/),能说清 REST 和 gRPC 的取舍即可。 + +### 要建的文件 + +- `internal/queue/stream.go`、`stream_test.go`;`task_service` 在负责人变化时 `Publish`;`cmd/worker/main.go` 调用 `Consume`,写 `notifications` 表(`event_id` 唯一约束做幂等,`INSERT ... ON CONFLICT DO NOTHING`)。 + +### 运行方式 + +```bash +REDIS_URL=redis://localhost:6379/0 go test ./internal/queue -v +go run ./cmd/server # 一个终端 +go run ./cmd/worker # 另一个终端 +``` + +### 完成标准 + +- 停掉 worker 后指派任务,再启动 worker,通知能补上;重复消息只产生一条通知。 + +--- + +## 第 6 天:Docker、Compose、Nginx + +### 学习内容 + +**1. Dockerfile 多阶段构建** + +- 说明:镜像是打包好的运行环境,容器是镜像的运行实例。多阶段构建:第一阶段用带编译器的镜像编译,第二阶段只拷贝编译好的二进制,镜像小、不含源码,并且用非 root 用户运行。 +- 案例: + +```dockerfile +# 文件:Dockerfile +FROM golang:1.24 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download # 依赖不变时这一层走缓存 +COPY . . +RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server && \ + CGO_ENABLED=0 go build -o /out/worker ./cmd/worker + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/ /app/ +USER nonroot +EXPOSE 8080 +ENTRYPOINT ["/app/server"] +``` + +(Go 版本号按你本地 `go version` 改;国内拉取镜像慢可以配置镜像加速。) + +**2. Docker Compose 编排多个服务** + +- 说明:一个文件描述所有服务、网络和卷;服务之间用服务名互相访问(`postgres:5432`);`depends_on` + 健康检查控制启动顺序;配置用 `.env` 注入。 +- 案例: + +```yaml +# 文件:docker-compose.yml +services: + postgres: + image: postgres:16 + environment: { POSTGRES_USER: board, POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}", POSTGRES_DB: board } + volumes: [pgdata:/var/lib/postgresql/data] + healthcheck: { test: ["CMD-SHELL", "pg_isready -U board"], interval: 5s, retries: 10 } + restart: unless-stopped + redis: + image: redis:7 + restart: unless-stopped + migrate: + image: migrate/migrate + volumes: [./db/migrations:/migrations] + command: ["-path", "/migrations", "-database", "postgres://board:${POSTGRES_PASSWORD}@postgres:5432/board?sslmode=disable", "up"] + depends_on: { postgres: { condition: service_healthy } } + api: + build: . + environment: + STORE: postgres + DATABASE_URL: postgres://board:${POSTGRES_PASSWORD}@postgres:5432/board?sslmode=disable + REDIS_URL: redis://redis:6379/0 + JWT_SECRET: ${JWT_SECRET} + depends_on: + migrate: { condition: service_completed_successfully } + redis: { condition: service_started } + restart: unless-stopped + worker: + build: . + entrypoint: ["/app/worker"] + environment: + DATABASE_URL: postgres://board:${POSTGRES_PASSWORD}@postgres:5432/board?sslmode=disable + REDIS_URL: redis://redis:6379/0 + depends_on: [api] + restart: unless-stopped + nginx: + image: nginx:1.27 + ports: ["80:80"] + volumes: + - ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./web-dist:/usr/share/nginx/html:ro # 第 7 周前端构建产物放这里 + depends_on: [api] + restart: unless-stopped +volumes: + pgdata: +``` + +**3. Nginx 反向代理** + +- 说明:Nginx 对外只暴露 80/443,静态文件直接返回,`/api/` 转发给后端;单页应用的前端路由要回退到 `index.html`。以后代理流式接口(SSE)时要关闭缓冲。 +- 案例: + +```nginx +# 文件:deploy/nginx.conf +server { + listen 80; + root /usr/share/nginx/html; + + location /api/ { + proxy_pass http://api:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_buffering off; # 流式接口需要 + } + + location / { + try_files $uri $uri/ /index.html; # 单页应用路由回退 + } +} +``` + +- Linux 常用命令(部署和排查要用):`ss -lntp`(看端口)、`df -h` / `free -h`(磁盘和内存)、`journalctl -u docker`、`docker compose logs -f api`、`docker stats`、`ufw allow 80`。 + +**易错点 / 面试常问(补充)** + +- 易错点:Dockerfile 先复制 `go.mod` / `go.sum` 再下载依赖、最后复制源码,这样改代码不会让依赖层缓存失效。 +- 易错点:容器里服务要监听 `0.0.0.0` 而不是 `127.0.0.1`,否则宿主机访问不到;Compose 里服务之间用服务名访问(如 `postgres:5432`),不是 `localhost`。 +- 易错点:`depends_on` 只保证启动顺序,不保证数据库已经可以连接,要配合 `healthcheck` 和 `condition: service_healthy`。 +- 面试常问:镜像和容器的区别;多阶段构建为什么能让镜像变小;Nginx 反向代理做了什么(同域转发、静态文件、HTTPS、负载均衡)。 + +### 要建的文件 + +- `Dockerfile`、`.dockerignore`、`docker-compose.yml`、`deploy/nginx.conf`、`.env.example`(`POSTGRES_PASSWORD`、`JWT_SECRET`) + +### 运行方式 + +```bash +cp .env.example .env # 改掉密码和密钥 +docker compose up -d --build +curl -s localhost/api/health +``` + +### 完成标准 + +- 一台只装了 Docker 的机器,一条命令跑起整套服务;后端镜像不含源码。 + +--- + +## 第 7 天:部署到服务器 + CI + 收尾 + +### 学习内容 + +**1. 部署** + +- 说明:最小流程:服务器装 Docker → 拉代码 → 配 `.env`(权限 600)→ `docker compose up -d --build`;更新时 `git pull` 再执行一次。数据库定期 `pg_dump` 备份。 +- 案例:`deploy/deploy.md` 写成可以照抄的步骤清单。 + +**2. CI(持续集成)** + +- 说明:每次推送自动跑检查和测试,坏代码进不了主分支。CD(持续部署)是测试通过后自动部署,本计划先手动部署。不同平台配置位置不同:GitHub Actions 是 `.github/workflows/`,Gitea Actions 写法和它基本兼容,GitLab 是 `.gitlab-ci.yml`。先确认你的 `git.gozeros.com` 是哪种平台、是否开启了 CI。 +- 案例(GitHub / Gitea Actions 写法): + +```yaml +# 文件:.github/workflows/ci.yml +name: ci +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: { POSTGRES_USER: test, POSTGRES_PASSWORD: test, POSTGRES_DB: test } + ports: ["5432:5432"] + redis: + image: redis:7 + ports: ["6379:6379"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version-file: go.mod } + - run: go vet ./... + - run: go install -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@latest + - run: migrate -path db/migrations -database "postgres://test:test@localhost:5432/test?sslmode=disable" up + - run: go test ./... -race + env: + TEST_DATABASE_URL: postgres://test:test@localhost:5432/test?sslmode=disable + REDIS_URL: redis://localhost:6379/0 + - run: docker build -t task-board . +``` + +**易错点 / 面试常问(补充)** + +- 易错点:服务器上的密钥放在 `.env` 或 CI 的密钥配置里,不写进仓库和镜像;部署后检查 `docker compose ps` 和日志,确认迁移执行成功再放流量。 +- 易错点:CI 里的测试要用 CI 提供的数据库服务(或跳过集成测试),不要连生产库。 +- 面试常问:CI 和 CD 的区别;一次发布出问题怎么回滚(保留上一个镜像标签,`docker compose` 切回去;数据库迁移要向后兼容才能安全回滚)。 + +### 要建的文件 + +- `deploy/deploy.md`、CI 配置;`task-board/README.md`:功能、架构图(mermaid:Nginx → API → PostgreSQL / Redis,API → Stream → Worker)、接口表、权限设计、缓存 / 限流 / 锁 / 队列的取舍、本地运行和部署步骤、已知不足。 +- 提交推送,确认 `.env` 没有提交。 + +### 运行方式 + +在服务器上按 `deploy.md` 执行一遍。 + +### 完成标准 + +- 服务器上能访问 `/api/health`;能用 3 分钟讲清项目一:分层、权限、缓存、限流、分布式锁、消息队列与幂等、部署。 + +--- + +## 本周自查清单 + +- [ ] 权限在后端 service 层判断,有测试;能讲清 401 / 403 / 404 的选择 +- [ ] 会用事务处理“多步写入必须一起成功” +- [ ] 会用 Redis 五种结构;能讲清旁路缓存、穿透 / 击穿 / 雪崩、降级 +- [ ] 会写 Lua 限流和分布式锁,知道锁的坑 +- [ ] 会用 Redis Streams 做消费者组,能讲清“至少一次 + 幂等”;能对比 RabbitMQ 和 Kafka;知道 gRPC 是什么 +- [ ] 会写多阶段 Dockerfile、Compose、Nginx 反向代理,会基本的 Linux 排查命令 +- [ ] 有 CI 配置,项目一已部署 +- [ ] 在总纲进度记录里勾掉第 3 周和“第一段 Go 全栈基础” diff --git a/plan/week04-auth-project1.md b/plan/week04-auth-project1.md deleted file mode 100644 index 7111937..0000000 --- a/plan/week04-auth-project1.md +++ /dev/null @@ -1,347 +0,0 @@ -# 转岗计划 · 第 4 周学习计划(项目一 · 上:注册登录 + JWT + 权限 + 前端) - -> 所属阶段:第一段 Go 全栈基础。项目一分两周做:本周完成功能主体,第 5 周加 Redis、消息队列和 Docker 部署(`week05-project1-redis-docker.md`)。总纲见同目录 `README.md`,上周计划 `week03-postgresql.md`。 - -目标:做出第一个能写进简历的全栈项目“团队任务看板”的功能主体:用户注册登录,创建看板、邀请成员,看板里的任务按“待办 / 进行中 / 已完成”分三列,只有看板成员能看和改,只有看板所有者能管理成员。 - -前置条件:第 3 周的 `todo-api` 已经能用 PostgreSQL 存储,迁移、sqlc、事务都跑通。 - -做法:新建 `task-board/` 目录,把 `todo-api/backend` 的骨架(`docker-compose.yml`、`Makefile`、`sqlc.yaml`、`internal/config`、`internal/middleware`、`handler/response.go`、`main.go` 的启动和优雅关机代码)复制过去再改,业务部分重新写。 - -本周比前三周多了一个“service 层”:handler 只负责解析请求和返回响应,权限判断和业务规则放在 service 里,方便测试,也是第 2、3 个月写 Agent 服务时会沿用的分层方式。 - -参考资料: -- bcrypt:https://pkg.go.dev/golang.org/x/crypto/bcrypt -- golang-jwt:https://github.com/golang-jwt/jwt -- JWT 介绍:https://jwt.io/introduction - ---- - -## 最终项目结构 - -``` -task-board/ -├── README.md # 项目说明(作品集用) -├── backend/ -│ ├── go.mod # module task-board -│ ├── main.go -│ ├── Makefile -│ ├── docker-compose.yml -│ ├── .env.example # DATABASE_URL、JWT_SECRET、JWT_TTL -│ ├── sqlc.yaml -│ ├── api.http -│ ├── db/ -│ │ ├── migrations/ -│ │ │ ├── 000001_init.up.sql -│ │ │ └── 000001_init.down.sql -│ │ ├── queries/ -│ │ │ ├── users.sql -│ │ │ ├── boards.sql -│ │ │ └── tasks.sql -│ │ └── seed.sql # 演示数据 -│ └── internal/ -│ ├── config/config.go -│ ├── db/ # sqlc 生成 -│ ├── auth/ -│ │ ├── password.go # bcrypt -│ │ ├── password_test.go -│ │ ├── jwt.go # 签发和解析令牌 -│ │ └── jwt_test.go -│ ├── service/ -│ │ ├── errors.go # 业务错误:未找到、无权限、冲突 -│ │ ├── user_service.go -│ │ ├── board_service.go -│ │ ├── task_service.go -│ │ └── task_service_test.go -│ ├── handler/ -│ │ ├── response.go -│ │ ├── errors.go # 业务错误 → HTTP 状态码 -│ │ ├── auth_handler.go -│ │ ├── board_handler.go -│ │ ├── task_handler.go -│ │ └── handler_test.go # 权限相关接口测试 -│ ├── middleware/ -│ │ ├── logger.go -│ │ ├── cors.go -│ │ ├── recovery.go -│ │ └── auth.go # RequireAuth -│ └── router/router.go -└── frontend/ - ├── .env.development - └── src/ - ├── api/ - │ ├── client.ts # 统一请求:带令牌、处理 401 - │ ├── auth.ts - │ ├── boards.ts - │ └── tasks.ts - ├── types/index.ts - ├── store/auth.ts # 登录状态(React 用 Context 或 zustand,Vue 用 Pinia) - ├── router.tsx # 路由和登录守卫 - ├── pages/ - │ ├── LoginPage.tsx - │ ├── RegisterPage.tsx - │ ├── BoardsPage.tsx - │ └── BoardDetailPage.tsx - └── components/ - ├── TaskColumn.tsx - ├── TaskCard.tsx - ├── TaskEditor.tsx - └── MemberPanel.tsx -``` - -Vue 的话把 `.tsx` 换成 `.vue`,`router.tsx` 换成 `router.ts`。 - -## 数据库表 - -``` -users - id BIGSERIAL PRIMARY KEY - email TEXT NOT NULL UNIQUE - name TEXT NOT NULL - password_hash TEXT NOT NULL - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - -boards - id BIGSERIAL PRIMARY KEY - name TEXT NOT NULL - owner_id BIGINT NOT NULL REFERENCES users(id) - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - -board_members - board_id BIGINT REFERENCES boards(id) ON DELETE CASCADE - user_id BIGINT REFERENCES users(id) ON DELETE CASCADE - role TEXT NOT NULL CHECK (role IN ('owner', 'member')) - joined_at TIMESTAMPTZ NOT NULL DEFAULT now() - PRIMARY KEY (board_id, user_id) - -tasks - id BIGSERIAL PRIMARY KEY - board_id BIGINT NOT NULL REFERENCES boards(id) ON DELETE CASCADE - title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 100) - description TEXT NOT NULL DEFAULT '' - status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'doing', 'done')) - position INT NOT NULL DEFAULT 0 -- 同一列里的排序 - assignee_id BIGINT REFERENCES users(id) ON DELETE SET NULL - created_by BIGINT NOT NULL REFERENCES users(id) - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - -索引:tasks(board_id, status, position)、board_members(user_id) -``` - -## 接口设计 - -除注册和登录外,所有接口都需要请求头 `Authorization: Bearer `。 - -| 方法 | 路径 | 说明 | 权限 | 成功状态码 | -| --- | --- | --- | --- | --- | -| POST | `/api/auth/register` | 注册 `{email, name, password}` | 公开 | 201 | -| POST | `/api/auth/login` | 登录,返回 `{token, user}` | 公开 | 200 | -| GET | `/api/me` | 当前用户 | 登录 | 200 | -| GET | `/api/boards` | 我参与的看板 | 登录 | 200 | -| POST | `/api/boards` | 新建看板,创建者自动成为 owner | 登录 | 201 | -| GET | `/api/boards/:boardId` | 看板详情 + 成员列表 | 成员 | 200 | -| POST | `/api/boards/:boardId/members` | 按邮箱添加成员 | owner | 201 | -| DELETE | `/api/boards/:boardId/members/:userId` | 移除成员(不能移除 owner) | owner | 204 | -| GET | `/api/boards/:boardId/tasks` | 任务列表,按状态分组 | 成员 | 200 | -| POST | `/api/boards/:boardId/tasks` | 新建任务 | 成员 | 201 | -| PATCH | `/api/boards/:boardId/tasks/:taskId` | 修改标题、描述、负责人 | 成员 | 200 | -| POST | `/api/boards/:boardId/tasks/:taskId/move` | 移动到某列某位置 `{status, position}` | 成员 | 200 | -| DELETE | `/api/boards/:boardId/tasks/:taskId` | 删除任务 | 创建者或 owner | 204 | - -错误约定:未登录或令牌无效 401;登录了但不是成员,统一返回 404(不暴露看板是否存在);是成员但没有 owner 权限 403;邮箱已注册 409;参数错误 400。 - ---- - -## 第 1 天:项目初始化和表设计 - -**学习内容** -- 从需求推出表结构:先列出“谁、有什么、能做什么”,再画表。 -- 权限放在哪张表:用户和看板是多对多,角色存在关联表 `board_members` 上。 -- 复习第 3 周的迁移和 sqlc。 - -**要建的文件** -- 复制骨架到 `task-board/backend/`,`go mod init task-board`,改掉 import 路径。 -- `docker-compose.yml`:数据库名、用户名改成 `board`;为了不和 `todo-api` 冲突,端口映射用 `5433:5432`。 -- `db/migrations/000001_init.up.sql`:按上面“数据库表”建 4 张表和索引。 -- `db/migrations/000001_init.down.sql`:按依赖顺序反向删除。 -- `db/queries/users.sql`:`CreateUser :one`、`GetUserByEmail :one`、`GetUserByID :one`。 -- `db/queries/boards.sql`:`CreateBoard :one`、`AddBoardMember :exec`、`RemoveBoardMember :execrows`、`GetMemberRole :one`(按 `board_id, user_id` 查角色)、`ListBoardsByUser :many`(`JOIN board_members`)、`ListBoardMembers :many`(`JOIN users`)。 -- `db/queries/tasks.sql`:`ListTasksByBoard :many`(按 `status, position` 排序)、`GetTask :one`(条件同时带 `board_id`,防止跨看板访问)、`CreateTask :one`、`UpdateTask :one`、`ShiftPositions :exec`、`MoveTask :one`、`DeleteTask :execrows`。 -- 画一张 ER 图(mermaid `erDiagram`),先写在 `task-board/README.md` 里。 - -**运行方式**:`make db-up && make migrate-up && make sqlc && go build ./...` - -**完成标准**:能说清楚为什么 `GetTask` 的条件里要带 `board_id`,以及为什么角色放在 `board_members` 而不是 `users` 上。 - ---- - -## 第 2 天:密码加密、注册和登录 - -**学习内容** -- 为什么密码只能存哈希,为什么用 bcrypt 这种“故意慢”的算法,而不是 MD5、SHA256。 -- JWT 的结构:头部、载荷、签名;载荷只是 Base64 编码,**不加密**,不能放敏感信息。 -- 令牌过期时间;签名密钥从环境变量读,不能写在代码里。 -- 依赖:`go get golang.org/x/crypto/bcrypt github.com/golang-jwt/jwt/v5`。 - -**要建的文件** -- `internal/auth/password.go`: - - `func HashPassword(plain string) (string, error)` - - `func CheckPassword(hash, plain string) bool` -- `internal/auth/jwt.go`: - - ``type Claims struct { UserID int64 `json:"uid"`; jwt.RegisteredClaims }`` - - `type TokenManager struct { secret []byte; ttl time.Duration }` - - `func NewTokenManager(secret string, ttl time.Duration) *TokenManager` - - `func (m *TokenManager) Issue(userID int64) (string, error)`:签发 HS256 令牌,写入过期时间。 - - `func (m *TokenManager) Parse(token string) (int64, error)`:校验签名算法、过期时间,返回用户 ID。 -- `internal/auth/password_test.go`、`jwt_test.go`:正确密码通过、错误密码失败;过期令牌、被篡改的令牌、用别的密钥签的令牌都解析失败。 -- `internal/service/errors.go`:`ErrNotFound`、`ErrForbidden`、`ErrConflict`、`ErrInvalidCredentials`、`ErrValidation`。 -- `internal/service/user_service.go`: - - `func (s *UserService) Register(ctx, email, name, password string) (User, error)`:邮箱格式和密码长度(至少 8 位)校验,邮箱转小写,唯一约束冲突转成 `ErrConflict`。 - - `func (s *UserService) Login(ctx, email, password string) (token string, user User, err error)`:邮箱不存在和密码错误都返回同一个 `ErrInvalidCredentials`,不让人试出哪些邮箱注册过。 -- `internal/handler/errors.go`:`func writeError(c *gin.Context, err error)`,用 `errors.Is` 把业务错误映射到 400 / 401 / 403 / 404 / 409。 -- `internal/handler/auth_handler.go`:`Register`、`Login`。 -- `internal/config/config.go`:新增 `JWTSecret`(必填,少于 32 个字符时启动报错)、`JWTTTL`(默认 2 小时)。 - -**运行方式**:`go test ./internal/auth -v`;`api.http` 里写好注册、重复注册、登录、错误密码登录四个请求。 - -**完成标准**:能说清楚 JWT 和服务端 Session 各自的优缺点,以及“JWT 签发后很难提前作废”这个问题一般怎么缓解(短过期时间 + 刷新令牌,本周不实现)。 - ---- - -## 第 3 天:鉴权中间件和当前用户 - -**学习内容** -- Gin 中间件里校验令牌,把用户 ID 放进 `gin.Context`,后面的 handler 直接取。 -- 401 和 403 的区别。 -- 前端令牌存哪:`localStorage`(简单,但怕 XSS)和 `httpOnly` Cookie(防 XSS,但要处理 CSRF)。本项目用 `Authorization` 请求头 + `localStorage`,在 README 里写清楚这个取舍。 - -**要建的文件** -- `internal/middleware/auth.go`: - - `func RequireAuth(tm *auth.TokenManager) gin.HandlerFunc`:没有请求头、格式不对、令牌无效都返回 401 并 `c.Abort()`。 - - `func CurrentUserID(c *gin.Context) int64`:从上下文取用户 ID。 -- `internal/handler/auth_handler.go`:新增 `Me`,返回当前用户(不返回 `password_hash`,用单独的响应结构体)。 -- `internal/router/router.go`: - - `/api/auth/*` 不加鉴权。 - - 其余接口挂在 `authed := api.Group("", middleware.RequireAuth(tm))` 下。 -- 更新 `api.http`:用变量保存登录得到的令牌,后续请求自动带上。 - -**运行方式**:`go run .`,不带令牌访问 `/api/me` 返回 401,带上令牌返回当前用户。 - -**完成标准**:能画出“请求 → RequireAuth → handler → service → 数据库”的流程,说清楚每一层负责什么。 - ---- - -## 第 4 天:看板和成员权限 - -**学习内容** -- 权限判断放在 service 层:每个操作先查当前用户在这个看板的角色,再决定能不能做。 -- 不是成员时返回 404 而不是 403 的原因:不暴露看板是否存在。 -- 创建看板要用事务:插入看板和插入 owner 成员必须一起成功。 - -**要建的文件** -- `internal/service/board_service.go`: - - `func (s *BoardService) requireRole(ctx, boardID, userID int64, need string) (role string, err error)`:查不到成员关系返回 `ErrNotFound`;需要 owner 但只是 member 返回 `ErrForbidden`。 - - `Create(ctx, userID, name)`:事务里插入看板 + 把自己加为 owner。 - - `ListMine(ctx, userID)`、`Get(ctx, userID, boardID)`(含成员列表)。 - - `AddMember(ctx, userID, boardID, email)`:仅 owner;邮箱不存在返回 `ErrNotFound`;已是成员返回 `ErrConflict`。 - - `RemoveMember(ctx, userID, boardID, targetUserID)`:仅 owner;不能移除 owner 自己。 -- `internal/handler/board_handler.go`:对应 5 个接口,路径参数解析失败返回 400。 -- 更新 `router.go` 和 `api.http`:准备两个账号 A、B,测“A 建看板 → B 看不到 → A 添加 B → B 能看到 → B 尝试添加成员得到 403”。 - -**完成标准**:能说清楚“为什么权限判断不能只在前端做”,以及事务在 `Create` 里保证了什么。 - ---- - -## 第 5 天:任务接口和权限测试 - -**学习内容** -- 同一列内排序:`position` 整数,移动时把目标位置之后的任务整体后移一位,放在一个事务里完成。 -- 用 `httptest` 测权限,比手动点更可靠。 - -**要建的文件** -- `internal/service/task_service.go`: - - `List(ctx, userID, boardID)`:返回 `map[string][]Task`(三列)。 - - `Create(ctx, userID, boardID, input)`:负责人必须是看板成员,否则 `ErrValidation`;新任务放在“待办”列末尾。 - - `Update(ctx, userID, boardID, taskID, input)`:字段用指针区分“没传”和“传了空值”(和第 2 周一样)。 - - `Move(ctx, userID, boardID, taskID, status, position)`:事务里 `ShiftPositions` + `MoveTask`。 - - `Delete(ctx, userID, boardID, taskID)`:只有任务创建者或看板 owner 能删,否则 `ErrForbidden`。 -- `internal/handler/task_handler.go`:对应 5 个接口。 -- `internal/service/task_service_test.go`:连测试数据库(没设 `TEST_DATABASE_URL` 就跳过),测负责人校验、移动后同列 `position` 连续不重复。 -- `internal/handler/handler_test.go`: - - `setupApp(t)`:连测试库、清表、组装完整路由,返回 `*gin.Engine`。 - - `registerAndLogin(t, app, email) string`:辅助函数,返回令牌。 - - 测试用例至少包括:未登录 401;非成员访问看板 404;member 添加成员 403;member 删除别人的任务 403;owner 删除任务 204;跨看板用别的看板的 `taskId` 访问返回 404。 - -**运行方式**:`make test-integration` - -**完成标准**:权限测试全部通过;能说清楚为什么 `Move` 要放在事务里。 - ---- - -## 第 6 天:前端 - -**学习内容** -- 统一请求封装:每个请求自动带令牌,收到 401 时清掉登录状态并跳到登录页。 -- 路由守卫:没登录访问看板页时跳到登录页。 -- 乐观更新:拖动任务时先改界面,接口失败再回滚。 - -**要建的文件** -- 用 Vite 新建 `frontend/`(同第 2 周),`.env.development` 写 `VITE_API_BASE=http://localhost:8080/api`。 -- `src/types/index.ts`:`User`、`Board`、`Member`、`Task`、`TaskStatus`(`'todo' | 'doing' | 'done'`)。 -- `src/api/client.ts`:`request(method, path, body?)`,处理统一响应格式和 401。 -- `src/api/auth.ts`、`boards.ts`、`tasks.ts`:一个接口一个函数。 -- `src/store/auth.ts`:保存令牌和当前用户,提供 `login`、`logout`,刷新页面后从 `localStorage` 恢复并调用 `/api/me` 校验。 -- `src/router.tsx`:`/login`、`/register`、`/boards`、`/boards/:id`,后两个需要登录。 -- `src/pages/LoginPage.tsx`、`RegisterPage.tsx`:表单、错误提示。 -- `src/pages/BoardsPage.tsx`:我的看板列表 + 新建看板。 -- `src/pages/BoardDetailPage.tsx`:三列看板 + 成员面板。 -- `src/components/TaskColumn.tsx`、`TaskCard.tsx`:卡片可以拖到别的列(用 HTML5 拖拽或 `@dnd-kit`;时间不够就先做“移到下一列”按钮)。 -- `src/components/TaskEditor.tsx`:编辑标题、描述、负责人(下拉选成员)。 -- `src/components/MemberPanel.tsx`:成员列表;owner 能看到“按邮箱添加”和“移除”。 - -**运行方式**:后端 `go run .`,前端 `npm run dev`,开两个浏览器(或一个无痕窗口)登录两个账号测试。 - -**完成标准**:两个账号完整走一遍:注册 → 建看板 → 加成员 → 两人各自建任务、拖动、指派 → member 删除别人的任务时看到无权限提示。 - ---- - -## 第 7 天:收尾,写成作品 - -**学习内容** -- 作品集 README 怎么写:别人 30 秒内知道它是什么,5 分钟内能跑起来。 -- 演示数据:让看的人不用自己注册就能体验。 - -**要建的文件和修改** -- `db/seed.sql`:两个演示账号(密码哈希用 `HashPassword` 生成后写入)、一个看板、十来条任务;`Makefile` 增加 `seed` 目标。 -- `task-board/README.md`: - - 一句话介绍 + 功能列表 + 截图。 - - 技术栈。 - - ER 图、接口表(复制上面的表)。 - - 架构说明:handler / service / store 分层,权限在哪一层判断。 - - 安全说明:密码哈希、JWT 过期、为什么非成员返回 404、令牌存储的取舍。 - - 本地运行步骤:`docker compose up -d` → `make migrate-up` → `make seed` → `go run .` → `npm run dev`。 - - 已知不足和下一步(刷新令牌、实时同步),诚实写出来。缓存、通知和部署下周补上。 -- 提交并推送到 `zhuangang` 仓库(路径 `task-board/`),确认 `.env` 和密钥没有提交。 - -**完成标准** -- `go vet ./...` 没有警告,`gofmt -l .` 没有输出。 -- `go test ./... -race` 和 `make test-integration` 都通过。 -- 在一个新目录里 `git clone` 下来,按 README 能从零跑通。 -- 能用 3 分钟讲清楚这个项目:做了什么、分层、权限怎么保证、遇到过什么问题。 -- 在总纲 `README.md` 的进度记录里勾掉第 4 周。 - ---- - -## 本周自查清单 - -- [ ] 能说清楚 bcrypt 为什么适合存密码 -- [ ] 能说清楚 JWT 的结构、优缺点,以及载荷不加密 -- [ ] 会写鉴权中间件,分得清 401 和 403 -- [ ] 权限判断放在后端 service 层,并有接口测试覆盖 -- [ ] 会用事务处理“多步写入必须一起成功”的场景 -- [ ] 前端有统一请求封装、401 处理和路由守卫 -- [ ] 项目一 README 完整,别人能从零跑起来 -- [ ] 能用 3 分钟讲清楚项目一 - -下周预告:第 5 周给项目一加 Redis 缓存和限流、用 Redis Streams 做一个简单的消息队列(任务指派通知),再用 Docker Compose 打包部署到你的 Ubuntu 服务器,见 `week05-project1-redis-docker.md`。 diff --git a/plan/week05-project1-redis-docker.md b/plan/week05-project1-redis-docker.md deleted file mode 100644 index 5e20c4d..0000000 --- a/plan/week05-project1-redis-docker.md +++ /dev/null @@ -1,225 +0,0 @@ -# 转岗计划 · 第 5 周学习计划(项目一 · 下:Redis 缓存与限流 + 消息队列入门 + Docker 部署) - -> 所属阶段:第一段 Go 全栈基础的最后一周。总纲见同目录 `README.md`,上周计划 `week04-auth-project1.md`,下周计划 `week06-python-basics.md`。 - -目标:给“团队任务看板”补上招聘里常见的三样东西:**Redis**(缓存、限流)、**消息队列**(用 Redis Streams 入门,了解 RabbitMQ 的基本概念)、**Docker 部署**(Docker Compose 一键启动,部署到自己的 Ubuntu 服务器,加一条 CI 流水线)。 - -原则:这三样都**浅学够用**。Go 全栈部分到本周结束,之后的主线是 Python 和 AI 应用。本周不追求高并发、集群这些深入话题。 - -前置条件:第 4 周的项目一功能完成,测试通过。 - -参考资料: -- Redis 命令参考(中文):https://redis.com.cn/commands.html -- go-redis:https://redis.uptrace.dev/zh/ -- Redis Streams 介绍:https://redis.io/docs/latest/develop/data-types/streams/ -- Docker 文档:https://docs.docker.com/get-started/ -- RabbitMQ 入门教程:https://www.rabbitmq.com/tutorials - ---- - -## 最终项目结构(只列新增和修改) - -``` -task-board/ -├── docker-compose.yml # 全套:postgres、redis、backend、frontend(nginx) -├── docker-compose.dev.yml # 开发用:只起 postgres 和 redis -├── .env.example # 部署用的环境变量示例 -├── deploy/ -│ ├── nginx.conf # 前端静态文件 + /api 反向代理到后端 -│ └── deploy.md # 部署到 Ubuntu 服务器的步骤 -├── .ci/ # 按你的代码托管平台放到对应目录(见第 7 天) -│ └── ci.yml -├── backend/ -│ ├── Dockerfile # 多阶段构建 -│ ├── .dockerignore -│ ├── cmd/ -│ │ ├── server/main.go # 原来的 main.go 移到这里 -│ │ └── worker/main.go # 消息消费者(通知处理) -│ └── internal/ -│ ├── cache/ -│ │ ├── redis.go # 连接和通用 Get/Set -│ │ ├── board_cache.go # 看板任务列表缓存 -│ │ └── board_cache_test.go -│ ├── ratelimit/ -│ │ ├── limiter.go # 固定窗口限流 -│ │ └── limiter_test.go -│ ├── middleware/ -│ │ └── ratelimit.go # 登录接口限流中间件 -│ ├── queue/ -│ │ ├── stream.go # Redis Streams 生产和消费 -│ │ └── stream_test.go -│ └── notify/ -│ ├── events.go # 事件结构:任务被指派 -│ ├── store.go # 通知表读写 -│ └── handler.go # 消费者处理逻辑(幂等) -│ └── db/migrations/ -│ ├── 000002_notifications.up.sql -│ └── 000002_notifications.down.sql -└── frontend/ - ├── Dockerfile # 构建静态文件,用 nginx 提供 - └── src/components/NotificationBell.tsx -``` - ---- - -## 第 1 天:Redis 基础 - -**学习内容** -- Redis 是什么:内存里的键值数据库,常用来做缓存、计数、限流、会话、排行榜、简单队列。 -- 五种常用类型:String、Hash、List、Set、Sorted Set;过期时间 `EXPIRE` / `TTL`。 -- 用 `redis-cli` 动手,Go 里用 go-redis:`go get github.com/redis/go-redis/v9`。 - -**要建的文件** -- `docker-compose.dev.yml`:`postgres:16` + `redis:7`,Redis 端口 `6379`。原来 `backend/docker-compose.yml` 的数据库服务挪到这里。 -- `backend/internal/cache/redis.go`: - - `func NewRedis(ctx context.Context, url string) (*redis.Client, error)`:连接并 `Ping`。 - - `func GetJSON[T any](ctx, rdb, key string) (T, bool, error)`、`func SetJSON(ctx, rdb, key string, v any, ttl time.Duration) error`(顺便练一下 Go 泛型)。 -- `internal/config/config.go`:新增 `RedisURL`。 -- `backend/sql/practice/redis_cli.md`:记录你在 `redis-cli` 里敲过的命令和结果(每种类型至少 2 条)。 - -**运行方式**:`docker compose -f docker-compose.dev.yml up -d`,`docker compose -f docker-compose.dev.yml exec redis redis-cli` - -**完成标准**:能说出 Redis 为什么快、数据放内存的风险(重启丢数据,了解 RDB 和 AOF 两种持久化即可)。 - ---- - -## 第 2 天:缓存 - -**学习内容** -- 旁路缓存(Cache Aside):读时先查缓存,没有再查数据库并写回缓存;写时**先更新数据库,再删除缓存**。 -- 三个常见问题,面试常问:缓存穿透(查不存在的数据)、缓存击穿(热点 key 过期瞬间大量请求)、缓存雪崩(大量 key 同时过期)。各自的常用办法:缓存空值、互斥锁 / 单飞(singleflight)、过期时间加随机值。 - -**要建的文件** -- `internal/cache/board_cache.go`: - - `type BoardCache struct { rdb *redis.Client; ttl time.Duration }` - - `func (c *BoardCache) GetTasks(ctx, boardID int64) (map[string][]model.Task, bool, error)` - - `func (c *BoardCache) SetTasks(ctx, boardID int64, tasks map[string][]model.Task) error`:过期时间 = 基础时间 + 随机几十秒。 - - `func (c *BoardCache) Invalidate(ctx, boardID int64) error` - - key 统一格式:`board:{id}:tasks`。 -- 修改 `internal/service/task_service.go`:`List` 先查缓存;`Create`、`Update`、`Move`、`Delete` 成功后删除缓存。用 `golang.org/x/sync/singleflight` 合并同一看板的并发查询。 -- **Redis 不可用时降级**:缓存出错只记日志,继续查数据库,不让整个接口失败。 -- `internal/cache/board_cache_test.go`:没设 `TEST_REDIS_URL` 时跳过;测试读写、过期、删除。 - -**完成标准**:能画出旁路缓存的读写流程,能解释为什么是“删缓存”而不是“更新缓存”;停掉 Redis 后接口照常可用。 - ---- - -## 第 3 天:限流 - -**学习内容** -- 为什么要限流:防暴力破解登录、防刷接口、保护下游(以后调用大模型接口时同样要限流,因为有额度和费用)。 -- 固定窗口计数:`INCR key` + 首次设置 `EXPIRE`;用 Lua 脚本或 `MULTI` 保证两步一起执行。了解滑动窗口和令牌桶的思路即可。 - -**要建的文件** -- `internal/ratelimit/limiter.go`: - - `type Limiter struct { rdb *redis.Client }` - - `func (l *Limiter) Allow(ctx, key string, limit int, window time.Duration) (allowed bool, remaining int, err error)` -- `internal/ratelimit/limiter_test.go`:同一个 key 第 `limit+1` 次被拒绝;窗口过去后恢复。 -- `internal/middleware/ratelimit.go`:`func RateLimit(l *ratelimit.Limiter, keyFn func(*gin.Context) string, limit int, window time.Duration) gin.HandlerFunc`,超限返回 429,响应头带 `Retry-After`。 -- `router.go`:登录接口按“IP + 邮箱”限流(比如每分钟 5 次),其他接口按用户 ID 宽松限流。 - -**完成标准**:用 `api.http` 连续错误登录 6 次,第 6 次返回 429;能说出固定窗口在窗口边界的问题。 - ---- - -## 第 4 天:消息队列入门(Redis Streams) - -**学习内容** -- 消息队列解决什么问题:**解耦**(指派任务的接口不用关心通知怎么发)、**异步**(接口快速返回)、**削峰**。 -- 基本概念:生产者、消费者、消费者组、确认(ACK)、重试、死信、“至少一次”投递导致的**重复消费**——所以消费者必须**幂等**。 -- Redis Streams 命令:`XADD`、`XREADGROUP`、`XACK`、`XPENDING`、`XAUTOCLAIM`。 -- RabbitMQ 只了解概念(交换机、队列、路由键、确认),对比一下:Redis Streams 轻量,适合小项目;RabbitMQ / Kafka 是专门的消息中间件,可靠性和功能更强。本周不装 RabbitMQ,读完官方入门教程的前两篇即可。 - -**要建的文件** -- `db/migrations/000002_notifications.up.sql` / `.down.sql`:`notifications(id, user_id, event_id TEXT UNIQUE, type, payload JSONB, read_at, created_at)`。`event_id` 唯一约束用来做幂等。 -- `internal/notify/events.go`:`type TaskAssigned struct { EventID string; BoardID, TaskID, AssigneeID, ByUserID int64; At time.Time }`。 -- `internal/queue/stream.go`: - - `func Publish(ctx, rdb, stream string, v any) (string, error)`:`XADD`,消息体 JSON。 - - `func Consume(ctx, rdb, stream, group, consumer string, handle func(ctx, id string, body []byte) error) error`:创建消费者组(已存在就忽略)→ 循环 `XREADGROUP` → 处理成功才 `XACK`;启动时先用 `XAUTOCLAIM` 认领别的消费者挂掉后遗留的消息。 -- `internal/notify/store.go`:`InsertNotification`,用 `ON CONFLICT (event_id) DO NOTHING`。 -- `internal/notify/handler.go`:处理 `TaskAssigned` → 写通知表。 -- 修改 `task_service.go`:创建或修改任务时负责人有变化,就 `Publish` 一个 `TaskAssigned` 事件(发布失败只记日志,不影响主流程,并在 README 里写明这个取舍)。 -- `cmd/worker/main.go`:独立的消费者进程,支持优雅关机。 -- 新接口:`GET /api/notifications`、`POST /api/notifications/:id/read`。 -- `internal/queue/stream_test.go`:同一条消息处理两次,通知表只有一行。 - -**运行方式**:一个终端 `go run ./cmd/server`,另一个 `go run ./cmd/worker`;指派任务后查通知接口。 - -**完成标准**:能说清“至少一次投递 + 幂等消费”;把 worker 停掉再指派任务,重新启动 worker 后通知能补上。 - ---- - -## 第 5 天:Docker 镜像 - -**学习内容** -- 镜像、容器、卷、网络;`Dockerfile` 常用指令。 -- 多阶段构建:第一阶段编译,第二阶段只放编译好的二进制,镜像小、没有源码。 -- 容器里的配置都走环境变量;服务之间用 Compose 的服务名互相访问(比如 `postgres:5432`)。 - -**要建的文件** -- `backend/Dockerfile`: - - 阶段一 `golang:1.x` 镜像:先复制 `go.mod`、`go.sum` 下载依赖(利用缓存),再复制源码,`CGO_ENABLED=0 go build` 出 `server` 和 `worker` 两个二进制。 - - 阶段二用体积小的基础镜像(`alpine` 或 `distroless`),非 root 用户运行。 -- `backend/.dockerignore` -- `frontend/Dockerfile`:阶段一 `node` 镜像 `npm ci && npm run build`;阶段二 `nginx` 镜像放静态文件和 `deploy/nginx.conf`。 -- `deploy/nginx.conf`:`/` 指向静态文件(单页应用的路由回退到 `index.html`),`/api/` 反向代理到 `backend:8080`。 -- 前端接口地址改成相对路径 `/api`,开发时用 Vite 代理。 - -**运行方式**:`docker build -t task-board-backend ./backend`,`docker images` 看镜像大小。 - -**完成标准**:能说出多阶段构建的好处;后端镜像不包含源码和 Go 工具链。 - ---- - -## 第 6 天:Docker Compose 一键启动 + 前端通知 - -**要建的文件** -- `task-board/docker-compose.yml`: - - `postgres`、`redis`(都带具名卷和健康检查)。 - - `migrate`:用 golang-migrate 的官方镜像执行迁移,执行完就退出。 - - `backend`:`depends_on` 等数据库健康和迁移完成;环境变量从 `.env` 读。 - - `worker`:同一个镜像,启动命令换成 `worker`。 - - `frontend`:对外暴露 `80` 端口。 -- `task-board/.env.example`:`POSTGRES_PASSWORD`、`JWT_SECRET`、`REDIS_URL` 等,写清楚哪些必须改。 -- `frontend/src/components/NotificationBell.tsx`:每 30 秒拉一次通知列表,显示未读数,点开可标记已读。 - -**运行方式**:`cp .env.example .env`(改密码和密钥)→ `docker compose up -d --build` → 打开 `http://localhost`。 - -**完成标准**:一台只装了 Docker 的机器,一条命令就能跑起完整系统。 - ---- - -## 第 7 天:部署到服务器 + CI + 收尾 - -**学习内容** -- 部署的最小流程:服务器装 Docker → 拉代码 → 配 `.env` → `docker compose up -d --build`;更新时 `git pull` 再执行一次。 -- CI:每次推送自动跑检查和测试。不同代码托管平台的配置文件位置不同(GitHub Actions 是 `.github/workflows/`,GitLab 是 `.gitlab-ci.yml`,Gitea 的 Actions 和 GitHub 写法接近),先确认你的 `git.gozeros.com` 是哪种平台、有没有开启 CI,没有的话就先写好文件、在本地用脚本跑同样的步骤。 -- 了解即可:Kubernetes 是更大规模的容器编排,招聘里没有点名要求,本计划不学。 - -**要建的文件和修改** -- `deploy/deploy.md`:在 Ubuntu 服务器上部署的完整步骤,包括防火墙只开 80(和 22)、`.env` 权限设为 600、查看日志 `docker compose logs -f backend`、备份数据库 `pg_dump`。 -- `.ci/ci.yml`(放到你平台对应的目录):后端 `go vet`、`go test -race`(起 postgres 和 redis 服务容器跑集成测试);前端 `npm ci && npm run build`;最后 `docker build` 检查镜像能构建。 -- 在你的 Ubuntu 服务器上按 `deploy.md` 部署一次(服务器是内网或没有域名也没关系,能在内网访问即可;HTTPS 放到第 15 周和 AI 项目一起处理)。 -- 更新 `task-board/README.md`:架构图加上 Redis、worker、Nginx;新增“缓存策略”“限流”“消息队列与幂等”“部署”四节;本地开发和一键部署两种运行方式。 -- 提交并推送到 `zhuangang` 仓库。确认 `.env` 没有提交。 - -**完成标准** -- `go vet ./...`、`go test ./... -race` 和集成测试都通过。 -- 服务器上能访问项目一,重启服务器后 `docker compose` 能自动拉起(服务加 `restart: unless-stopped`)。 -- 能用 3 分钟讲清楚项目一的完整架构:分层、鉴权、缓存、限流、消息队列、部署。 -- 在总纲 `README.md` 的进度记录里勾掉第 5 周和“第一段 Go 全栈基础”。 - ---- - -## 本周自查清单 - -- [ ] 会用 Redis 五种常用类型,知道持久化的两种方式 -- [ ] 会写旁路缓存,能讲清穿透、击穿、雪崩和对应办法 -- [ ] Redis 挂了接口不跟着挂(降级) -- [ ] 会用 Redis 写固定窗口限流 -- [ ] 理解消息队列的作用,能讲清“至少一次投递 + 幂等消费” -- [ ] 会写 Redis Streams 的生产者和消费者组,知道 RabbitMQ 的基本概念 -- [ ] 会写多阶段构建的 Dockerfile 和多服务的 Docker Compose -- [ ] 项目一已部署到服务器,有 CI 配置 - -下周预告:第 6 周开始 Python。从这里起 Python 是主语言,Go 是加分项。