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

19 KiB
Raw Blame History

转岗计划 · 第 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 服务时会沿用的分层方式。

参考资料:


最终项目结构

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 项目里都会再用到。