添加转岗学习计划:总纲、第 1-4 周、Python 补课和迷你多 Agent 项目

This commit is contained in:
Shikui Wang committed 2026-10-22 17:45:59 +08:00
commit f8a25cb765
9 files changed
+2362

No files matched your search

+347
View File
@@ -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 项目里都会再用到。