Files
zhuangang/plan/week04-auth-project1.md
T

348 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 转岗计划 · 第 4 周学习计划(项目一 · 上:注册登录 + JWT + 权限 + 前端)
> 所属阶段:第一段 Go 全栈基础。项目一分两周做:本周完成功能主体,第 5 周加 Redis、消息队列和 Docker 部署(`week05-project1-redis-docker.md`)。总纲见同目录 `README.md`,上周计划 `week03-postgresql.md`。
目标:做出第一个能写进简历的全栈项目“团队任务看板”的功能主体:用户注册登录,创建看板、邀请成员,看板里的任务按“待办 / 进行中 / 已完成”分三列,只有看板成员能看和改,只有看板所有者能管理成员。
前置条件:第 3 周的 `todo-api` 已经能用 PostgreSQL 存储,迁移、sqlc、事务都跑通。
做法:新建 `task-board/` 目录,把 `todo-api/backend` 的骨架(`docker-compose.yml`、`Makefile`、`sqlc.yaml`、`internal/config`、`internal/middleware`、`handler/response.go`、`main.go` 的启动和优雅关机代码)复制过去再改,业务部分重新写。
本周比前三周多了一个“service 层”:handler 只负责解析请求和返回响应,权限判断和业务规则放在 service 里,方便测试,也是第 2、3 个月写 Agent 服务时会沿用的分层方式。
参考资料:
- bcrypt:https://pkg.go.dev/golang.org/x/crypto/bcrypt
- golang-jwt:https://github.com/golang-jwt/jwt
- JWT 介绍:https://jwt.io/introduction
---
## 最终项目结构
```
task-board/
├── README.md # 项目说明(作品集用)
├── backend/
│ ├── go.mod # module task-board
│ ├── main.go
│ ├── Makefile
│ ├── docker-compose.yml
│ ├── .env.example # DATABASE_URL、JWT_SECRET、JWT_TTL
│ ├── sqlc.yaml
│ ├── api.http
│ ├── db/
│ │ ├── migrations/
│ │ │ ├── 000001_init.up.sql
│ │ │ └── 000001_init.down.sql
│ │ ├── queries/
│ │ │ ├── users.sql
│ │ │ ├── boards.sql
│ │ │ └── tasks.sql
│ │ └── seed.sql # 演示数据
│ └── internal/
│ ├── config/config.go
│ ├── db/ # sqlc 生成
│ ├── auth/
│ │ ├── password.go # bcrypt
│ │ ├── password_test.go
│ │ ├── jwt.go # 签发和解析令牌
│ │ └── jwt_test.go
│ ├── service/
│ │ ├── errors.go # 业务错误:未找到、无权限、冲突
│ │ ├── user_service.go
│ │ ├── board_service.go
│ │ ├── task_service.go
│ │ └── task_service_test.go
│ ├── handler/
│ │ ├── response.go
│ │ ├── errors.go # 业务错误 → HTTP 状态码
│ │ ├── auth_handler.go
│ │ ├── board_handler.go
│ │ ├── task_handler.go
│ │ └── handler_test.go # 权限相关接口测试
│ ├── middleware/
│ │ ├── logger.go
│ │ ├── cors.go
│ │ ├── recovery.go
│ │ └── auth.go # RequireAuth
│ └── router/router.go
└── frontend/
├── .env.development
└── src/
├── api/
│ ├── client.ts # 统一请求:带令牌、处理 401
│ ├── auth.ts
│ ├── boards.ts
│ └── tasks.ts
├── types/index.ts
├── store/auth.ts # 登录状态(React 用 Context 或 zustand,Vue 用 Pinia)
├── router.tsx # 路由和登录守卫
├── pages/
│ ├── LoginPage.tsx
│ ├── RegisterPage.tsx
│ ├── BoardsPage.tsx
│ └── BoardDetailPage.tsx
└── components/
├── TaskColumn.tsx
├── TaskCard.tsx
├── TaskEditor.tsx
└── MemberPanel.tsx
```
Vue 的话把 `.tsx` 换成 `.vue`,`router.tsx` 换成 `router.ts`。
## 数据库表
```
users
id BIGSERIAL PRIMARY KEY
email TEXT NOT NULL UNIQUE
name TEXT NOT NULL
password_hash TEXT NOT NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
boards
id BIGSERIAL PRIMARY KEY
name TEXT NOT NULL
owner_id BIGINT NOT NULL REFERENCES users(id)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
board_members
board_id BIGINT REFERENCES boards(id) ON DELETE CASCADE
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE
role TEXT NOT NULL CHECK (role IN ('owner', 'member'))
joined_at TIMESTAMPTZ NOT NULL DEFAULT now()
PRIMARY KEY (board_id, user_id)
tasks
id BIGSERIAL PRIMARY KEY
board_id BIGINT NOT NULL REFERENCES boards(id) ON DELETE CASCADE
title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 100)
description TEXT NOT NULL DEFAULT ''
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'doing', 'done'))
position INT NOT NULL DEFAULT 0 -- 同一列里的排序
assignee_id BIGINT REFERENCES users(id) ON DELETE SET NULL
created_by BIGINT NOT NULL REFERENCES users(id)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
索引:tasks(board_id, status, position)、board_members(user_id)
```
## 接口设计
除注册和登录外,所有接口都需要请求头 `Authorization: Bearer <token>`。
| 方法 | 路径 | 说明 | 权限 | 成功状态码 |
| --- | --- | --- | --- | --- |
| POST | `/api/auth/register` | 注册 `{email, name, password}` | 公开 | 201 |
| POST | `/api/auth/login` | 登录,返回 `{token, user}` | 公开 | 200 |
| GET | `/api/me` | 当前用户 | 登录 | 200 |
| GET | `/api/boards` | 我参与的看板 | 登录 | 200 |
| POST | `/api/boards` | 新建看板,创建者自动成为 owner | 登录 | 201 |
| GET | `/api/boards/:boardId` | 看板详情 + 成员列表 | 成员 | 200 |
| POST | `/api/boards/:boardId/members` | 按邮箱添加成员 | owner | 201 |
| DELETE | `/api/boards/:boardId/members/:userId` | 移除成员(不能移除 owner) | owner | 204 |
| GET | `/api/boards/:boardId/tasks` | 任务列表,按状态分组 | 成员 | 200 |
| POST | `/api/boards/:boardId/tasks` | 新建任务 | 成员 | 201 |
| PATCH | `/api/boards/:boardId/tasks/:taskId` | 修改标题、描述、负责人 | 成员 | 200 |
| POST | `/api/boards/:boardId/tasks/:taskId/move` | 移动到某列某位置 `{status, position}` | 成员 | 200 |
| DELETE | `/api/boards/:boardId/tasks/:taskId` | 删除任务 | 创建者或 owner | 204 |
错误约定:未登录或令牌无效 401;登录了但不是成员,统一返回 404(不暴露看板是否存在);是成员但没有 owner 权限 403;邮箱已注册 409;参数错误 400。
---
## 第 1 天:项目初始化和表设计
**学习内容**
- 从需求推出表结构:先列出“谁、有什么、能做什么”,再画表。
- 权限放在哪张表:用户和看板是多对多,角色存在关联表 `board_members` 上。
- 复习第 3 周的迁移和 sqlc。
**要建的文件**
- 复制骨架到 `task-board/backend/`,`go mod init task-board`,改掉 import 路径。
- `docker-compose.yml`:数据库名、用户名改成 `board`;为了不和 `todo-api` 冲突,端口映射用 `5433:5432`。
- `db/migrations/000001_init.up.sql`:按上面“数据库表”建 4 张表和索引。
- `db/migrations/000001_init.down.sql`:按依赖顺序反向删除。
- `db/queries/users.sql`:`CreateUser :one`、`GetUserByEmail :one`、`GetUserByID :one`。
- `db/queries/boards.sql`:`CreateBoard :one`、`AddBoardMember :exec`、`RemoveBoardMember :execrows`、`GetMemberRole :one`(按 `board_id, user_id` 查角色)、`ListBoardsByUser :many`(`JOIN board_members`)、`ListBoardMembers :many`(`JOIN users`)。
- `db/queries/tasks.sql`:`ListTasksByBoard :many`(按 `status, position` 排序)、`GetTask :one`(条件同时带 `board_id`,防止跨看板访问)、`CreateTask :one`、`UpdateTask :one`、`ShiftPositions :exec`、`MoveTask :one`、`DeleteTask :execrows`。
- 画一张 ER 图(mermaid `erDiagram`),先写在 `task-board/README.md` 里。
**运行方式**:`make db-up && make migrate-up && make sqlc && go build ./...`
**完成标准**:能说清楚为什么 `GetTask` 的条件里要带 `board_id`,以及为什么角色放在 `board_members` 而不是 `users` 上。
---
## 第 2 天:密码加密、注册和登录
**学习内容**
- 为什么密码只能存哈希,为什么用 bcrypt 这种“故意慢”的算法,而不是 MD5、SHA256。
- JWT 的结构:头部、载荷、签名;载荷只是 Base64 编码,**不加密**,不能放敏感信息。
- 令牌过期时间;签名密钥从环境变量读,不能写在代码里。
- 依赖:`go get golang.org/x/crypto/bcrypt github.com/golang-jwt/jwt/v5`。
**要建的文件**
- `internal/auth/password.go`:
- `func HashPassword(plain string) (string, error)`
- `func CheckPassword(hash, plain string) bool`
- `internal/auth/jwt.go`:
- ``type Claims struct { UserID int64 `json:"uid"`; jwt.RegisteredClaims }``
- `type TokenManager struct { secret []byte; ttl time.Duration }`
- `func NewTokenManager(secret string, ttl time.Duration) *TokenManager`
- `func (m *TokenManager) Issue(userID int64) (string, error)`:签发 HS256 令牌,写入过期时间。
- `func (m *TokenManager) Parse(token string) (int64, error)`:校验签名算法、过期时间,返回用户 ID。
- `internal/auth/password_test.go`、`jwt_test.go`:正确密码通过、错误密码失败;过期令牌、被篡改的令牌、用别的密钥签的令牌都解析失败。
- `internal/service/errors.go`:`ErrNotFound`、`ErrForbidden`、`ErrConflict`、`ErrInvalidCredentials`、`ErrValidation`。
- `internal/service/user_service.go`:
- `func (s *UserService) Register(ctx, email, name, password string) (User, error)`:邮箱格式和密码长度(至少 8 位)校验,邮箱转小写,唯一约束冲突转成 `ErrConflict`。
- `func (s *UserService) Login(ctx, email, password string) (token string, user User, err error)`:邮箱不存在和密码错误都返回同一个 `ErrInvalidCredentials`,不让人试出哪些邮箱注册过。
- `internal/handler/errors.go`:`func writeError(c *gin.Context, err error)`,用 `errors.Is` 把业务错误映射到 400 / 401 / 403 / 404 / 409。
- `internal/handler/auth_handler.go`:`Register`、`Login`。
- `internal/config/config.go`:新增 `JWTSecret`(必填,少于 32 个字符时启动报错)、`JWTTTL`(默认 2 小时)。
**运行方式**:`go test ./internal/auth -v`;`api.http` 里写好注册、重复注册、登录、错误密码登录四个请求。
**完成标准**:能说清楚 JWT 和服务端 Session 各自的优缺点,以及“JWT 签发后很难提前作废”这个问题一般怎么缓解(短过期时间 + 刷新令牌,本周不实现)。
---
## 第 3 天:鉴权中间件和当前用户
**学习内容**
- Gin 中间件里校验令牌,把用户 ID 放进 `gin.Context`,后面的 handler 直接取。
- 401 和 403 的区别。
- 前端令牌存哪:`localStorage`(简单,但怕 XSS)和 `httpOnly` Cookie(防 XSS,但要处理 CSRF)。本项目用 `Authorization` 请求头 + `localStorage`,在 README 里写清楚这个取舍。
**要建的文件**
- `internal/middleware/auth.go`:
- `func RequireAuth(tm *auth.TokenManager) gin.HandlerFunc`:没有请求头、格式不对、令牌无效都返回 401 并 `c.Abort()`。
- `func CurrentUserID(c *gin.Context) int64`:从上下文取用户 ID。
- `internal/handler/auth_handler.go`:新增 `Me`,返回当前用户(不返回 `password_hash`,用单独的响应结构体)。
- `internal/router/router.go`:
- `/api/auth/*` 不加鉴权。
- 其余接口挂在 `authed := api.Group("", middleware.RequireAuth(tm))` 下。
- 更新 `api.http`:用变量保存登录得到的令牌,后续请求自动带上。
**运行方式**:`go run .`,不带令牌访问 `/api/me` 返回 401,带上令牌返回当前用户。
**完成标准**:能画出“请求 → RequireAuth → handler → service → 数据库”的流程,说清楚每一层负责什么。
---
## 第 4 天:看板和成员权限
**学习内容**
- 权限判断放在 service 层:每个操作先查当前用户在这个看板的角色,再决定能不能做。
- 不是成员时返回 404 而不是 403 的原因:不暴露看板是否存在。
- 创建看板要用事务:插入看板和插入 owner 成员必须一起成功。
**要建的文件**
- `internal/service/board_service.go`:
- `func (s *BoardService) requireRole(ctx, boardID, userID int64, need string) (role string, err error)`:查不到成员关系返回 `ErrNotFound`;需要 owner 但只是 member 返回 `ErrForbidden`。
- `Create(ctx, userID, name)`:事务里插入看板 + 把自己加为 owner。
- `ListMine(ctx, userID)`、`Get(ctx, userID, boardID)`(含成员列表)。
- `AddMember(ctx, userID, boardID, email)`:仅 owner;邮箱不存在返回 `ErrNotFound`;已是成员返回 `ErrConflict`。
- `RemoveMember(ctx, userID, boardID, targetUserID)`:仅 owner;不能移除 owner 自己。
- `internal/handler/board_handler.go`:对应 5 个接口,路径参数解析失败返回 400。
- 更新 `router.go` 和 `api.http`:准备两个账号 A、B,测“A 建看板 → B 看不到 → A 添加 B → B 能看到 → B 尝试添加成员得到 403”。
**完成标准**:能说清楚“为什么权限判断不能只在前端做”,以及事务在 `Create` 里保证了什么。
---
## 第 5 天:任务接口和权限测试
**学习内容**
- 同一列内排序:`position` 整数,移动时把目标位置之后的任务整体后移一位,放在一个事务里完成。
- 用 `httptest` 测权限,比手动点更可靠。
**要建的文件**
- `internal/service/task_service.go`:
- `List(ctx, userID, boardID)`:返回 `map[string][]Task`(三列)。
- `Create(ctx, userID, boardID, input)`:负责人必须是看板成员,否则 `ErrValidation`;新任务放在“待办”列末尾。
- `Update(ctx, userID, boardID, taskID, input)`:字段用指针区分“没传”和“传了空值”(和第 2 周一样)。
- `Move(ctx, userID, boardID, taskID, status, position)`:事务里 `ShiftPositions` + `MoveTask`。
- `Delete(ctx, userID, boardID, taskID)`:只有任务创建者或看板 owner 能删,否则 `ErrForbidden`。
- `internal/handler/task_handler.go`:对应 5 个接口。
- `internal/service/task_service_test.go`:连测试数据库(没设 `TEST_DATABASE_URL` 就跳过),测负责人校验、移动后同列 `position` 连续不重复。
- `internal/handler/handler_test.go`:
- `setupApp(t)`:连测试库、清表、组装完整路由,返回 `*gin.Engine`。
- `registerAndLogin(t, app, email) string`:辅助函数,返回令牌。
- 测试用例至少包括:未登录 401;非成员访问看板 404;member 添加成员 403;member 删除别人的任务 403;owner 删除任务 204;跨看板用别的看板的 `taskId` 访问返回 404。
**运行方式**:`make test-integration`
**完成标准**:权限测试全部通过;能说清楚为什么 `Move` 要放在事务里。
---
## 第 6 天:前端
**学习内容**
- 统一请求封装:每个请求自动带令牌,收到 401 时清掉登录状态并跳到登录页。
- 路由守卫:没登录访问看板页时跳到登录页。
- 乐观更新:拖动任务时先改界面,接口失败再回滚。
**要建的文件**
- 用 Vite 新建 `frontend/`(同第 2 周),`.env.development` 写 `VITE_API_BASE=http://localhost:8080/api`。
- `src/types/index.ts`:`User`、`Board`、`Member`、`Task`、`TaskStatus`(`'todo' | 'doing' | 'done'`)。
- `src/api/client.ts`:`request<T>(method, path, body?)`,处理统一响应格式和 401。
- `src/api/auth.ts`、`boards.ts`、`tasks.ts`:一个接口一个函数。
- `src/store/auth.ts`:保存令牌和当前用户,提供 `login`、`logout`,刷新页面后从 `localStorage` 恢复并调用 `/api/me` 校验。
- `src/router.tsx`:`/login`、`/register`、`/boards`、`/boards/:id`,后两个需要登录。
- `src/pages/LoginPage.tsx`、`RegisterPage.tsx`:表单、错误提示。
- `src/pages/BoardsPage.tsx`:我的看板列表 + 新建看板。
- `src/pages/BoardDetailPage.tsx`:三列看板 + 成员面板。
- `src/components/TaskColumn.tsx`、`TaskCard.tsx`:卡片可以拖到别的列(用 HTML5 拖拽或 `@dnd-kit`;时间不够就先做“移到下一列”按钮)。
- `src/components/TaskEditor.tsx`:编辑标题、描述、负责人(下拉选成员)。
- `src/components/MemberPanel.tsx`:成员列表;owner 能看到“按邮箱添加”和“移除”。
**运行方式**:后端 `go run .`,前端 `npm run dev`,开两个浏览器(或一个无痕窗口)登录两个账号测试。
**完成标准**:两个账号完整走一遍:注册 → 建看板 → 加成员 → 两人各自建任务、拖动、指派 → member 删除别人的任务时看到无权限提示。
---
## 第 7 天:收尾,写成作品
**学习内容**
- 作品集 README 怎么写:别人 30 秒内知道它是什么,5 分钟内能跑起来。
- 演示数据:让看的人不用自己注册就能体验。
**要建的文件和修改**
- `db/seed.sql`:两个演示账号(密码哈希用 `HashPassword` 生成后写入)、一个看板、十来条任务;`Makefile` 增加 `seed` 目标。
- `task-board/README.md`:
- 一句话介绍 + 功能列表 + 截图。
- 技术栈。
- ER 图、接口表(复制上面的表)。
- 架构说明:handler / service / store 分层,权限在哪一层判断。
- 安全说明:密码哈希、JWT 过期、为什么非成员返回 404、令牌存储的取舍。
- 本地运行步骤:`docker compose up -d` → `make migrate-up` → `make seed` → `go run .` → `npm run dev`。
- 已知不足和下一步(刷新令牌、实时同步),诚实写出来。缓存、通知和部署下周补上。
- 提交并推送到 `zhuangang` 仓库(路径 `task-board/`),确认 `.env` 和密钥没有提交。
**完成标准**
- `go vet ./...` 没有警告,`gofmt -l .` 没有输出。
- `go test ./... -race` 和 `make test-integration` 都通过。
- 在一个新目录里 `git clone` 下来,按 README 能从零跑通。
- 能用 3 分钟讲清楚这个项目:做了什么、分层、权限怎么保证、遇到过什么问题。
- 在总纲 `README.md` 的进度记录里勾掉第 4 周。
---
## 本周自查清单
- [ ] 能说清楚 bcrypt 为什么适合存密码
- [ ] 能说清楚 JWT 的结构、优缺点,以及载荷不加密
- [ ] 会写鉴权中间件,分得清 401 和 403
- [ ] 权限判断放在后端 service 层,并有接口测试覆盖
- [ ] 会用事务处理“多步写入必须一起成功”的场景
- [ ] 前端有统一请求封装、401 处理和路由守卫
- [ ] 项目一 README 完整,别人能从零跑起来
- [ ] 能用 3 分钟讲清楚项目一
下周预告:第 5 周给项目一加 Redis 缓存和限流、用 Redis Streams 做一个简单的消息队列(任务指派通知),再用 Docker Compose 打包部署到你的 Ubuntu 服务器,见 `week05-project1-redis-docker.md`。