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