19 KiB
转岗计划 · 第 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) *TokenManagerfunc (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)和httpOnlyCookie(防 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 项目里都会再用到。