Go 部分压缩为 3 周:Go 基础+Gin、数据库+鉴权、项目一(Redis/队列/Docker),每个知识点补充说明、案例、代码和易错点
This commit is contained in:
1 parent
3094fe152f
commit
b79280e6d4
8 files changed
+3348
-1359
No files matched your search
@@ -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 接上去。
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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 生成数据访问代码。因为本周用接口隔离了存储层,下周只需要新写一个实现,接口代码基本不用动。
|
|
||||||
@@ -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、看板成员权限、按状态分列的任务,前后端完整联调。
|
|
||||||
@@ -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 全栈基础”
|
||||||
@@ -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 <token>`。
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 权限 | 成功状态码 |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| 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<T>(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`。
|
|
||||||
@@ -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 是加分项。
|
|
||||||
Reference in new issue
Block a user