添加转岗学习计划:总纲、第 1-4 周、Python 补课和迷你多 Agent 项目
This commit is contained in:
commit
f8a25cb765
9 files changed
+2362
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
notes/private/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
node_modules/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# zhuangang · 转岗学习仓库
|
||||||
|
|
||||||
|
Go 全栈基础 + Python 多 Agent 应用开发的 12 周转岗计划和全部练习代码。
|
||||||
|
|
||||||
|
从 [plan/README.md](plan/README.md) 开始看:里面有总路线、每周计划文件和进度记录。
|
||||||
+417
@@ -0,0 +1,417 @@
|
|||||||
|
# 转岗计划总纲:Go 全栈基础 + Python 多 Agent 应用
|
||||||
|
|
||||||
|
这是整个计划的入口文件。换了电脑或者隔一段时间回来,先看这份文件的“进度记录”,再打开当前所在周的计划文件继续做。
|
||||||
|
|
||||||
|
**状态**:未开始 / 进行中 / 已完成( )
|
||||||
|
**开始日期**:
|
||||||
|
**当前所在周**:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景、起点和目标
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
|
||||||
|
- 7 年 Web 前端(Vue / React),成人本科。
|
||||||
|
- AI 写代码越来越多,“只会把页面写出来”的价值在下降,需要往“能设计、能交付、能讲清原理”的方向走。
|
||||||
|
- 学历在大厂简历筛选里吃亏,所以主攻看作品和经验的岗位,用能跑起来的项目和真实案例说话。
|
||||||
|
|
||||||
|
### 起点(已经具备,不用从零学)
|
||||||
|
|
||||||
|
- 能独立开发和改通业务前端,能和后端 Agent 网关联调接口。
|
||||||
|
- 在公司里接触过真实的多 Agent 产品链路:意图识别 → 多角色研讨 → 总结 / 交付 / 过程报告。
|
||||||
|
- 踩过真实的生产问题:流式连接断开、模型额度不足、状态没有回写、数据库表结构和代码不一致、关掉页面后编排中断。
|
||||||
|
|
||||||
|
这些经历是转岗最大的本钱,后面的计划会把它们整理成“案例卡”和一个可以公开演示的迷你项目。
|
||||||
|
|
||||||
|
### 目标岗位
|
||||||
|
|
||||||
|
- 应用侧 Agent 工程师
|
||||||
|
- 大模型应用开发工程师
|
||||||
|
- 全栈工程师(前端 + Go 后端)
|
||||||
|
|
||||||
|
### 终点(4 条必须同时满足,才算这条路径走完)
|
||||||
|
|
||||||
|
1. **能讲清**:一张架构图 + 三分钟口述“多 Agent 如何状态机化、状态存在哪、失败怎么办”。
|
||||||
|
2. **能做小**:一个**不带公司业务外壳**的迷你多 Agent 项目,本地可跑,覆盖编排 + 持久化 + 断点续跑(或写清降级策略)。
|
||||||
|
3. **能举证**:2 到 3 个“问题 → 根因 → 方案”的案例,优先来自自己在工作中遇到的真实问题(脱敏后)。
|
||||||
|
4. **能对标岗位**:简历和面试条目写得像“应用侧 Agent 工程师 / 全栈工程师”,而不是“改过公司某个页面”。
|
||||||
|
|
||||||
|
另外,第 1 个月结束时要有一个能演示的 Go 全栈项目(项目一:团队任务看板),作为全栈能力的证明。
|
||||||
|
|
||||||
|
任意一条没满足,都不要提前宣布“学完了”。
|
||||||
|
|
||||||
|
### 非目标(故意不做)
|
||||||
|
|
||||||
|
- 不以模型预训练、微调为主业。
|
||||||
|
- 不把公司业务代码开源,也不整仓搬进作品集。
|
||||||
|
- 不深入运维巡检、内网穿透、多云部署,简历上最多一句带过。
|
||||||
|
- 本计划不再单独做“文档知识库问答(RAG)”项目,也不专门学 Redis、Nginx、HTTPS 部署。这些放到“完成后的下一步”里,不塞进本计划(见第 10 节)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术栈(已确定)
|
||||||
|
|
||||||
|
| 用途 | 选择 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 全栈后端语言 | Go | 第 1 个月主线。语法简单、风格统一,适合用 AI 辅助编写和审查 |
|
||||||
|
| Web 框架(Go) | Gin | 国内使用广,概念少 |
|
||||||
|
| 数据库 | PostgreSQL | 第 3 周开始用;Agent 项目默认用 SQLite,零依赖、方便别人跑起来,可选换成 PostgreSQL |
|
||||||
|
| 数据访问(Go) | sqlc + pgx | 写真 SQL,自动生成类型安全的 Go 代码,顺便练 SQL |
|
||||||
|
| 数据库迁移 | golang-migrate | 表结构变更全部用迁移文件管理,避免“库表和代码不一致” |
|
||||||
|
| 鉴权 | bcrypt + JWT | 第 4 周 |
|
||||||
|
| 前端 | React 或 Vue(你的强项) | 前后端分离,前端只负责调接口 |
|
||||||
|
| Agent 项目语言 | Python 3.12 | 第 8 周补课,第 9 到 11 周做项目。Agent 相关的框架、资料和岗位要求大多是 Python |
|
||||||
|
| Python 工具 | uv(管理环境和依赖)、ruff(格式化和检查)、pytest | |
|
||||||
|
| Agent 项目框架 | **自写 asyncio 编排循环**(默认) | 参考文档要求“LangGraph 或自写循环二选一,不要两个都深挖”。默认选自写,理由见下面 |
|
||||||
|
| Agent 服务 | FastAPI + SSE | 后台跑任务、前端通过 SSE 订阅进度 |
|
||||||
|
| 大模型调用 | httpx 调 OpenAI 兼容接口 + 一个假模型(FakeLLM) | 假模型让测试和演示不依赖密钥和网络 |
|
||||||
|
| AI 编程工具 | Cursor / Cursor CLI / Grok Build | 让 AI 写,你负责设计、审查、调试 |
|
||||||
|
|
||||||
|
**为什么默认自写 asyncio 循环,而不是 LangGraph**
|
||||||
|
|
||||||
|
- 面试要证明的是“懂编排、状态、续跑的原理”,自己写一遍循环、检查点和恢复逻辑,最能讲清楚。
|
||||||
|
- 代码量不大(核心几百行),出了问题容易调试。
|
||||||
|
- 公司里的 Agent 网关如果已经用了 LangGraph 一类的框架,你自写一遍之后再去读它,会更容易看懂它替你做了什么。
|
||||||
|
- 如果你在第 5 周盘点时更想用 LangGraph(比如目标公司明确要求),可以换,`agent-mini-project.md` 最后有一张“自写版模块 ↔ LangGraph 对应概念”的表。**只选一个,不要两个都做。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 学习原则
|
||||||
|
|
||||||
|
1. **每一步都落到文件**:每天的任务都有明确要建的文件和完成标准;每周至少有一个能运行的提交或一页笔记。禁止“只看不写”。
|
||||||
|
2. **AI 写,你审**:代码可以让 AI 生成,但每个文件你都要能讲清楚每一行在做什么,面试时这是最大的区分点。
|
||||||
|
3. **边上班边学**:工作日每天 1.5 到 2 小时,周末多投入一些。不建议裸辞。
|
||||||
|
4. **代码和计划都进 Git**:全部推到 `zhuangang` 仓库,换设备直接接着做。
|
||||||
|
5. **只砍不加**:进度落后时砍范围,不加新内容;新想法记到第 10 节“完成后的下一步”。
|
||||||
|
6. **脱敏**:公司代码、内网地址、真实任务数据、密钥、客户名,一律不进仓库。工作里的经验只以“机制 + 脱敏案例”的形式记录(详见第 8 节)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 十二周总路线
|
||||||
|
|
||||||
|
```text
|
||||||
|
第 1 个月 Go 全栈基础(第 1~4 周) → 项目一:团队任务看板
|
||||||
|
第 2 个月 阶段0 盘点 + 阶段1 读通画图(第 5 周)
|
||||||
|
阶段2 五个重点 A~E + 案例卡(第 6~7 周)
|
||||||
|
Python 补课(第 8 周)
|
||||||
|
第 3 个月 阶段3 Python 迷你多 Agent 项目(第 9~11 周)★主交付
|
||||||
|
阶段4 包装与终点验收 + 投递(第 12 周)
|
||||||
|
```
|
||||||
|
|
||||||
|
精力不够时可以拉长到 14 到 16 周,但顺序不要变,也不要跳过验收。
|
||||||
|
|
||||||
|
### 第 1 个月:Go 全栈基础
|
||||||
|
|
||||||
|
| 周 | 主题 | 产出 | 计划文件 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 第 1 周 | Go 基础语法 | 命令行任务清单 `todo` | `week01-go-basics.md` |
|
||||||
|
| 第 2 周 | Gin 写 HTTP 接口 + 接前端 | 任务清单 Web 版(内存存储) | `week02-gin-api.md` |
|
||||||
|
| 第 3 周 | PostgreSQL、SQL、迁移、sqlc、事务 | 任务清单改用数据库存储 | `week03-postgresql.md` |
|
||||||
|
| 第 4 周 | 注册登录、JWT、权限 | **项目一:团队任务看板**(多用户、成员权限) | `week04-auth-project1.md` |
|
||||||
|
|
||||||
|
月底验收:
|
||||||
|
- [ ] 能独立用 Go + Gin + PostgreSQL 写出带鉴权的 REST 接口
|
||||||
|
- [ ] 项目一本地 `docker compose up` + `go run .` + `npm run dev` 就能跑起来,README 写清楚
|
||||||
|
- [ ] 能讲清楚:接口隔离存储层、事务、索引、JWT 的优缺点
|
||||||
|
|
||||||
|
### 第 2 个月:盘点、读通、深挖、补 Python
|
||||||
|
|
||||||
|
#### 第 5 周:阶段 0 盘点(前 2~3 天)+ 阶段 1 读通与画图(后 4~5 天)
|
||||||
|
|
||||||
|
**阶段 0 做什么**
|
||||||
|
|
||||||
|
1. 写下一句话目标岗位(例:应用侧 Agent 工程师 / 大模型应用开发)。
|
||||||
|
2. 列出自己在公司亲自改过、排查过的模块(只写通用名字,例如“前端多 Agent 研讨模块”“Agent 网关的作业接口”)。
|
||||||
|
3. 按第 5 节 A~E 五个重点,填一张“已会 / 模糊 / 不会”三列对照表。
|
||||||
|
4. 确认迷你项目技术栈:Python + 自写 asyncio 循环(默认)或 LangGraph,二选一。
|
||||||
|
|
||||||
|
文件:`notes/00-inventory.md`
|
||||||
|
|
||||||
|
阶段 0 验收:
|
||||||
|
- [ ] 一句话目标岗位已写下
|
||||||
|
- [ ] A~E 的“已会 / 模糊 / 不会”三列填完
|
||||||
|
- [ ] 迷你项目技术栈已选定,写进 `notes/00-inventory.md`
|
||||||
|
|
||||||
|
**阶段 1 做什么**(重点 A 编排 + B 权威源,先建立全局地图,再钻细节)
|
||||||
|
|
||||||
|
按优先级读公司项目里这几块(在公司电脑上读,笔记只写机制):
|
||||||
|
|
||||||
|
| 优先级 | 要读的模块(通用叫法) | 要搞清的问题 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P0 | 前端的多 Agent 功能:意图、多席研讨、草稿、作业 | 几个步骤怎么串起来,谁发起 SSE |
|
||||||
|
| P0 | 前端的会话与交付模块 | 会话状态和交付状态分别存在哪 |
|
||||||
|
| P0 | Agent 网关里多 Agent、意图、作业、草稿相关的接口 | 服务端是“单轮调用”还是“后台作业” |
|
||||||
|
| P1 | 前端对 Agent 网关的接口契约清单 | 前后端到底约定了哪些接口 |
|
||||||
|
| P2 | 团队里关于“后台挂起 / 后台作业”的设计文档(如果有) | 理想架构和现状差在哪 |
|
||||||
|
|
||||||
|
产出:
|
||||||
|
1. **一张图**(mermaid):用户 → 前端 → Agent 网关 → 草稿 / 作业 / 业务库。文件 `notes/01-architecture.md`
|
||||||
|
2. **一张表**:关键接口(初始化、运行并流式返回、草稿、作业、会话)各自职责。文件 `notes/01-api-table.md`(接口只写职责,不写真实路径和内网地址)
|
||||||
|
|
||||||
|
阶段 1 验收:
|
||||||
|
- [ ] 不看代码能口述多 Agent 功能的主流程
|
||||||
|
- [ ] 图上标出:**关掉浏览器时,流程断在哪一层**
|
||||||
|
- [ ] 指出至少一处“状态双写或权威来源不清”的位置
|
||||||
|
|
||||||
|
#### 第 6 周:阶段 2(上)重点 A 编排 + C 流式
|
||||||
|
|
||||||
|
| 重点 | 必做实验(二选一即可) | 产出 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A 多 Agent 编排 | 跟一次完整的多 Agent 流程(或用联调桩),记录派活顺序和结束(共识)条件 | `notes/cards/A-orchestration.md` |
|
||||||
|
| C 流式与长任务 | 故意断网或关页面,记录“单轮继续”和“整个循环停掉”的区别 | `notes/cards/C-streaming.md` |
|
||||||
|
|
||||||
|
周末顺手补完阶段 1 没画完的部分。
|
||||||
|
|
||||||
|
#### 第 7 周:阶段 2(下)重点 B 权威源 + D 人机协作 + E 业务集成
|
||||||
|
|
||||||
|
| 重点 | 必做实验 | 产出 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| B 状态与权威源 | 对比草稿数据和任务 / 过程报告,找出哪些没有回写 | `notes/cards/B-source-of-truth.md` |
|
||||||
|
| D 人机协作 | 走一遍“需要澄清”或“换模型失败 → 恢复” | `notes/cards/D-human-in-loop.md` |
|
||||||
|
| E 与业务系统集成 | 跟一次过程报告 / 任务状态:LLM 结果怎样变成业务记录 | `notes/cards/E-integration.md`(可以和 B 合并成一张,但五个重点都要覆盖) |
|
||||||
|
|
||||||
|
阶段 2 验收:
|
||||||
|
- [ ] A~E 每个重点至少一张案例卡(模板见第 6 节)
|
||||||
|
- [ ] 每张卡都写了“若重做,我会怎样设计”,这些结论会直接变成第 9~11 周迷你项目的设计依据
|
||||||
|
|
||||||
|
#### 第 8 周:Python 补课(给 JS 开发者的速成)
|
||||||
|
|
||||||
|
计划文件:`week08-python-basics.md`
|
||||||
|
|
||||||
|
内容:基础语法对照 JS、类型标注(typing)、asyncio、pydantic、httpx + pytest、FastAPI 基础和 SSE。
|
||||||
|
产出:`py-learning/` 练习目录,最后一天跑通一个能推送 SSE 的 FastAPI 小服务。
|
||||||
|
|
||||||
|
这一周只学 Python,不开 Agent 项目,也不深挖新案例(戒律:同一周不同时开新框架和新功能)。
|
||||||
|
|
||||||
|
### 第 3 个月:迷你多 Agent 项目 + 包装求职
|
||||||
|
|
||||||
|
#### 第 9~11 周:阶段 3 迷你复现项目(主交付)★
|
||||||
|
|
||||||
|
计划文件:`agent-mini-project.md`
|
||||||
|
|
||||||
|
| 周 | 里程碑 | 产出 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 第 9 周 | M1 单轮 LLM 调用封装(超时、可读的错误码)+ M2 多轮编排循环 | 命令行能跑完一次“协调者 + 两个专家”的多轮讨论 |
|
||||||
|
| 第 10 周 | M3 状态持久化 + 进程重启后续跑 + FastAPI 后台作业 + SSE | 杀掉进程再启动,任务能接着跑完,浏览器断开重连能补齐事件 |
|
||||||
|
| 第 11 周 | M4 README、架构图、和生产概念的映射表;可选:Go 网关或极简前端 | 别人按 README 10 分钟内跑通 |
|
||||||
|
|
||||||
|
范围(来自参考文档):
|
||||||
|
|
||||||
|
| 必须有 | 本阶段禁止 |
|
||||||
|
| --- | --- |
|
||||||
|
| 至少 2 个专家角色 + 1 个协调者 | 完整任务中心、字典、巡检 |
|
||||||
|
| 明确的结束条件(达成共识或达到最大轮数) | 照搬公司 UI |
|
||||||
|
| 状态持久化(默认 SQLite,可换 PostgreSQL) | 依赖公司内网才能演示 |
|
||||||
|
| 进程重启后能续跑;编排跑在后台,不依赖浏览器 | 没有任何说明的纯聊天 demo 页 |
|
||||||
|
| SSE 推送进度,断线可重连补齐 | |
|
||||||
|
|
||||||
|
阶段 3 验收:
|
||||||
|
- [ ] 别人按 README 10 分钟内跑通(不需要真实模型密钥,用 FakeLLM 也能演示)
|
||||||
|
- [ ] 演示:启动 →(中途杀进程)→ 重启 → 最终结果可查
|
||||||
|
- [ ] 映射表至少 5 个概念对齐生产(角色 / 派活 / 共识 / 草稿 / 作业,或等价概念)
|
||||||
|
|
||||||
|
#### 第 12 周:阶段 4 包装与终点验收 + 投递
|
||||||
|
|
||||||
|
做什么:
|
||||||
|
1. 简历三条 Agent 相关经历(STAR 写法):编排可靠性、状态权威、业务落地各一条,现象和结果写具体(数字必须真实,没有就写现象,不要编)。另加一条 Go 全栈项目一。文件 `career/resume.md`
|
||||||
|
2. 面试口述稿:3 分钟架构 + 1 分钟“踩过的最大坑”。计时练习,总长不超过 4 分钟。文件 `career/pitch.md`
|
||||||
|
3. Go 和数据库基础复习:goroutine / channel、切片、接口、错误处理、索引、事务。文件 `career/interview-notes.md`
|
||||||
|
4. 对照第 1 节“终点”四条和第 12 节终点验收清单打勾;缺哪条补哪条,**不开新需求**。
|
||||||
|
5. 开始投递:中小公司、创业团队、AI 应用团队的 Agent 应用 / 大模型应用 / 全栈岗位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 五个学习重点(始终抓住这 5 条)
|
||||||
|
|
||||||
|
转岗面试和第 2、3 个月的自学,优先加深这五条,其他功能当背景。
|
||||||
|
|
||||||
|
| # | 重点 | 在工作中的抓手(通用叫法) | 学完应能回答的一句话 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| A | 多 Agent 编排 | 链式 / DAG 编排、主角色派活、各角色交付、共识 | “角色怎么分工、什么时候结束、怎么避免同一个任务被跑两次” |
|
||||||
|
| B | 状态与权威源 | 草稿 / 作业 / 任务,谁说了算 | “刷新或关页后为什么丢进度,权威数据写在哪” |
|
||||||
|
| C | 流式与长任务 | SSE、连接断开处理、浏览器里循环 vs 后台作业 | “为什么编排不该只活在浏览器里” |
|
||||||
|
| D | 人机协作 | 意图澄清、等待用户输入、模型切换 | “Agent 什么时候必须停下来问人” |
|
||||||
|
| E | 与业务系统集成 | 过程报告、任务状态、鉴权隔离 | “LLM 的结果怎样落到可审计的业务对象上” |
|
||||||
|
|
||||||
|
迷你项目里每个重点都有对应的实现(见 `agent-mini-project.md` 的映射表)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 案例卡模板(直接复制)
|
||||||
|
|
||||||
|
每张卡一个文件,放在 `notes/cards/`。仓库里只放脱敏版本。
|
||||||
|
|
||||||
|
```text
|
||||||
|
标题:
|
||||||
|
对应重点:A / B / C / D / E
|
||||||
|
现象:
|
||||||
|
根因(一层):
|
||||||
|
根因(系统层,可选):
|
||||||
|
当时怎么处理 / 未处理:
|
||||||
|
可迁移结论(面试一句):
|
||||||
|
若重做的设计:
|
||||||
|
关联代码/路径:(私有,只写在 notes/private/ 里,不进仓库)
|
||||||
|
日期:
|
||||||
|
```
|
||||||
|
|
||||||
|
优先写成卡的题目(来自你已经遇到过的问题):
|
||||||
|
|
||||||
|
1. 关掉浏览器后,多轮研讨就停了(编排跑在前端)。
|
||||||
|
2. 分析已经进行到第三步,但任务状态还是“未开始”(权威源 / 回写缺失)。
|
||||||
|
3. 模型额度或鉴权失败,以及“换模型后恢复”。
|
||||||
|
4. 主分支升级后某张表缺字段导致接口 500(应用依赖和数据库迁移)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 每周节奏和戒律
|
||||||
|
|
||||||
|
| 日 | 做什么 |
|
||||||
|
| --- | --- |
|
||||||
|
| 周一 | 定本周重点(第 2、3 个月只选第 5 节中的一条,或一个里程碑) |
|
||||||
|
| 周二~周四 | 写代码 / 做实验 / 写案例卡 |
|
||||||
|
| 周五 | 验收:本周有没有“可展示的产出”;没有的话,周末只补交付,不学新东西 |
|
||||||
|
| 周日 | 30 分钟回顾:对照终点清单调整下一周范围(只砍不加),更新进度记录并 `git push` |
|
||||||
|
|
||||||
|
第 1 个月按每周计划文件里的“第 1 天~第 7 天”走,周五和周日的检查照做。
|
||||||
|
|
||||||
|
**戒律**
|
||||||
|
|
||||||
|
- 同一周不同时开“新框架 + 新业务功能 + 新论文”。
|
||||||
|
- 公司需求和学习冲突时:学习这边只保“案例卡或迷你项目提交”,不保阅读量。
|
||||||
|
- 概念类资料每个重点最多读 1 篇(比如工具调用、结构化输出、人在回路),读完必须写进案例卡。
|
||||||
|
- 不拿“无状态的聊天界面教程”当主线,最多用来对比“为什么不够”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 脱敏和保密
|
||||||
|
|
||||||
|
- 仓库里**不放**:公司代码片段、内网地址和端口、真实接口路径、真实任务 / 客户数据、密钥、截图里的业务内容。
|
||||||
|
- 案例卡只写机制:“草稿状态没有回写到任务表”可以写;具体表名、接口路径、字段名不写。
|
||||||
|
- 需要记私有细节时,写在 `notes/private/`,并把它加进 `.gitignore`,只留在公司电脑上。
|
||||||
|
- 迷你项目必须是自己从零写的“无业务外壳”版本,角色和题目都用通用例子,不能复制公司代码或提示词。
|
||||||
|
- 每次 `git push` 前跑一遍 `git diff --cached`,看一眼有没有不该提交的内容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 仓库结构(`zhuangang`)
|
||||||
|
|
||||||
|
```
|
||||||
|
zhuangang/
|
||||||
|
├── .gitignore # 至少包含 notes/private/、.env、*.db、node_modules/、.venv/
|
||||||
|
├── plan/ # 本计划文件夹(就是这些 .md 文件)
|
||||||
|
│ ├── README.md
|
||||||
|
│ ├── week01-go-basics.md
|
||||||
|
│ ├── week02-gin-api.md
|
||||||
|
│ ├── week03-postgresql.md
|
||||||
|
│ ├── week04-auth-project1.md
|
||||||
|
│ ├── week08-python-basics.md
|
||||||
|
│ └── agent-mini-project.md
|
||||||
|
├── go-learning/ # 第 1 周语法练习,含第 7 天的 todo 命令行项目
|
||||||
|
├── todo-api/ # 第 2~3 周:任务清单 Web 版(Gin + PostgreSQL + 前端)
|
||||||
|
│ ├── backend/
|
||||||
|
│ └── frontend/
|
||||||
|
├── task-board/ # 第 4 周:项目一 团队任务看板
|
||||||
|
│ ├── backend/
|
||||||
|
│ └── frontend/
|
||||||
|
├── notes/ # 第 5~7 周:盘点、架构图、案例卡(全部脱敏)
|
||||||
|
│ ├── 00-inventory.md
|
||||||
|
│ ├── 01-architecture.md
|
||||||
|
│ ├── 01-api-table.md
|
||||||
|
│ ├── cards/
|
||||||
|
│ └── private/ # 不进仓库(.gitignore)
|
||||||
|
├── py-learning/ # 第 8 周 Python 练习
|
||||||
|
├── agent-mini/ # 第 9~11 周:迷你多 Agent 项目(主交付)
|
||||||
|
└── career/ # 第 12 周:简历、口述稿、面试笔记
|
||||||
|
```
|
||||||
|
|
||||||
|
以后整理作品集时,可以把 `task-board/` 和 `agent-mini/` 单独同步到公开的 GitHub 仓库(再检查一遍脱敏)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 换设备后如何继续
|
||||||
|
|
||||||
|
1. 新设备安装:Git、Go、Node.js、Docker、Python 3.12 和 uv、编辑器(VS Code 或 Cursor)。
|
||||||
|
2. 配好能访问 `git.gozeros.com` 的 SSH 密钥,然后克隆仓库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://wsk@git.gozeros.com:30023/wsk/zhuangang.git
|
||||||
|
cd zhuangang
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 打开 `plan/README.md`,在“进度记录”里找到上次做到哪周哪天。
|
||||||
|
4. 打开对应周的计划文件,从下一天继续。
|
||||||
|
5. 需要数据库的项目:在项目的 `backend/` 目录 `docker compose up -d` 启动 PostgreSQL,再按该项目 README 跑迁移。
|
||||||
|
6. Python 项目:进入 `agent-mini/`,运行 `uv sync` 安装依赖,复制 `.env.example` 为 `.env`。
|
||||||
|
7. 每天学完:更新进度记录 → `git add` → `git commit` → `git push`。
|
||||||
|
|
||||||
|
**完成后的下一步(不属于本计划,完成终点验收后再选一个开新路径)**
|
||||||
|
|
||||||
|
- 评测集:给 Agent 输出做自动评测。
|
||||||
|
- 工具调用规范:函数调用、MCP。
|
||||||
|
- 可观测性:链路追踪、日志、成本统计。
|
||||||
|
- RAG + pgvector 知识库、Redis 缓存、Docker + Nginx + HTTPS 正式部署。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 进度记录
|
||||||
|
|
||||||
|
### 按周
|
||||||
|
|
||||||
|
第 1~4 周和第 8 周按天打勾(☐ 改成 ☑)。第 5~7 周和第 9~12 周按“周一~周日”的节奏,周五验收时打勾。
|
||||||
|
|
||||||
|
| 周 | 主题 | 计划文件 | 每天 | 计划完成日 | 实际完成日 | 产出路径 | 周验收 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Go 基础语法 | week01-go-basics.md | ☐☐☐☐☐☐☐ | | | go-learning/ | ☐ |
|
||||||
|
| 2 | Gin 接口 + 前端 | week02-gin-api.md | ☐☐☐☐☐☐☐ | | | todo-api/ | ☐ |
|
||||||
|
| 3 | PostgreSQL | week03-postgresql.md | ☐☐☐☐☐☐☐ | | | todo-api/ | ☐ |
|
||||||
|
| 4 | 鉴权 + 项目一 | week04-auth-project1.md | ☐☐☐☐☐☐☐ | | | task-board/ | ☐ |
|
||||||
|
| 5 | 阶段0 盘点 + 阶段1 画图 | README 第 4 节 | ☐☐☐☐☐☐☐ | | | notes/00、01 | ☐ |
|
||||||
|
| 6 | 阶段2 A 编排 + C 流式 | README 第 4 节 | ☐☐☐☐☐☐☐ | | | notes/cards/ | ☐ |
|
||||||
|
| 7 | 阶段2 B + D + E | README 第 4 节 | ☐☐☐☐☐☐☐ | | | notes/cards/ | ☐ |
|
||||||
|
| 8 | Python 补课 | week08-python-basics.md | ☐☐☐☐☐☐☐ | | | py-learning/ | ☐ |
|
||||||
|
| 9 | 阶段3 M1 + M2 | agent-mini-project.md | ☐☐☐☐☐☐☐ | | | agent-mini/ | ☐ |
|
||||||
|
| 10 | 阶段3 M3 持久化 + 续跑 + SSE | agent-mini-project.md | ☐☐☐☐☐☐☐ | | | agent-mini/ | ☐ |
|
||||||
|
| 11 | 阶段3 M4 文档 + 映射表 | agent-mini-project.md | ☐☐☐☐☐☐☐ | | | agent-mini/ | ☐ |
|
||||||
|
| 12 | 阶段4 包装 + 终点验收 + 投递 | README 第 4 节 | ☐☐☐☐☐☐☐ | | | career/ | ☐ |
|
||||||
|
|
||||||
|
### 按阶段(来自参考文档)
|
||||||
|
|
||||||
|
| 阶段 | 计划完成日 | 实际完成日 | 产出路径 | 验收 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 第 1 个月 Go 全栈基础 | | | task-board/ | ☐ |
|
||||||
|
| 0 盘点 | | | notes/00-inventory.md | ☐ |
|
||||||
|
| 1 读通与画图 | | | notes/01-*.md | ☐ |
|
||||||
|
| 2 案例卡 | | | notes/cards/ | ☐ |
|
||||||
|
| Python 补课 | | | py-learning/ | ☐ |
|
||||||
|
| 3 迷你项目 | | | agent-mini/ | ☐ |
|
||||||
|
| 4 终点 | | | career/ | ☐ |
|
||||||
|
|
||||||
|
### 卡住和收获(每天一句)
|
||||||
|
|
||||||
|
| 日期 | 周/天 | 一句话收获或卡住的地方 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 终点验收清单(全部勾选才算完结)
|
||||||
|
|
||||||
|
**全栈能力**
|
||||||
|
- [ ] 项目一(团队任务看板)能本地跑起来,README 清楚
|
||||||
|
- [ ] 能讲清 goroutine、channel、切片底层、接口、错误处理
|
||||||
|
- [ ] 能讲清表结构设计、索引、事务、迁移、JWT 鉴权
|
||||||
|
|
||||||
|
**Agent 能力(参考文档的终点)**
|
||||||
|
- [ ] 架构图最新版已归档,和迷你项目 README 里的一致
|
||||||
|
- [ ] 迷你项目可演示:启动 → 中途杀进程 → 重启续跑 → 结果可查
|
||||||
|
- [ ] 至少 2 张案例卡可以对外讲(已脱敏)
|
||||||
|
- [ ] 口述稿计时不超过 4 分钟,覆盖 A / B / C 中至少两项
|
||||||
|
- [ ] 第 1 节“终点”四条全部满足
|
||||||
|
|
||||||
|
**求职**
|
||||||
|
- [ ] 简历更新完成(Agent 三条 + 全栈一条),没有编造的数字
|
||||||
|
- [ ] 已开始投递
|
||||||
|
- [ ] 写下一句“下一步如果继续学”:评测集 / 工具调用规范 / 可观测性(三选一),作为新路径,不塞进本计划
|
||||||
|
|
||||||
|
**完结动作**:把本文件开头的状态改成“已完成(YYYY-MM-DD)”,提交并推送。本路径正式结束。
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
# 转岗计划 · 第 9~11 周:Python 迷你多 Agent 项目(阶段 3 主交付)
|
||||||
|
|
||||||
|
> 所属阶段:第 3 个月,阶段 3。总纲见同目录 `README.md`,前置补课见 `week08-python-basics.md`。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把在公司里见过的多 Agent 产品“削”成一个**没有业务外壳**、可以公开演示的内核,证明你“会造”,而不只是“会改”。
|
||||||
|
|
||||||
|
做完后要能演示:
|
||||||
|
|
||||||
|
1. 提一个问题 → 协调者派活给两个专家 → 多轮讨论 → 达成共识或到达最大轮数后结束 → 生成可审计的报告。
|
||||||
|
2. 中途 `kill -9` 杀掉服务进程 → 重新启动 → 任务从上一步接着跑完,已完成的步骤不重复。
|
||||||
|
3. 浏览器通过 SSE 看进度;关掉页面任务照跑;重新打开能补齐漏掉的事件。
|
||||||
|
4. 协调者觉得信息不够时停下来问人,人回答后继续。
|
||||||
|
5. 模型调用失败(超时、限流、额度、鉴权)时,任务进入失败状态并带上可读的错误码,可以换模型后继续。
|
||||||
|
|
||||||
|
项目名:`agent-mini`,放在 `zhuangang/agent-mini/`。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
| 必须有 | 禁止有(本阶段) |
|
||||||
|
| --- | --- |
|
||||||
|
| 至少 2 个专家(提案者 proposer、评审者 critic)+ 1 个协调者 coordinator | 完整任务中心、字典、巡检 |
|
||||||
|
| 明确的结束条件:评审通过(共识)或达到最大轮数 | 照搬公司 UI、提示词或代码 |
|
||||||
|
| 状态持久化:默认 SQLite(可选 PostgreSQL) | 依赖公司内网才能演示 |
|
||||||
|
| 编排跑在服务端后台,进程重启后能续跑 | 没有说明的纯聊天 demo 页 |
|
||||||
|
| SSE 推送进度,断线重连能补齐 | 同时再学 LangGraph(二选一,已选自写) |
|
||||||
|
| 不需要真实模型密钥也能演示(FakeLLM) | |
|
||||||
|
|
||||||
|
讨论的题目用通用例子,比如“一个 5 人创业团队做内部工具,该选什么技术栈”“给一个读书 App 设计会员规则”,不要用公司业务题目。
|
||||||
|
|
||||||
|
## 技术选型
|
||||||
|
|
||||||
|
| 用途 | 选择 |
|
||||||
|
| --- | --- |
|
||||||
|
| 语言和依赖 | Python 3.12 + uv |
|
||||||
|
| 编排 | 自写 asyncio 循环 |
|
||||||
|
| 模型调用 | httpx 调 OpenAI 兼容的 `/chat/completions` 接口;FakeLLM 用于测试和离线演示 |
|
||||||
|
| 数据校验 | pydantic v2、pydantic-settings |
|
||||||
|
| 存储 | SQLite(aiosqlite),通过 `RunStore` 协议隔离,可另写 PostgreSQL 实现 |
|
||||||
|
| 服务 | FastAPI + uvicorn,SSE 用 `StreamingResponse` 手写 |
|
||||||
|
| 前端 | 一个静态 `web/index.html`(原生 JS + `EventSource`),可选用你熟悉的 Vue / React 重写 |
|
||||||
|
| 质量 | pytest + pytest-asyncio、ruff、pyright |
|
||||||
|
|
||||||
|
## 核心设计(先读懂,再动手)
|
||||||
|
|
||||||
|
### 状态机
|
||||||
|
|
||||||
|
```text
|
||||||
|
queued ──(被某个进程领取)──> running ──> completed
|
||||||
|
^ │ │
|
||||||
|
│ │ ├──> failed(模型错误、输出无法解析等,带 error_code)
|
||||||
|
│ │ ├──> cancelled(用户取消)
|
||||||
|
│ │ └──> awaiting_input(协调者要求问人)
|
||||||
|
│ │
|
||||||
|
└──(人回答 / 失败后重试 / 进程崩溃后租约过期)──┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一“步”是什么
|
||||||
|
|
||||||
|
一步 = 协调者做一次决定 + 被派到的专家回答一次。每一步完成后,在**同一个数据库事务**里:
|
||||||
|
|
||||||
|
1. 插入这一步的发言记录 `turns`(主键 `run_id + step`,重复插入会被忽略,保证不会写出两条);
|
||||||
|
2. 更新 `runs.next_step`、`runs.round`、`runs.status`;
|
||||||
|
3. 追加事件 `events`(给 SSE 用)。
|
||||||
|
|
||||||
|
进程在一步中间崩溃,这一步的模型调用会在恢复后**重做一次**(模型调用“至少一次”),但写进数据库的步骤“恰好一次”。这个取舍要写进 README。
|
||||||
|
|
||||||
|
### 结束条件写在代码里,不只靠模型自觉
|
||||||
|
|
||||||
|
- 评审者每次回答最后一行必须是 `VERDICT: APPROVE` 或 `VERDICT: REVISE`。最近一次评审是 `APPROVE` 时,代码直接让协调者收尾。
|
||||||
|
- 轮数达到 `max_rounds` 时,不再问协调者,直接收尾,并在报告里注明“未达成共识,因轮数上限结束”。
|
||||||
|
- 协调者输出格式错误:带上错误提示重试 1 次,还错就以 `LLM_BAD_OUTPUT` 失败。
|
||||||
|
|
||||||
|
### 防止同一个任务被跑两次(重点 A)
|
||||||
|
|
||||||
|
`runs` 表上有租约字段 `lease_owner`、`lease_until`。进程领取任务时用一条带条件的 `UPDATE`(只有租约为空、已过期或本来就是自己的才能领到),影响行数为 1 才算领取成功。运行中每步续租;进程崩溃后租约过期,看门狗会把任务重新领走。
|
||||||
|
|
||||||
|
### 权威数据在数据库(重点 B)
|
||||||
|
|
||||||
|
SSE 推送、内存里的通知都只是“加速”,任何时候以数据库里的 `runs` / `turns` / `events` 为准。SSE 断线重连就是按 `events.seq` 从数据库补发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最终项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
agent-mini/
|
||||||
|
├── README.md # 作品说明(M4)
|
||||||
|
├── pyproject.toml # 依赖、脚本入口 agent-mini、pytest/ruff 配置
|
||||||
|
├── .python-version
|
||||||
|
├── .env.example # 所有配置项和说明,默认 LLM_PROVIDER=fake
|
||||||
|
├── .gitignore # .env、data/、.venv/
|
||||||
|
├── docs/
|
||||||
|
│ ├── architecture.md # 组件图、状态机、崩溃恢复时序图(mermaid)
|
||||||
|
│ ├── design-decisions.md # 关键取舍:为什么后台跑、租约、至少一次
|
||||||
|
│ └── mapping.md # 与生产概念的映射表(也摘进 README)
|
||||||
|
├── scripts/
|
||||||
|
│ └── demo_crash_resume.sh # 一键演示:启动 → 杀进程 → 重启 → 查看结果
|
||||||
|
├── web/
|
||||||
|
│ └── index.html # 极简页面:提问、看进度、回答问题
|
||||||
|
├── src/agent_mini/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── config.py # Settings(pydantic-settings)
|
||||||
|
│ ├── errors.py # ErrorCode、AgentError
|
||||||
|
│ ├── models.py # Run、RunStatus、Turn、Decision、Event
|
||||||
|
│ ├── llm/
|
||||||
|
│ │ ├── __init__.py # build_llm(settings) 工厂函数
|
||||||
|
│ │ ├── base.py # ChatMessage、LLMResult、LLMClient 协议
|
||||||
|
│ │ ├── openai_compat.py # 真实调用:超时、重试、错误码映射
|
||||||
|
│ │ └── fake.py # 假模型:按脚本回复,可设延迟和故障
|
||||||
|
│ ├── roles/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── base.py # Specialist 基类、构造提示词的公共函数
|
||||||
|
│ │ ├── proposer.py # 提案者
|
||||||
|
│ │ ├── critic.py # 评审者(输出 VERDICT)
|
||||||
|
│ │ └── coordinator.py # 协调者:decide()、summarize()
|
||||||
|
│ ├── parsing.py # 从模型输出里提取 JSON / VERDICT
|
||||||
|
│ ├── orchestrator.py # 单步执行 + 循环,结束条件
|
||||||
|
│ ├── store/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── base.py # RunStore 协议
|
||||||
|
│ │ ├── schema.sql # 建表语句
|
||||||
|
│ │ └── sqlite_store.py # SQLite 实现(事务、租约、事件)
|
||||||
|
│ ├── events.py # EventNotifier:进程内通知,加速 SSE
|
||||||
|
│ ├── runner.py # RunManager:后台任务、租约续期、看门狗恢复、取消
|
||||||
|
│ ├── report.py # 由 turns 生成可审计的 Markdown 报告
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── app.py # create_app()、lifespan(启动看门狗)
|
||||||
|
│ │ ├── routes_runs.py # 任务相关接口
|
||||||
|
│ │ └── sse.py # GET /runs/{id}/events
|
||||||
|
│ └── cli.py # agent-mini ask / run / show / list / serve
|
||||||
|
└── tests/
|
||||||
|
├── conftest.py # 临时数据库、FakeLLM、测试设置
|
||||||
|
├── test_llm_openai_compat.py
|
||||||
|
├── test_parsing.py
|
||||||
|
├── test_orchestrator.py
|
||||||
|
├── test_sqlite_store.py
|
||||||
|
├── test_runner_resume.py
|
||||||
|
└── test_api_sse.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库表(`store/schema.sql`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS runs (
|
||||||
|
id TEXT PRIMARY KEY, -- uuid
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- queued/running/awaiting_input/completed/failed/cancelled
|
||||||
|
model TEXT NOT NULL, -- 本任务用的模型,可在重试时更换
|
||||||
|
round INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_rounds INTEGER NOT NULL,
|
||||||
|
next_step INTEGER NOT NULL DEFAULT 1,
|
||||||
|
human_question TEXT, -- awaiting_input 时要问人的问题
|
||||||
|
final_answer TEXT,
|
||||||
|
error_code TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
lease_owner TEXT,
|
||||||
|
lease_until REAL, -- Unix 时间戳
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS turns (
|
||||||
|
run_id TEXT NOT NULL REFERENCES runs(id),
|
||||||
|
step INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL, -- proposer/critic/human/coordinator
|
||||||
|
instruction TEXT NOT NULL DEFAULT '', -- 协调者派活时说的话
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
decision_json TEXT, -- 协调者这一步的决定原文(审计用)
|
||||||
|
model TEXT,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (run_id, step)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
run_id TEXT NOT NULL REFERENCES runs(id),
|
||||||
|
type TEXT NOT NULL, -- run_created/decision/turn/awaiting_input/input_received/run_completed/run_failed/run_cancelled
|
||||||
|
data TEXT NOT NULL, -- JSON
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_run_seq ON events(run_id, seq);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON runs(status, lease_until);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接口设计
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/health` | 健康检查 |
|
||||||
|
| POST | `/runs` | 创建任务 `{question, max_rounds?}`,立即返回 `{id, status: "queued"}`,后台开始跑 |
|
||||||
|
| GET | `/runs` | 最近的任务列表 |
|
||||||
|
| GET | `/runs/{id}` | 任务状态 + 所有 turns |
|
||||||
|
| GET | `/runs/{id}/events` | SSE,支持 `Last-Event-ID` 请求头或 `?after=seq` 补发 |
|
||||||
|
| POST | `/runs/{id}/input` | 回答协调者的问题 `{answer}`,仅 `awaiting_input` 状态可用 |
|
||||||
|
| POST | `/runs/{id}/retry` | 失败后重试,可换模型 `{model?}`,仅 `failed` 状态可用 |
|
||||||
|
| POST | `/runs/{id}/cancel` | 取消 |
|
||||||
|
| GET | `/runs/{id}/report` | Markdown 报告:问题、每一步谁说了什么、结束原因、最终结论 |
|
||||||
|
| GET | `/` | 返回 `web/index.html` |
|
||||||
|
|
||||||
|
错误响应统一:`{"error": {"code": "RUN_NOT_FOUND", "message": "..."}}`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 9 周 · M1:单轮 LLM 调用封装(第 1~3 天)
|
||||||
|
|
||||||
|
### 第 1 天:项目初始化、配置和错误码
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- `src/` 布局的 Python 项目,`pyproject.toml` 里配置脚本入口。
|
||||||
|
- 配置集中管理,所有可调参数都能通过环境变量改。
|
||||||
|
- 为什么要有统一的错误码:日志、接口、前端提示都能按错误码处理,而不是靠解析错误文字。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `uv init --package agent-mini`,`uv python pin 3.12`;`uv add httpx pydantic pydantic-settings`;`uv add --dev pytest pytest-asyncio ruff pyright`。
|
||||||
|
- `pyproject.toml`:`[project.scripts] agent-mini = "agent_mini.cli:main"`;`[tool.pytest.ini_options] asyncio_mode = "auto"`。
|
||||||
|
- `.env.example`:`LLM_PROVIDER=fake`、`LLM_BASE_URL=`、`LLM_API_KEY=`、`LLM_MODEL=`、`LLM_TIMEOUT_S=60`、`LLM_MAX_RETRIES=2`、`DB_PATH=data/agent_mini.db`、`MAX_ROUNDS=6`、`LEASE_SECONDS=15`、`FAKE_DELAY_S=0`,每项上面一行注释。
|
||||||
|
- `.gitignore`:`.env`、`data/`、`.venv/`、`__pycache__/`。
|
||||||
|
- `src/agent_mini/config.py`:`class Settings(BaseSettings)`,字段同上;`llm_provider: Literal["fake", "openai"]`;`def get_settings() -> Settings`。
|
||||||
|
- `src/agent_mini/errors.py`:
|
||||||
|
- `class ErrorCode(StrEnum)`:`LLM_TIMEOUT`、`LLM_RATE_LIMITED`、`LLM_QUOTA_EXCEEDED`、`LLM_AUTH`、`LLM_BAD_REQUEST`、`LLM_UNAVAILABLE`、`LLM_BAD_OUTPUT`、`RUN_NOT_FOUND`、`RUN_INVALID_STATE`、`RUN_LEASE_LOST`。
|
||||||
|
- `class AgentError(Exception)`:`code: ErrorCode`、`message: str`、`retryable: bool`、`detail: dict | None`。
|
||||||
|
- 每个错误码配一句中文说明 `ERROR_HINTS: dict[ErrorCode, str]`,比如 `LLM_QUOTA_EXCEEDED: "模型额度不足,请更换模型或充值后重试"`。
|
||||||
|
|
||||||
|
**运行方式**:`uv run python -c "from agent_mini.config import get_settings; print(get_settings())"`
|
||||||
|
|
||||||
|
**完成标准**:改环境变量能改变配置;`uv run ruff check .` 通过。
|
||||||
|
|
||||||
|
### 第 2 天:OpenAI 兼容客户端
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- `/chat/completions` 请求和响应的结构(`messages`、`choices[0].message.content`、`usage`)。
|
||||||
|
- 超时分两种:连不上和等太久;整体超时用 `httpx.Timeout`。
|
||||||
|
- 错误分类:哪些重试(超时、429 限流、5xx、连接失败),哪些不重试(401/403 鉴权、400 参数、额度不足)。
|
||||||
|
- 指数退避:第 n 次重试前等 `base * 2**n` 秒,加一点随机抖动。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `src/agent_mini/llm/base.py`:
|
||||||
|
- `class ChatMessage(BaseModel)`:`role: Literal["system", "user", "assistant"]`、`content: str`。
|
||||||
|
- `class LLMResult(BaseModel)`:`content`、`model`、`prompt_tokens: int | None`、`completion_tokens: int | None`、`latency_ms: int`。
|
||||||
|
- `class LLMClient(Protocol)`:`async def chat(self, messages: list[ChatMessage], *, model: str | None = None, temperature: float = 0.3) -> LLMResult`;`async def aclose(self) -> None`。
|
||||||
|
- `src/agent_mini/llm/openai_compat.py`:
|
||||||
|
- `class OpenAICompatClient`:构造时创建 `httpx.AsyncClient(base_url, timeout, headers)`,可注入 `transport` 方便测试。
|
||||||
|
- `async def chat(...)`:发请求、计时、解析;按上面的分类转成 `AgentError`。429 时看响应体里有没有额度相关的错误类型(不同服务商字段不同,先按你实际用的服务商处理,并在注释里说明),有就是 `LLM_QUOTA_EXCEEDED`(不重试),否则 `LLM_RATE_LIMITED`(重试)。响应结构不对时 `LLM_BAD_OUTPUT`。
|
||||||
|
- `def _map_error(resp: httpx.Response) -> AgentError`:单独一个函数,方便测试。
|
||||||
|
- 日志里记录模型名、耗时、重试次数,**不记录 API Key 和完整提示词**。
|
||||||
|
|
||||||
|
**完成标准**:能说清楚每种错误为什么重试或不重试。
|
||||||
|
|
||||||
|
### 第 3 天:FakeLLM、测试和 `ask` 命令
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- 为什么需要假模型:测试要快、要稳定、不花钱;演示不依赖密钥和网络;可以故意制造故障。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `src/agent_mini/llm/fake.py`:
|
||||||
|
- `class FakeLLM`:实现 `LLMClient` 协议。
|
||||||
|
- 根据 system 消息里的角色标记(比如 `[ROLE:critic]`)决定回复:协调者按“提案 → 评审 → 修改 → 评审”的顺序输出 JSON 决定;评审者第一次 `VERDICT: REVISE`、第二次 `VERDICT: APPROVE`。
|
||||||
|
- 参数 `delay_s`(每次调用等待多久,演示崩溃恢复用)、`fail_plan: dict[int, ErrorCode]`(第几次调用抛什么错误)、`calls: list` 记录每次调用,方便测试断言。
|
||||||
|
- `src/agent_mini/llm/__init__.py`:`def build_llm(settings) -> LLMClient`。
|
||||||
|
- `tests/test_llm_openai_compat.py`,用 `httpx.MockTransport`:
|
||||||
|
- 200 正常解析出 `content` 和 `usage`。
|
||||||
|
- 401 → `LLM_AUTH`,只请求了 1 次。
|
||||||
|
- 429 额度不足 → `LLM_QUOTA_EXCEEDED`,只请求了 1 次。
|
||||||
|
- 429 限流两次后 200 → 成功,请求了 3 次。
|
||||||
|
- 一直超时 → `LLM_TIMEOUT`,请求了 `1 + max_retries` 次。
|
||||||
|
- 响应里没有 `choices` → `LLM_BAD_OUTPUT`。
|
||||||
|
- 测试里把退避时间设为 0,别让测试变慢。
|
||||||
|
- `src/agent_mini/cli.py`:先实现 `agent-mini ask "问题"`:调一次模型,打印回复、耗时、token 数;出错时打印错误码和中文提示,退出码非 0。
|
||||||
|
|
||||||
|
**运行方式**:`uv run pytest -v`;`uv run agent-mini ask "你好"`(fake);配置好 `.env` 后用真实模型再试一次。
|
||||||
|
|
||||||
|
**M1 验收**
|
||||||
|
- [ ] 6 个客户端测试全部通过
|
||||||
|
- [ ] 真实模型调用一次成功;故意填错 Key,看到 `LLM_AUTH` 和中文提示
|
||||||
|
- [ ] 能说清楚错误码设计如何对应你在工作中遇到的“额度 / 鉴权失败”问题(对应案例卡 D 或 E)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 9 周 · M2:多轮编排循环(第 4~7 天)
|
||||||
|
|
||||||
|
### 第 4 天:数据模型和输出解析
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `src/agent_mini/models.py`:
|
||||||
|
- `class RunStatus(StrEnum)`:6 个状态;`TERMINAL = {completed, failed, cancelled}`。
|
||||||
|
- `class Decision(BaseModel)`:`action: Literal["dispatch", "ask_human", "finish"]`、`target: Literal["proposer", "critic"] | None`、`instruction: str = ""`、`question_for_human: str | None = None`、`reason: str`;用 `model_validator` 保证 `dispatch` 必须有 `target`、`ask_human` 必须有 `question_for_human`。
|
||||||
|
- `class Turn(BaseModel)`:`step`、`role`、`instruction`、`content`、`decision_json`、`model`、`latency_ms`。
|
||||||
|
- `class Run(BaseModel)`:对应 `runs` 表的字段。
|
||||||
|
- `class Event(BaseModel)`:`seq`、`run_id`、`type`、`data: dict`。
|
||||||
|
- `src/agent_mini/parsing.py`:
|
||||||
|
- `def extract_json(text: str) -> str`:处理代码块包裹、前后多余文字(第 8 周第 5 天写过类似的)。
|
||||||
|
- `def parse_decision(text: str) -> Decision`:失败抛 `AgentError(LLM_BAD_OUTPUT)`。
|
||||||
|
- `def parse_verdict(text: str) -> Literal["APPROVE", "REVISE"] | None`:取最后一个 `VERDICT:` 行。
|
||||||
|
- `tests/test_parsing.py`:参数化测试,至少 8 种输入。
|
||||||
|
|
||||||
|
### 第 5 天:角色和提示词
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- 角色 = system 提示词 + 能看到的上下文 + 输出格式要求。
|
||||||
|
- 上下文控制:专家只看到问题、之前的发言摘要和这次的指令;历史太长时只保留最近几步(先写死保留最近 6 步,在 README 里说明这是简化)。
|
||||||
|
- 结构化输出:协调者必须只输出 JSON,提示词里给出格式和例子。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `src/agent_mini/roles/base.py`:
|
||||||
|
- `def format_history(turns: list[Turn], limit: int = 6) -> str`
|
||||||
|
- `class Specialist`:`name`、`system_prompt`;`async def act(self, llm, question, turns, instruction, model) -> Turn`。
|
||||||
|
- `src/agent_mini/roles/proposer.py`:提案者,给出或修改方案,要求分点、具体。
|
||||||
|
- `src/agent_mini/roles/critic.py`:评审者,指出风险和遗漏,最后一行必须是 `VERDICT: APPROVE` 或 `VERDICT: REVISE`。
|
||||||
|
- `src/agent_mini/roles/coordinator.py`:
|
||||||
|
- `async def decide(self, llm, question, turns, round, max_rounds, model) -> Decision`:解析失败时把错误信息附在消息末尾重试 1 次。
|
||||||
|
- `async def summarize(self, llm, question, turns, end_reason, model) -> str`:输出最终结论。
|
||||||
|
- 所有提示词放在各自文件顶部的常量里,带 `[ROLE:xxx]` 标记(FakeLLM 靠它识别角色)。提示词全部自己写,不复制公司的。
|
||||||
|
|
||||||
|
### 第 6 天:编排循环(内存版)
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- 把“做一步”和“循环”分开:`step()` 是纯逻辑,方便测试;循环只负责反复调用 `step()` 并检查结束条件。下周加持久化时只改循环,不改 `step()`。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `src/agent_mini/orchestrator.py`:
|
||||||
|
- `class StepOutcome(BaseModel)`:`decision: Decision`、`turn: Turn | None`、`new_status: RunStatus`、`end_reason: str | None`、`final_answer: str | None`。
|
||||||
|
- `class Orchestrator`:持有 `llm` 和三个角色。
|
||||||
|
- `async def step(self, run: Run, turns: list[Turn]) -> StepOutcome`,规则按顺序:
|
||||||
|
1. 最近一次评审是 `APPROVE` → 直接 `summarize`,`completed`,结束原因“评审通过”。
|
||||||
|
2. `run.round >= run.max_rounds` → 直接 `summarize`,`completed`,结束原因“达到最大轮数”。
|
||||||
|
3. 否则调 `coordinator.decide`:`dispatch` → 调对应专家,`round + 1`;`ask_human` → `awaiting_input`;`finish` → `summarize`,`completed`。
|
||||||
|
- `async def run_in_memory(self, question: str, max_rounds: int, on_event) -> Run`:内存里循环调用 `step()`,每步调用 `on_event` 回调。
|
||||||
|
- `cli.py`:新增 `agent-mini run "问题" --max-rounds 6 --memory`,每步打印“第几步 / 谁 / 指令 / 回答前 200 字”,最后打印结论。
|
||||||
|
|
||||||
|
**运行方式**:`uv run agent-mini run "5 人创业团队做内部工具该选什么技术栈" --memory`
|
||||||
|
|
||||||
|
### 第 7 天:编排测试和真实模型试跑
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `tests/test_orchestrator.py`(全部用 FakeLLM):
|
||||||
|
- 评审第二次通过 → 正常结束,步骤顺序是 proposer、critic、proposer、critic。
|
||||||
|
- 评审一直不通过 → 在 `max_rounds` 结束,结束原因是“达到最大轮数”。
|
||||||
|
- 协调者第一次输出乱码、第二次正常 → 成功;两次都乱码 → `LLM_BAD_OUTPUT`。
|
||||||
|
- 协调者要求问人 → 状态 `awaiting_input`,`question_for_human` 有值。
|
||||||
|
- 协调者派给不存在的角色 → 解析阶段就报错(`Literal` 校验)。
|
||||||
|
- 用真实模型跑 3 个不同题目,把输出存到 `docs/samples/`(检查没有敏感信息),观察协调者有没有不按格式输出、评审是否太容易通过,调整提示词。
|
||||||
|
|
||||||
|
**M2 验收**
|
||||||
|
- [ ] `uv run pytest` 全部通过
|
||||||
|
- [ ] 命令行能用 fake 和真实模型各跑完一次多轮讨论
|
||||||
|
- [ ] 能说清楚“结束条件为什么要写在代码里”(对应重点 A)
|
||||||
|
- [ ] 提交并推送;在总纲进度记录里勾掉第 9 周
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 10 周 · M3:持久化 + 续跑 + 后台作业 + SSE
|
||||||
|
|
||||||
|
### 第 1 天:SQLite 存储层
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- aiosqlite 用法;SQLite 开启 WAL 模式(`PRAGMA journal_mode=WAL`)让读写不互相卡住;`PRAGMA foreign_keys=ON`。
|
||||||
|
- 事务:`BEGIN IMMEDIATE ... COMMIT`。
|
||||||
|
- 幂等写入:`INSERT ... ON CONFLICT(run_id, step) DO NOTHING`(第 3 周学过 PostgreSQL 的写法,SQLite 语法几乎一样)。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `uv add aiosqlite`
|
||||||
|
- `src/agent_mini/store/schema.sql`:见上面“数据库表”。
|
||||||
|
- `src/agent_mini/store/base.py`:`class RunStore(Protocol)`,方法:
|
||||||
|
- `create_run(question, max_rounds, model) -> Run`
|
||||||
|
- `get_run(run_id) -> Run`(找不到抛 `RUN_NOT_FOUND`)、`list_runs(limit) -> list[Run]`、`list_turns(run_id) -> list[Turn]`
|
||||||
|
- `commit_step(run_id, worker_id, outcome: StepOutcome, events: list[tuple[str, dict]]) -> None`:一个事务里写 turn、更新 run、追加事件;**先检查租约还是自己的**,不是就抛 `RUN_LEASE_LOST`,什么都不写。
|
||||||
|
- `try_acquire(run_id, worker_id, lease_seconds) -> bool`、`renew_lease(...) -> bool`、`release(run_id, worker_id)`
|
||||||
|
- `find_resumable(now) -> list[str]`:状态是 `queued` 或 `running` 且租约为空或已过期的任务。
|
||||||
|
- `set_status(run_id, status, **fields)`、`append_event(run_id, type, data) -> int`、`list_events(run_id, after_seq) -> list[Event]`
|
||||||
|
- `src/agent_mini/store/sqlite_store.py`:`class SqliteRunStore`,`async def open(path)` 时执行建表和 PRAGMA。
|
||||||
|
- `tests/test_sqlite_store.py`(用 `tmp_path` 下的临时数据库):
|
||||||
|
- 同一步 `commit_step` 两次,`turns` 只有一行。
|
||||||
|
- 两个 worker 抢同一个任务,只有一个 `try_acquire` 成功;租约过期后另一个能抢到。
|
||||||
|
- 租约被别人抢走后,原 worker 的 `commit_step` 抛 `RUN_LEASE_LOST`,数据库没有变化。
|
||||||
|
- `list_events(after_seq)` 只返回之后的事件,`seq` 递增。
|
||||||
|
|
||||||
|
### 第 2 天:带持久化的编排循环
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `src/agent_mini/orchestrator.py` 新增:
|
||||||
|
- `async def drive(self, store, run_id, worker_id, should_stop) -> RunStatus`:
|
||||||
|
1. 从数据库读 `run` 和 `turns`(**每次恢复都从数据库重建状态,不依赖内存**)。
|
||||||
|
2. 循环:检查 `should_stop()` 和数据库里的状态(被取消就退出)→ `step()` → `commit_step()` → 续租。
|
||||||
|
3. `AgentError` 时把任务置为 `failed`,写 `error_code`、`error_message`,追加 `run_failed` 事件。
|
||||||
|
4. 结束、等人、失败时释放租约。
|
||||||
|
- `src/agent_mini/report.py`:`def build_report(run, turns) -> str`:Markdown,包含问题、每一步(角色、指令、回答、模型、耗时)、结束原因、最终结论、生成时间。报告完全由数据库数据拼出,不再调模型,这样结果可追溯(对应重点 E)。
|
||||||
|
- `cli.py`:`agent-mini run "问题"`(不带 `--memory` 时写数据库,打印 run_id)、`agent-mini show <run_id>`(打印报告)、`agent-mini list`。
|
||||||
|
|
||||||
|
**运行方式**:`uv run agent-mini run "..."`,然后 `uv run agent-mini show <run_id>`。
|
||||||
|
|
||||||
|
### 第 3 天:RunManager 后台作业和看门狗
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- 后台任务和 HTTP 请求分开:请求只负责创建任务并返回,真正的编排由后台任务执行。
|
||||||
|
- 看门狗:定期扫描“该有人跑但没人跑”的任务(服务刚启动、别的进程崩溃后租约过期)。
|
||||||
|
- 优雅关机:停止看门狗、取消后台任务、释放租约。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `src/agent_mini/events.py`:`class EventNotifier`:每个 run 一个 `asyncio.Event`,`notify(run_id)` 唤醒等待的 SSE 连接,`wait(run_id, timeout)` 等待。只是加速,没有它 SSE 也能靠轮询数据库工作。
|
||||||
|
- `src/agent_mini/runner.py`:`class RunManager`:
|
||||||
|
- `worker_id`:进程启动时生成(主机名 + 进程号 + 随机串)。
|
||||||
|
- `async def submit(run_id)`:`try_acquire` 成功才 `create_task(self._drive(run_id))`,任务对象存到 `self._tasks` 字典,完成后自动移除。
|
||||||
|
- `async def _drive(run_id)`:调用 `orchestrator.drive`,捕获所有异常并记录日志,保证不会让后台任务静默死掉。
|
||||||
|
- `async def watchdog()`:每 `lease_seconds / 3` 秒执行一次 `find_resumable` → `submit`。
|
||||||
|
- `async def cancel(run_id)`:数据库置为 `cancelled` + 取消本进程里对应的任务。
|
||||||
|
- `async def shutdown()`。
|
||||||
|
|
||||||
|
### 第 4 天:崩溃恢复测试和演示脚本
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `tests/test_runner_resume.py`:
|
||||||
|
- 用 FakeLLM(`delay_s=0.2`)和短租约(1 秒)启动 manager A 跑任务;跑完 2 步后直接取消 A 的后台任务且**不释放租约**(模拟进程被杀)。
|
||||||
|
- 启动 manager B(新的 `worker_id`),等待租约过期,看门狗把任务领走并跑完。
|
||||||
|
- 断言:最终 `completed`;`turns` 的 `step` 从 1 开始连续、没有重复;事件里出现过两个不同的 worker。
|
||||||
|
- 再测一个:A 还活着时 B 不能抢走任务。
|
||||||
|
- `scripts/demo_crash_resume.sh`:
|
||||||
|
1. 用 `FAKE_DELAY_S=2` 后台启动服务,记下进程号;
|
||||||
|
2. `curl -X POST /runs` 创建任务;
|
||||||
|
3. 等 5 秒,`kill -9` 杀掉服务;
|
||||||
|
4. 打印此时数据库里的步骤数;
|
||||||
|
5. 重新启动服务,轮询 `/runs/{id}` 直到 `completed`;
|
||||||
|
6. 打印报告,显示步骤连续不重复。
|
||||||
|
|
||||||
|
**运行方式**:`uv run pytest tests/test_runner_resume.py -v`;`bash scripts/demo_crash_resume.sh`
|
||||||
|
|
||||||
|
### 第 5 天:FastAPI 接口
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `uv add fastapi uvicorn`
|
||||||
|
- `src/agent_mini/api/app.py`:
|
||||||
|
- `def create_app(settings=None, llm=None) -> FastAPI`:可以注入 FakeLLM 方便测试。
|
||||||
|
- `lifespan`:打开存储 → 创建 `RunManager` → 启动看门狗 → `yield` → `shutdown()` → 关闭存储和 LLM 客户端。
|
||||||
|
- 全局异常处理:`AgentError` 转成统一错误响应(`RUN_NOT_FOUND` → 404,`RUN_INVALID_STATE` → 409)。
|
||||||
|
- `src/agent_mini/api/routes_runs.py`:接口表里除 SSE 外的所有接口。
|
||||||
|
- `POST /runs/{id}/input`:状态必须是 `awaiting_input`;把回答写成一条 `role=human` 的 turn,状态改回 `queued`,追加 `input_received` 事件,再 `submit`。
|
||||||
|
- `POST /runs/{id}/retry`:状态必须是 `failed`;可选更换 `model`;清掉错误字段,状态改为 `queued`,`submit`。
|
||||||
|
- `cli.py`:`agent-mini serve --port 8000`(内部调用 uvicorn)。
|
||||||
|
|
||||||
|
**运行方式**:`uv run agent-mini serve`,打开 `http://localhost:8000/docs` 用自动生成的接口文档测试。
|
||||||
|
|
||||||
|
### 第 6 天:SSE
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- SSE 每条消息的 `id:` 用 `events.seq`;浏览器重连时自动带 `Last-Event-ID`,服务端从这个序号之后补发。
|
||||||
|
- 心跳:长时间没有事件时发 `: ping`,避免中间的代理断开空闲连接。
|
||||||
|
- 连接断开不影响任务:SSE 只是“读”,编排在 RunManager 里。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `src/agent_mini/api/sse.py`:`GET /runs/{id}/events`:
|
||||||
|
- 起点:`Last-Event-ID` 请求头 > `?after=` 参数 > 0。
|
||||||
|
- 循环:从数据库取 `seq > last` 的事件逐条发出 → 任务已是终态且没有新事件,发 `event: end` 后结束 → 否则 `notifier.wait(run_id, timeout=15)`,超时就发 `: ping`。
|
||||||
|
- 每轮检查 `request.is_disconnected()`,断开就退出生成器。
|
||||||
|
- 响应头:`Cache-Control: no-cache`、`X-Accel-Buffering: no`(经过 Nginx 时不缓冲)。
|
||||||
|
- `tests/test_api_sse.py`(用 `with TestClient(create_app(llm=FakeLLM())) as client:` 触发 lifespan):
|
||||||
|
- 创建任务 → 读 SSE 直到 `end`,事件顺序符合预期,最后一个业务事件是 `run_completed`。
|
||||||
|
- 带 `Last-Event-ID: 3` 再读一次,只收到 `seq > 3` 的事件。
|
||||||
|
- 不存在的 run 返回 404。
|
||||||
|
- `ask_human` 场景:状态变成 `awaiting_input` 后调 `/input`,任务能继续跑完。
|
||||||
|
|
||||||
|
### 第 7 天:极简页面和端到端走查
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `web/index.html`(原生 JS,不超过 200 行;你熟悉 Vue / React 的话之后可以重写,但本周先用最简单的):
|
||||||
|
- 输入问题 → `POST /runs` → 用 `EventSource` 订阅。
|
||||||
|
- 时间线:每一步显示角色、指令、回答;结束后显示结论和“查看报告”链接。
|
||||||
|
- `awaiting_input` 时出现输入框,提交到 `/input`。
|
||||||
|
- `run_failed` 时显示错误码、中文提示和“重试”按钮(可填新模型名)。
|
||||||
|
- 显示连接状态(已连接 / 重连中)。
|
||||||
|
- `api/app.py`:`GET /` 返回这个页面。
|
||||||
|
|
||||||
|
**手动走查**:
|
||||||
|
1. 正常跑完一次;
|
||||||
|
2. 跑到一半关掉标签页,过一会儿打开同一任务,能看到完整时间线;
|
||||||
|
3. 跑到一半 `Ctrl+C` 停服务再启动,页面自动重连后继续收到事件;
|
||||||
|
4. 用错误的 Key 制造 `LLM_AUTH` 失败,修好配置后点“重试”成功。
|
||||||
|
|
||||||
|
**M3 验收**
|
||||||
|
- [ ] `uv run pytest` 全部通过,包括崩溃恢复和 SSE 测试
|
||||||
|
- [ ] `demo_crash_resume.sh` 演示成功
|
||||||
|
- [ ] 走查 4 个场景全部符合预期
|
||||||
|
- [ ] 能说清楚:权威数据在哪(B)、为什么编排不能放在浏览器里(C)、什么时候停下来问人(D)、结果如何变成可审计的报告(E)、怎么防止双跑(A)
|
||||||
|
- [ ] 提交并推送;在总纲进度记录里勾掉第 10 周
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 11 周 · M4:文档、架构图、映射表
|
||||||
|
|
||||||
|
### 第 1 天:架构图
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `docs/architecture.md`,三张 mermaid 图:
|
||||||
|
1. 组件图:浏览器 → FastAPI(routes / SSE)→ RunManager → Orchestrator → 角色 → LLMClient;存储层和 EventNotifier 的位置。
|
||||||
|
2. 状态图(`stateDiagram-v2`):6 个状态和所有转换条件。
|
||||||
|
3. 时序图(`sequenceDiagram`):创建任务 → 跑两步 → 进程崩溃 → 新进程看门狗领取 → 续跑 → SSE 重连补发。
|
||||||
|
- 回头对照第 5 周画的 `notes/01-architecture.md`(公司系统的脱敏图),在 `architecture.md` 末尾写一段“和生产系统相比,我简化了什么”。
|
||||||
|
|
||||||
|
### 第 2 天:README 主体
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `README.md`:
|
||||||
|
- 一句话介绍 + 动图或截图(页面时间线、崩溃恢复演示)。
|
||||||
|
- 功能列表(对应上面“目标”的 5 条)。
|
||||||
|
- **10 分钟快速开始**:安装 uv → `git clone` → `uv sync` → `cp .env.example .env` → `uv run agent-mini serve` → 打开页面。默认用 FakeLLM,不需要任何密钥;再写怎么切换到真实模型。
|
||||||
|
- 配置项表。
|
||||||
|
- 接口表。
|
||||||
|
- 运行测试:`uv run pytest`。
|
||||||
|
- 崩溃恢复演示:`bash scripts/demo_crash_resume.sh`。
|
||||||
|
- 项目结构(简版)。
|
||||||
|
|
||||||
|
### 第 3 天:映射表和设计取舍
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `docs/mapping.md`,同时摘进 README(至少 5 行,生产一栏只写通用概念,不写公司接口名、表名):
|
||||||
|
|
||||||
|
| 生产系统里的概念(通用叫法) | 本项目里的实现 | 对应重点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 席位 / 专家角色 | `roles/proposer.py`、`roles/critic.py` | A |
|
||||||
|
| 主持人派活 | `Coordinator.decide()` 返回 `Decision(action="dispatch")` | A |
|
||||||
|
| 共识 / 结束条件 | `VERDICT: APPROVE` + 最大轮数,规则写在 `Orchestrator.step()` | A |
|
||||||
|
| 草稿(编排中间状态) | `runs` + `turns` 表,每步一个事务提交 | B |
|
||||||
|
| 后台作业 | `RunManager` 后台任务 + 租约 + 看门狗 | A / C |
|
||||||
|
| 流式输出 | `GET /runs/{id}/events`,按 `seq` 断点补发 | C |
|
||||||
|
| 意图澄清 / 等待用户输入 | `ask_human` → `awaiting_input` → `POST /input` | D |
|
||||||
|
| 换模型恢复 | 错误码 + `POST /retry {model}` | D |
|
||||||
|
| 过程报告 | `report.py` 从数据库拼出的 Markdown | E |
|
||||||
|
|
||||||
|
- `docs/design-decisions.md`,每条写“选择 / 理由 / 代价”:
|
||||||
|
1. 编排放在服务端后台,而不是浏览器循环(关页、刷新、断网都不影响)。
|
||||||
|
2. 每步一个事务 + `(run_id, step)` 主键:写入恰好一次,模型调用至少一次。
|
||||||
|
3. 租约而不是进程内锁:多进程、多机器也不会双跑;代价是崩溃后要等租约过期才恢复。
|
||||||
|
4. 数据库是权威,SSE 只是视图:断线重连靠 `seq` 补发。
|
||||||
|
5. 结束条件写在代码里,不只靠模型判断。
|
||||||
|
6. SQLite 而不是 PostgreSQL:方便别人跑;存储层有协议隔离,换 PostgreSQL 只需新写一个实现(可选练习:用第 3 周学的 PostgreSQL 写 `pg_store.py`)。
|
||||||
|
7. 没做的事(诚实写):token 级流式输出、多实例下的事件通知(目前靠轮询兜底)、鉴权、评测。
|
||||||
|
|
||||||
|
### 第 4 天:从零验证
|
||||||
|
|
||||||
|
- 在一个新目录(最好是另一台电脑,或者请朋友)`git clone` 下来,严格按 README 操作并计时,目标 10 分钟内跑通。
|
||||||
|
- 遇到的每个卡点都改进 README 或代码。
|
||||||
|
- `uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest` 全部通过。
|
||||||
|
- 最后检查一遍脱敏:`git grep` 搜公司名、内网域名、IP、密钥前缀,确认没有。
|
||||||
|
|
||||||
|
### 第 5 天:演示和讲解准备
|
||||||
|
|
||||||
|
- 录一段 2~3 分钟的演示(屏幕录制即可):提问 → 看时间线 → 杀进程 → 重启续跑 → 查看报告。
|
||||||
|
- 写 `docs/talk.md`:3 分钟讲解稿,结构是“问题(为什么多 Agent 难做可靠)→ 设计(状态机、权威源、后台作业、租约)→ 演示 → 取舍”,并引用 1~2 张案例卡说明“这些设计来自我在工作中踩过的坑”。这份稿子第 12 周会再压缩成面试口述稿。
|
||||||
|
|
||||||
|
### 第 6~7 天:缓冲 / 可选扩展
|
||||||
|
|
||||||
|
先确认 M1~M4 的验收全部完成。**没完成就用这两天补,不做下面的扩展。**
|
||||||
|
|
||||||
|
可选 M5(二选一,做一个就够,复用第 1 个月的 Go 技能):
|
||||||
|
|
||||||
|
- **Go 网关**:新建 `zhuangang/agent-gateway/`,Gin 服务:
|
||||||
|
- 复用 `task-board` 的 `auth` 包做登录和 JWT 校验;
|
||||||
|
- `/api/runs/*` 用 `httputil.ReverseProxy` 转发到 Python 服务,设置 `FlushInterval = -1` 让 SSE 不被缓冲;
|
||||||
|
- 转发时加上 `X-User-ID` 请求头,Python 端 `runs` 表加 `owner_id`,只能看自己的任务。
|
||||||
|
- 讲点:“Go 做接入层(鉴权、限流、转发),Python 做 Agent 编排”。
|
||||||
|
- **Vue / React 前端**:用你最熟悉的框架重写 `web/index.html`,加上任务列表、报告页、错误提示,体现前端功底。
|
||||||
|
|
||||||
|
**M4 验收(也是阶段 3 出门条件)**
|
||||||
|
- [ ] 别人按 README 10 分钟内跑通(不需要真实模型密钥)
|
||||||
|
- [ ] 演示:启动 →(中途杀进程)→ 重启 → 最终结果可查
|
||||||
|
- [ ] 映射表至少 5 个概念对齐生产
|
||||||
|
- [ ] 架构图和 README 一致,并归档到总纲要求的位置
|
||||||
|
- [ ] 测试、ruff、pyright 全部通过,脱敏检查完成
|
||||||
|
- [ ] 提交并推送;在总纲进度记录里勾掉第 11 周和“阶段 3”,填上“迷你项目路径:`zhuangang/agent-mini/`”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 和案例卡的关系
|
||||||
|
|
||||||
|
第 6~7 周每张案例卡都有一栏“若重做的设计”。动手前把这些结论列出来,逐条对应到本项目的某个模块或某条设计取舍;对应不上的,在 `docs/design-decisions.md` 里说明为什么这次没做。面试时就能讲成:“我在工作中遇到 X 问题,根因是 Y,所以在自己的项目里用 Z 解决,这是代码和演示。”
|
||||||
|
|
||||||
|
## 附:如果阶段 0 改选了 LangGraph
|
||||||
|
|
||||||
|
只在第 5 周盘点时明确改选 LangGraph 才看这一节,**不要两个都做**。概念大致对应如下(具体 API 以 LangGraph 官方文档为准):
|
||||||
|
|
||||||
|
| 自写版 | LangGraph 里的对应 |
|
||||||
|
| --- | --- |
|
||||||
|
| `Orchestrator.step()` 的规则 | 图的节点 + 条件边 |
|
||||||
|
| `Run` / `turns` 状态 | 图的 State(TypedDict 或 pydantic 模型) |
|
||||||
|
| `commit_step` 每步持久化 | Checkpointer(有 SQLite / PostgreSQL 实现) |
|
||||||
|
| 崩溃后续跑 | 用同一个 `thread_id` 从最近的检查点继续 |
|
||||||
|
| `ask_human` / `awaiting_input` | `interrupt()` 暂停,之后带上人的回答恢复 |
|
||||||
|
| SSE 事件 | 图的流式输出(`astream`)转成 SSE |
|
||||||
|
| 租约防双跑、看门狗 | 框架不直接管,仍要自己在任务表上实现 |
|
||||||
|
|
||||||
|
M1(LLM 封装和错误码)、M3 的接口和 SSE、M4 的文档和映射表都照做。
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# 转岗计划 · 第 1 周学习计划(Go 基础语法)
|
||||||
|
|
||||||
|
> 所属阶段:第 1 个月 Go 全栈基础。总纲见同目录 `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 接上去。
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# 转岗计划 · 第 2 周学习计划(Gin 写 HTTP 接口 + 接前端)
|
||||||
|
|
||||||
|
> 所属阶段:第 1 个月 Go 全栈基础。总纲见同目录 `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 生成数据访问代码。因为本周用接口隔离了存储层,下周只需要新写一个实现,接口代码基本不用动。
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# 转岗计划 · 第 3 周学习计划(PostgreSQL + SQL + sqlc)
|
||||||
|
|
||||||
|
> 所属阶段:第 1 个月 Go 全栈基础。总纲见同目录 `README.md`,上周计划 `week02-gin-api.md`,下周计划 `week04-auth-project1.md`。
|
||||||
|
|
||||||
|
目标:把第 2 周任务清单里的 `MemoryStore` 换成 PostgreSQL。学会表结构设计、常用 SQL、索引、事务和数据库迁移,用 sqlc 生成类型安全的数据访问代码。因为上周用接口隔离了存储层,本周主要是新写一个 `PgStore`,接口代码基本不用动。
|
||||||
|
|
||||||
|
前置条件:第 2 周的 `todo-api` 已完成,`go test ./... -race` 能通过,前端能联调。
|
||||||
|
|
||||||
|
要求:SQL 自己写,AI 只帮你检查和解释。本周是你在 Agent 项目里设计状态表的基础,表结构设计这一块一定要自己想清楚。
|
||||||
|
|
||||||
|
参考资料:
|
||||||
|
- 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` 做“存在就跳过或更新”
|
||||||
|
- [ ] 写了能自动跳过的集成测试
|
||||||
|
- [ ] 会做游标分页
|
||||||
|
|
||||||
|
下周预告:第 4 周在这套基础上做项目一“团队任务看板”:注册登录、JWT、看板成员权限、按状态分列的任务,前后端完整联调。
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# 转岗计划 · 第 4 周学习计划(注册登录 + JWT + 权限 → 项目一:团队任务看板)
|
||||||
|
|
||||||
|
> 所属阶段:第 1 个月 Go 全栈基础的收官周。总纲见同目录 `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 周和“第 1 个月 Go 全栈基础”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本周自查清单
|
||||||
|
|
||||||
|
- [ ] 能说清楚 bcrypt 为什么适合存密码
|
||||||
|
- [ ] 能说清楚 JWT 的结构、优缺点,以及载荷不加密
|
||||||
|
- [ ] 会写鉴权中间件,分得清 401 和 403
|
||||||
|
- [ ] 权限判断放在后端 service 层,并有接口测试覆盖
|
||||||
|
- [ ] 会用事务处理“多步写入必须一起成功”的场景
|
||||||
|
- [ ] 前端有统一请求封装、401 处理和路由守卫
|
||||||
|
- [ ] 项目一 README 完整,别人能从零跑起来
|
||||||
|
- [ ] 能用 3 分钟讲清楚项目一
|
||||||
|
|
||||||
|
下一步:第 5 周进入第 2 个月,先做阶段 0 盘点和阶段 1 读通画图,具体见总纲 `README.md` 第 4 节。第 1 个月学到的分层、事务、`ON CONFLICT` 幂等写入、鉴权,在第 9~11 周的 Agent 项目里都会再用到。
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# 转岗计划 · 第 8 周学习计划(Python 速成:给 JS 开发者)
|
||||||
|
|
||||||
|
> 所属阶段:第 2 个月最后一周,为第 9~11 周的 Python 迷你多 Agent 项目做准备。总纲见同目录 `README.md`,项目计划见 `agent-mini-project.md`。
|
||||||
|
|
||||||
|
目标:一周内把写 Agent 服务需要的 Python 补齐:基础语法、类型标注、asyncio、pydantic、httpx、pytest、FastAPI 和 SSE。不追求全面,只学项目里马上会用到的。
|
||||||
|
|
||||||
|
学法:你有 7 年 JS 和一个月 Go 的底子,每个知识点都先想“JS / Go 里对应的是什么”,再看 Python 的写法。每天的练习文件第一段写一张小对照表。
|
||||||
|
|
||||||
|
本周只学 Python,不开 Agent 项目(戒律:同一周不同时开新框架和新功能)。
|
||||||
|
|
||||||
|
参考资料:
|
||||||
|
- Python 官方教程(中文):https://docs.python.org/zh-cn/3/tutorial/
|
||||||
|
- asyncio 官方文档(中文):https://docs.python.org/zh-cn/3/library/asyncio.html
|
||||||
|
- uv:https://docs.astral.sh/uv/
|
||||||
|
- pydantic:https://docs.pydantic.dev/
|
||||||
|
- FastAPI(中文):https://fastapi.tiangolo.com/zh/
|
||||||
|
- httpx:https://www.python-httpx.org/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最终项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
py-learning/
|
||||||
|
├── pyproject.toml # uv 管理依赖
|
||||||
|
├── .python-version
|
||||||
|
├── day1_basics.py
|
||||||
|
├── day2_typing.py
|
||||||
|
├── day3_errors_files.py
|
||||||
|
├── day4_async.py
|
||||||
|
├── day5_models.py
|
||||||
|
├── day6_http.py
|
||||||
|
├── fastapi_demo/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py # FastAPI 应用
|
||||||
|
│ └── static/
|
||||||
|
│ └── sse.html # 用 EventSource 订阅 SSE 的测试页
|
||||||
|
└── tests/
|
||||||
|
├── test_day2.py
|
||||||
|
├── test_day5.py
|
||||||
|
├── test_day6.py
|
||||||
|
└── test_fastapi_demo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 1 天:环境和基础语法
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- 用 uv 管理 Python 版本和依赖(相当于 `nvm + npm` 合在一起):`curl -LsSf https://astral.sh/uv/install.sh | sh`。
|
||||||
|
- 缩进代替大括号;`None` / `True` / `False`;`list`、`dict`、`tuple`、`set`。
|
||||||
|
- f-string(相当于模板字符串)、切片 `a[1:3]`、解包 `a, *rest = items`。
|
||||||
|
- 列表推导式和字典推导式(相当于 `map` + `filter`)。
|
||||||
|
- 函数:默认参数、关键字参数、`*args`、`**kwargs`;默认参数不要用可变对象(`def f(x=[])` 是经典坑)。
|
||||||
|
- `if __name__ == "__main__":` 的作用。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `py-learning/`:运行 `uv init py-learning && cd py-learning && uv python pin 3.12`,生成 `pyproject.toml` 和 `.python-version`。
|
||||||
|
- `day1_basics.py`:
|
||||||
|
- 文件开头用注释写一张 JS ↔ Python 对照表(至少 10 行:`const/let`、`===`、`null/undefined`、`arr.map`、`arr.filter`、对象展开、解构、模板字符串、`for...of`、箭头函数)。
|
||||||
|
- `def word_count(text: str) -> dict[str, int]`:和第 1 周 Go 的 `WordCount` 做同一件事,对比代码长短。
|
||||||
|
- `def unique(items: list[str]) -> list[str]`:去重并保持原顺序。
|
||||||
|
- 演示可变默认参数的坑和正确写法(`def f(x=None)`)。
|
||||||
|
|
||||||
|
**运行方式**:`uv run python day1_basics.py`
|
||||||
|
|
||||||
|
**完成标准**:能说清楚 Python 的 `list` 和 `tuple` 区别,以及为什么默认参数不能用 `[]`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 2 天:类型标注、dataclass 和 Protocol
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- 类型标注只是提示,运行时不检查(和 TS 一样),要靠检查工具:`uv add --dev pyright ruff pytest`。
|
||||||
|
- `list[str]`、`dict[str, int]`、`X | None`、`Literal["a", "b"]`、`TypedDict`。
|
||||||
|
- `@dataclass`:相当于带构造函数的结构体。
|
||||||
|
- `Protocol`:Python 的“隐式接口”,和 Go 的 `interface` 思路一样,Agent 项目里 LLM 客户端和存储层都会用它。
|
||||||
|
- 类、`self`、`@property`、`@classmethod`。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `day2_typing.py`:
|
||||||
|
- `TaskStatus = Literal["todo", "doing", "done"]`
|
||||||
|
- `@dataclass class Task`:`id: int`、`title: str`、`status: TaskStatus = "todo"`、`tags: list[str] = field(default_factory=list)`。
|
||||||
|
- `class TaskStore(Protocol)`:`add(self, task: Task) -> None`、`list(self) -> list[Task]`。
|
||||||
|
- `class MemoryTaskStore`:不继承 `TaskStore`,但实现同样的方法(体会“隐式实现”)。
|
||||||
|
- `def count_by_status(tasks: list[Task]) -> dict[TaskStatus, int]`
|
||||||
|
- `tests/test_day2.py`:测试 `count_by_status`;用 `pytest.mark.parametrize` 写表格驱动测试(对应 Go 的表格驱动测试)。
|
||||||
|
|
||||||
|
**运行方式**:`uv run pytest -v`、`uv run pyright day2_typing.py`、`uv run ruff check .`
|
||||||
|
|
||||||
|
**完成标准**:故意把 `status` 写成 `"deleted"`,pyright 能报错;能说清楚 `Protocol` 和 Go 接口的相同点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 3 天:异常、上下文管理器和文件
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- `try / except / else / finally`,`raise ... from err`(相当于 Go 的 `%w` 包装错误)。
|
||||||
|
- 自定义异常层级:一个基类,下面分具体错误。
|
||||||
|
- `with` 语句(上下文管理器):自动关闭文件、连接,相当于 Go 的 `defer`。
|
||||||
|
- `pathlib.Path`、`json.loads` / `json.dumps`(注意 `ensure_ascii=False` 才能正常输出中文)。
|
||||||
|
- `logging` 基本用法。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `day3_errors_files.py`:
|
||||||
|
- `class AppError(Exception)`,带 `code: str` 属性;子类 `NotFoundError`、`ValidationError`。
|
||||||
|
- `def load_tasks(path: Path) -> list[dict]`:文件不存在返回空列表;JSON 格式错误时 `raise ValidationError(...) from err`。
|
||||||
|
- `def save_tasks(path: Path, tasks: list[dict]) -> None`:先写临时文件再 `replace`,避免写到一半崩溃导致文件损坏(Agent 项目里“写状态要原子”就是这个道理)。
|
||||||
|
- 用 `contextlib.contextmanager` 写一个 `timer(name)`,在 `with timer("load"):` 结束时打印耗时。
|
||||||
|
|
||||||
|
**运行方式**:`uv run python day3_errors_files.py`
|
||||||
|
|
||||||
|
**完成标准**:能说清楚 `raise X from err` 保留了什么信息,以及“先写临时文件再改名”为什么更安全。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 4 天:asyncio(本周最重要的一天)
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- `async def` / `await` 和 JS 很像,但要有事件循环:入口用 `asyncio.run(main())`。
|
||||||
|
- 和 JS 的区别:调用 `async` 函数只得到协程对象,**不会自动开始执行**,要 `await` 或 `asyncio.create_task`。
|
||||||
|
- `asyncio.gather`(相当于 `Promise.all`)、`asyncio.wait_for` / `asyncio.timeout`(超时)、`asyncio.Queue`(相当于 Go 的 channel)、`asyncio.Event`。
|
||||||
|
- 取消:`task.cancel()` 会在任务里抛出 `CancelledError`,要在 `finally` 里清理,**不要吞掉这个异常**。
|
||||||
|
- 异步生成器 `async def ... yield`,以及 `async for`:SSE 推送就是靠它。
|
||||||
|
- 不能在 `async` 函数里调用会阻塞的同步代码(比如 `time.sleep`、同步的数据库驱动),否则整个服务卡住;实在要用就 `await asyncio.to_thread(...)`。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `day4_async.py`:
|
||||||
|
- `async def fake_llm(name: str, delay: float) -> str`:`await asyncio.sleep(delay)` 后返回结果,模拟一次模型调用。
|
||||||
|
- `async def run_parallel()`:用 `gather` 同时调 3 个 `fake_llm`,打印总耗时,和顺序调用对比。
|
||||||
|
- `async def run_with_timeout()`:给一个 3 秒的调用设 1 秒超时,捕获 `TimeoutError` 并打印。
|
||||||
|
- `async def producer_consumer()`:一个生产者往 `Queue` 放 5 个任务,两个消费者处理,全部处理完后退出(`queue.join()`)。
|
||||||
|
- `async def ticker(n: int)`:异步生成器,每 0.5 秒 `yield` 一个数字;用 `async for` 消费,消费到第 3 个时取消,观察 `finally` 是否执行。
|
||||||
|
- 文件开头写对照表:`Promise.all` ↔ `gather`、`setTimeout` ↔ `asyncio.sleep`、Go 的 `go f()` ↔ `create_task`、Go 的 channel ↔ `Queue`、Go 的 `context` 取消 ↔ `task.cancel()`。
|
||||||
|
|
||||||
|
**运行方式**:`uv run python day4_async.py`
|
||||||
|
|
||||||
|
**完成标准**:能说清楚“调用 async 函数但没 await”会发生什么;能解释为什么在 async 服务里用同步阻塞调用会拖垮所有请求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 5 天:pydantic 数据模型
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- pydantic 是“带运行时校验的类型”,作用类似前端的 zod:外部进来的数据(请求体、模型输出的 JSON)都要先过一遍校验。
|
||||||
|
- `BaseModel`、`Field(min_length=..., max_length=...)`、默认值、`model_validate`、`model_validate_json`、`model_dump`、`model_dump_json`。
|
||||||
|
- `Literal` 字段和“按某个字段区分类型”的联合类型(discriminated union)。
|
||||||
|
- `ValidationError` 里能拿到哪个字段、为什么错。
|
||||||
|
- `pydantic-settings`:从环境变量和 `.env` 读配置。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `uv add pydantic pydantic-settings`
|
||||||
|
- `day5_models.py`:
|
||||||
|
- `class Decision(BaseModel)`:`action: Literal["dispatch", "finish", "ask_human"]`、`target: str | None = None`、`instruction: str = ""`、`reason: str`。(这就是第 9 周协调者输出的雏形)
|
||||||
|
- `def parse_decision(raw: str) -> Decision`:模型常常会在 JSON 外面包一层 ```` ```json ```` 代码块或多说几句话,先提取出第一个 `{...}` 再 `model_validate_json`;失败时抛出自定义的 `BadModelOutput`,错误信息里带上原始文本的前 200 个字。
|
||||||
|
- `class Settings(BaseSettings)`:`llm_base_url: str`、`llm_api_key: str = ""`、`llm_model: str`、`llm_timeout_s: float = 30`,从 `.env` 读取。
|
||||||
|
- `tests/test_day5.py`:参数化测试 `parse_decision`:正常 JSON、带代码块的 JSON、前后有废话的 JSON、`action` 拼错、缺少 `reason`、完全不是 JSON。
|
||||||
|
|
||||||
|
**运行方式**:`uv run pytest tests/test_day5.py -v`
|
||||||
|
|
||||||
|
**完成标准**:6 种输入都按预期通过或报错;能说清楚“为什么模型输出必须当成不可信的外部输入来校验”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 6 天:httpx 异步请求和测试
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- `httpx.AsyncClient`:设置 `base_url`、`timeout`、请求头;用 `async with` 管理生命周期,复用连接。
|
||||||
|
- 状态码和异常:`raise_for_status()`、`httpx.TimeoutException`、`httpx.ConnectError`。
|
||||||
|
- 测试时不真的发请求:`httpx.MockTransport` 可以按请求返回任意响应,用来模拟超时、429、500。
|
||||||
|
- pytest 跑异步测试:`uv add --dev pytest-asyncio`,在 `pyproject.toml` 里设置 `asyncio_mode = "auto"`。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `uv add httpx`
|
||||||
|
- `day6_http.py`:
|
||||||
|
- `class HttpError(Exception)`,带 `code` 和 `retryable: bool`。
|
||||||
|
- `async def get_json(client: httpx.AsyncClient, url: str, retries: int = 2) -> dict`:超时和 5xx、429 时等待后重试(等待时间逐次翻倍),401 / 400 不重试,最后抛出带错误码的 `HttpError`。
|
||||||
|
- `main()` 里请求一个公开接口(例如 `https://httpbin.org/json`)打印结果。
|
||||||
|
- `tests/test_day6.py`,用 `MockTransport` 测试:
|
||||||
|
- 200 直接返回。
|
||||||
|
- 前两次 503、第三次 200,最终成功,并断言一共请求了 3 次。
|
||||||
|
- 401 不重试,直接抛 `code == "AUTH"`。
|
||||||
|
- 一直超时,最终抛 `code == "TIMEOUT"`。
|
||||||
|
|
||||||
|
**运行方式**:`uv run python day6_http.py`、`uv run pytest tests/test_day6.py -v`
|
||||||
|
|
||||||
|
**完成标准**:四个测试都通过;能说清楚哪些错误值得重试、哪些不值得。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 7 天:FastAPI 基础和 SSE
|
||||||
|
|
||||||
|
**学习内容**
|
||||||
|
- FastAPI 路由、路径参数、查询参数,请求体直接用 pydantic 模型(对照第 2 周的 Gin)。
|
||||||
|
- 依赖注入 `Depends`。
|
||||||
|
- `lifespan`:服务启动和关闭时执行的代码(第 10 周用它在启动时恢复未完成的任务)。
|
||||||
|
- 后台任务:用 `asyncio.create_task` 启动,**要把任务对象保存起来**,否则可能被垃圾回收。
|
||||||
|
- SSE 格式:`id: 1\nevent: progress\ndata: {...}\n\n`;用 `StreamingResponse(生成器, media_type="text/event-stream")` 返回;浏览器用 `EventSource` 订阅,断线会自动重连并带上 `Last-Event-ID` 请求头。
|
||||||
|
- 检测客户端断开:`await request.is_disconnected()`。
|
||||||
|
|
||||||
|
**要建的文件**
|
||||||
|
- `uv add fastapi uvicorn`
|
||||||
|
- `fastapi_demo/main.py`:
|
||||||
|
- `GET /health`。
|
||||||
|
- `POST /jobs`:请求体 `{"n": 10}`,用 `create_task` 启动一个后台任务,每秒把进度追加到内存里的 `jobs[job_id]["events"]` 列表,返回 `{"job_id": ...}`。
|
||||||
|
- `GET /jobs/{job_id}`:返回当前状态和已产生的事件数。
|
||||||
|
- `GET /jobs/{job_id}/events`:SSE。先补发 `Last-Event-ID` 之后的事件,再等待新事件,任务完成后发 `event: done` 并结束;每 15 秒没有新事件就发一行 `: ping` 心跳。
|
||||||
|
- **关键实验**:浏览器关掉后,后台任务要继续跑完(因为任务和 SSE 连接是分开的)。
|
||||||
|
- `fastapi_demo/static/sse.html`:一个按钮启动任务,用 `EventSource` 显示进度。
|
||||||
|
- `tests/test_fastapi_demo.py`:用 `fastapi.testclient.TestClient` 测 `/health` 和创建任务。
|
||||||
|
|
||||||
|
**运行方式**:`uv run uvicorn fastapi_demo.main:app --reload`,浏览器打开测试页;或者 `curl -N localhost:8000/jobs/<id>/events`。
|
||||||
|
|
||||||
|
**完成标准**
|
||||||
|
- 启动任务后关掉页面,再打开、重新订阅,能看到任务已经继续往前跑,而且能补齐中间漏掉的事件。
|
||||||
|
- 能说清楚“编排为什么不能只活在浏览器里”——这就是重点 C,也是第 10 周要做的核心功能的缩小版。
|
||||||
|
- `uv run ruff check . && uv run pytest` 通过,提交并推送到 `zhuangang` 仓库(路径 `py-learning/`)。
|
||||||
|
- 在总纲 `README.md` 的进度记录里勾掉第 8 周。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本周自查清单
|
||||||
|
|
||||||
|
- [ ] 会用 uv 建项目、加依赖、跑脚本
|
||||||
|
- [ ] 熟悉列表推导式、解包、f-string,知道可变默认参数的坑
|
||||||
|
- [ ] 会写类型标注、dataclass、Protocol,会用 pyright 检查
|
||||||
|
- [ ] 会用自定义异常和 `raise ... from`
|
||||||
|
- [ ] 理解 asyncio:协程、`create_task`、`gather`、超时、取消、`Queue`、异步生成器
|
||||||
|
- [ ] 会用 pydantic 校验外部数据(包括模型输出)
|
||||||
|
- [ ] 会用 httpx 发异步请求、按错误类型决定是否重试,会用 `MockTransport` 测试
|
||||||
|
- [ ] 会写 FastAPI 接口和 SSE,理解后台任务和 SSE 连接是两回事
|
||||||
|
|
||||||
|
下周预告:第 9 周开始迷你多 Agent 项目,先做 M1(LLM 调用封装)和 M2(多轮编排循环),见 `agent-mini-project.md`。
|
||||||
Reference in new issue
Block a user