--- id: plan.week02 title: "第 2 周:数据库(PostgreSQL 为主,兼顾 MySQL)+ 注册登录与 JWT" type: week domain: plan stack: [go, gin, postgresql, mysql] week: [2] est_hours: 12 order: 2 --- # 第 2 周:数据库(PostgreSQL 为主,兼顾 MySQL)+ 注册登录与 JWT > 第一段“Go 全栈基础”的第 2 周。上周 `week01-go-gin.md`,下周 `week03-project1.md`。 **本周目标** 1. 把任务清单的存储从内存换成 PostgreSQL:会设计表、写常用 SQL、看懂执行计划、用迁移管理表结构、在 Go 里写事务。 2. 能说清 MySQL 和 PostgreSQL 的主要差异(招聘里 MySQL 出现得更多),包括索引、`EXPLAIN`、事务隔离级别。 3. 给服务加上注册、登录和 JWT 鉴权。 **为什么用 PostgreSQL 学、却要兼顾 MySQL**:两者的常用 SQL 大部分相同;PostgreSQL 的 pgvector 扩展在第 5~6 周 RAG 项目里可以当向量库用;而招聘写 MySQL 的更多,所以每个知识点都附上 MySQL 的写法和区别,面试时能讲清差异即可。 **参考资料** - PostgreSQL 中文文档:http://www.postgres.cn/docs/current/ - MySQL 8 参考手册:https://dev.mysql.com/doc/refman/8.0/en/ - pgx:https://github.com/jackc/pgx - golang-migrate:https://github.com/golang-migrate/migrate - golang-jwt:https://github.com/golang-jwt/jwt ## 本周最终目录(在上周 `todo-api/backend` 基础上新增) ```text todo-api/backend/ ├── docker-compose.dev.yml # postgres(和可选的 mysql) ├── Makefile ├── .env.example ├── db/migrations/ │ ├── 000001_create_tasks.up.sql / .down.sql │ ├── 000002_add_tasks_index.up.sql / .down.sql │ └── 000003_users_and_tags.up.sql / .down.sql ├── sql/practice/ # 练习 SQL,不参与运行 │ ├── day1_crud.sql │ ├── day2_queries.sql │ └── mysql_vs_pg.sql └── internal/ ├── config/config.go ├── model/user.go ├── store/pg_store.go # 任务的 PostgreSQL 实现 ├── store/pg_store_test.go # 集成测试(没有数据库时自动跳过) ├── store/user_store.go # 用户存储接口 + 内存实现 ├── store/pg_user_store.go ├── auth/password.go ├── auth/jwt.go ├── auth/jwt_test.go ├── handler/auth_handler.go ├── middleware/auth.go └── router/router.go # 改为接收依赖集合 ``` --- ## 第 1 天:用 Docker 跑 PostgreSQL,写基础 SQL 和表设计 ### 学习内容 **1. 用 Docker 启动数据库** - 说明:本地开发不直接往系统装数据库,用 Docker 起一个容器,删掉重来也不怕;数据放在具名卷里,容器删了数据还在。 - 案例:开发用的 Compose 文件。 ```yaml # 文件:docker-compose.dev.yml services: postgres: image: postgres:16 environment: POSTGRES_USER: todo POSTGRES_PASSWORD: todo POSTGRES_DB: todo ports: ["5432:5432"] volumes: [pgdata:/var/lib/postgresql/data] mysql: # 只在做 MySQL 对比练习时启动:docker compose -f docker-compose.dev.yml up -d mysql image: mysql:8 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: todo ports: ["3306:3306"] volumes: pgdata: ``` ```bash # Ubuntu 没装 Docker 的话:sudo apt install docker.io docker-compose-v2,然后把自己加进 docker 组 docker compose -f docker-compose.dev.yml up -d postgres docker compose -f docker-compose.dev.yml exec postgres psql -U todo -d todo # psql 常用:\dt 列出表 \d tasks 看表结构 \q 退出 ``` **2. 建表和约束** - 说明:主键、`NOT NULL`、`UNIQUE`、`CHECK`、外键这些约束让数据库帮你守住规则,比只在代码里校验更可靠(多个服务、手工改数据时也不会写进脏数据)。 - 案例:用户、任务(一对多)、标签(多对多)。 ```sql -- 文件:sql/practice/day1_crud.sql CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE practice_tasks ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- 一对多 title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 100), done BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE tags (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE); CREATE TABLE task_tags ( -- 多对多的关联表 task_id BIGINT REFERENCES practice_tasks(id) ON DELETE CASCADE, tag_id BIGINT REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (task_id, tag_id) ); INSERT INTO users (email, name) VALUES ('a@example.com', '小王'), ('b@example.com', '小李'); INSERT INTO practice_tasks (user_id, title) VALUES (1, '学 SQL'), (1, '学 Gin'), (2, '写周报'); UPDATE practice_tasks SET done = true WHERE id = 1; SELECT id, title, done FROM practice_tasks WHERE user_id = 1 ORDER BY created_at DESC LIMIT 10; DELETE FROM practice_tasks WHERE id = 3; ``` - 常见坑:`UPDATE` / `DELETE` 忘写 `WHERE` 会改掉整张表;生产环境操作前先用同样的条件 `SELECT` 一遍。 - 面试常问:外键的 `ON DELETE CASCADE` 和 `RESTRICT` 各适合什么场景(订单明细跟着订单删 → CASCADE;有订单的用户不能删 → RESTRICT)。 **3. MySQL 写法对照** - 说明:同样的表在 MySQL 里要注意:自增用 `AUTO_INCREMENT`;字符集用 `utf8mb4`(否则存不了表情);存储引擎用 InnoDB(支持事务和外键,MySQL 8 默认就是)。 - 案例: ```sql -- 文件:sql/practice/mysql_vs_pg.sql(在 MySQL 里执行) CREATE TABLE users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- PostgreSQL: INSERT ... RETURNING id; MySQL 没有 RETURNING: INSERT INTO users (email, name) VALUES ('a@example.com', '小王'); SELECT LAST_INSERT_ID(); -- “存在就更新”:PostgreSQL 用 ON CONFLICT (email) DO UPDATE,MySQL 用: INSERT INTO users (email, name) VALUES ('a@example.com', '王') ON DUPLICATE KEY UPDATE name = VALUES(name); ``` | 方面 | PostgreSQL | MySQL 8 | | --- | --- | --- | | 自增主键 | `BIGSERIAL` / `GENERATED AS IDENTITY` | `AUTO_INCREMENT` | | 插入后取回 | `RETURNING` | `LAST_INSERT_ID()` | | 存在就更新 | `ON CONFLICT ... DO UPDATE` | `ON DUPLICATE KEY UPDATE` | | 时间 | `TIMESTAMPTZ`(带时区) | `DATETIME`(不带时区,一般统一存 UTC) | | JSON | `JSONB`,可直接建索引 | `JSON`,索引要借助生成列 | | 字符串比较 | 默认区分大小写 | 常用排序规则默认不区分大小写 | | 默认隔离级别 | 读已提交 | 可重复读 | | Go 驱动占位符 | `$1, $2` | `?` | ### 要建的文件 - `docker-compose.dev.yml`、`sql/practice/day1_crud.sql`、`sql/practice/mysql_vs_pg.sql` ### 运行方式 ```bash docker compose -f docker-compose.dev.yml exec -T postgres psql -U todo -d todo < sql/practice/day1_crud.sql ``` ### 完成标准 - 不看资料能写出建表、增删改查;能说出 3 条以上 MySQL 和 PostgreSQL 的差异。 --- ## 第 2 天:多表查询、索引、EXPLAIN ### 学习内容 **1. JOIN、GROUP BY、HAVING** - 说明:`JOIN` 把多张表按关系连起来;`LEFT JOIN` 保留左表所有行(右边没有就是 NULL);`GROUP BY` 分组统计,`HAVING` 对分组结果过滤。 - 案例:任务统计报表。 ```sql -- 文件:sql/practice/day2_queries.sql -- 每个用户的任务数和完成数(没有任务的用户也要列出来) SELECT u.name, count(t.id) AS total, count(t.id) FILTER (WHERE t.done) AS done -- MySQL 写法:SUM(t.done = 1) FROM users u LEFT JOIN practice_tasks t ON t.user_id = u.id GROUP BY u.id, u.name ORDER BY total DESC; -- 带某个标签的任务(经过关联表的两次 JOIN) SELECT t.id, t.title FROM practice_tasks t JOIN task_tags tt ON tt.task_id = t.id JOIN tags g ON g.id = tt.tag_id WHERE g.name = '学习'; -- 任务数超过 2 的用户 SELECT user_id, count(*) FROM practice_tasks GROUP BY user_id HAVING count(*) > 2; ``` **2. 索引和执行计划** - 说明:索引(B+ 树 / B 树)让查询不用扫全表,但会让写入变慢、占空间,所以只给常用的查询条件和排序字段建。组合索引遵循“最左前缀”:`(user_id, created_at)` 能用于 `WHERE user_id = ?` 和 `WHERE user_id = ? ORDER BY created_at`,不能单独用于 `WHERE created_at > ?`。 - 案例:造 10 万条数据,对比加索引前后的执行计划。 ```sql -- 造数据(PostgreSQL 的 generate_series) INSERT INTO practice_tasks (user_id, title, done) SELECT 1 + (random() * 1)::int, '任务 ' || g, random() < 0.3 FROM generate_series(1, 100000) g; ANALYZE practice_tasks; EXPLAIN ANALYZE SELECT id, title FROM practice_tasks WHERE user_id = 1 AND NOT done ORDER BY created_at DESC LIMIT 20; -- 看到 Seq Scan(全表扫描)和较长的执行时间 CREATE INDEX idx_tasks_user_created ON practice_tasks (user_id, created_at DESC); EXPLAIN ANALYZE SELECT id, title FROM practice_tasks WHERE user_id = 1 AND NOT done ORDER BY created_at DESC LIMIT 20; -- 变成 Index Scan,时间明显下降(具体数字以你机器上为准,记到练习文件的注释里) ``` - MySQL 对照:`EXPLAIN SELECT ...` 重点看 `type`(`ALL` 是全表扫描,`ref`/`range` 用到了索引)、`key`(实际用的索引)、`rows`(估计扫描行数)、`Extra`(`Using filesort` 说明排序没走索引)。MySQL 8.0.18 起也支持 `EXPLAIN ANALYZE`。 - 面试常问: - 索引为什么用 B+ 树(矮、范围查询友好); - 索引失效的常见情况:对字段做函数运算(`WHERE date(created_at) = ...`)、隐式类型转换、`LIKE '%xx'` 前缀模糊、违反最左前缀; - MySQL InnoDB 的聚簇索引和二级索引“回表”,以及“覆盖索引”如何避免回表。 ### 要建的文件 - `sql/practice/day2_queries.sql`:上面的查询 + 你自己的 `EXPLAIN` 结果注释。 - `db/migrations/000002_add_tasks_index.up.sql`:给正式的 `tasks` 表加 `(done, created_at DESC)` 索引(第 3 天建迁移体系后再执行)。 ### 运行方式 在 `psql` 里执行;MySQL 部分在 `docker compose ... exec mysql mysql -uroot -proot todo` 里执行。 ### 完成标准 - 能读懂执行计划里最关键的两三行,能说出 3 种索引失效的情况。 --- ## 第 3 天:迁移和 Go 访问数据库(pgx) ### 学习内容 **1. 数据库迁移** - 说明:表结构变更也要像代码一样进 Git、按顺序执行。每次变更一对文件:`up`(执行)和 `down`(回滚)。上线顺序一般是“先跑迁移,再发新代码”,避免“代码升级了、表还是旧的”导致接口 500。 - 案例: ```sql -- 文件:db/migrations/000001_create_tasks.up.sql CREATE TABLE tasks ( id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 100), done BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ``` ```sql -- 文件:db/migrations/000001_create_tasks.down.sql DROP TABLE IF EXISTS tasks; ``` ```makefile # 文件:Makefile(缩进必须是 Tab) DATABASE_URL ?= postgres://todo:todo@localhost:5432/todo?sslmode=disable db-up: docker compose -f docker-compose.dev.yml up -d postgres migrate-up: migrate -path db/migrations -database "$(DATABASE_URL)" up migrate-down: migrate -path db/migrations -database "$(DATABASE_URL)" down 1 migrate-new: migrate create -ext sql -dir db/migrations -seq $(name) ``` ```bash go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest make db-up && make migrate-up make migrate-down && make migrate-up # 确认能来回执行 ``` **2. 用 pgx 实现 TaskStore** - 说明:pgx 是 Go 里常用的 PostgreSQL 驱动,`pgxpool` 是连接池。因为上周把存储层定义成了接口,这里只要新写一个实现。SQL 用 `$1` 占位符传参,**永远不要拼接字符串**(SQL 注入)。 - 另一种常见做法是 sqlc(写 SQL 生成 Go 代码)或 GORM(ORM)。本计划用 pgx 直接写,SQL 看得最清楚;了解另外两种的取舍即可。 - 案例: ```go // 文件:internal/store/pg_store.go package store import ( "context" "errors" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "todo-api/internal/model" ) type PgStore struct{ pool *pgxpool.Pool } func NewPgStore(pool *pgxpool.Pool) *PgStore { return &PgStore{pool: pool} } const taskCols = "id, title, done, created_at, updated_at" func (s *PgStore) List(ctx context.Context, done *bool) ([]model.Task, error) { rows, err := s.pool.Query(ctx, `SELECT `+taskCols+` FROM tasks WHERE ($1::boolean IS NULL OR done = $1) ORDER BY id`, done) if err != nil { return nil, err } return pgx.CollectRows(rows, pgx.RowToStructByPos[model.Task]) // 按列顺序填充结构体 } func (s *PgStore) Get(ctx context.Context, id int64) (model.Task, error) { var t model.Task err := s.pool.QueryRow(ctx, `SELECT `+taskCols+` FROM tasks WHERE id = $1`, id). Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt, &t.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return t, ErrNotFound } return t, err } func (s *PgStore) Create(ctx context.Context, t model.Task) (model.Task, error) { err := s.pool.QueryRow(ctx, `INSERT INTO tasks (title, done) VALUES ($1, $2) RETURNING `+taskCols, t.Title, t.Done). Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt, &t.UpdatedAt) return t, err } func (s *PgStore) Update(ctx context.Context, t model.Task) (model.Task, error) { err := s.pool.QueryRow(ctx, `UPDATE tasks SET title = $2, done = $3, updated_at = now() WHERE id = $1 RETURNING `+taskCols, t.ID, t.Title, t.Done). Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt, &t.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return t, ErrNotFound } return t, err } func (s *PgStore) Delete(ctx context.Context, id int64) error { tag, err := s.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } ``` - 常见坑:`RowToStructByPos` 要求结构体字段顺序和 `SELECT` 的列顺序一致;用 MySQL 时驱动是 `github.com/go-sql-driver/mysql` + 标准库 `database/sql`,占位符是 `?`。 **易错点 / 面试常问(补充)** - 易错点:迁移文件一旦在别的环境执行过就不要再改,要改就新建一个迁移;`down` 文件要能真正回滚 `up` 的改动。 - 易错点:pgx 查询后要处理 `rows.Err()`,并 `defer rows.Close()`;查单行没结果时返回的是 `pgx.ErrNoRows`,要转成业务的“不存在”。 - 易错点:SQL 一律用参数占位符(PostgreSQL 是 `$1`,MySQL 是 `?`),不要拼接字符串,否则有 SQL 注入。 - 面试常问:连接池为什么必要、参数怎么设(最大连接数受数据库 `max_connections` 限制);应用升级和数据库迁移谁先谁后(先做向后兼容的迁移,再发新代码)。 ### 要建的文件 - `db/migrations/000001_*`、`000002_*`、`Makefile`、`internal/store/pg_store.go` - `.env.example`:`DATABASE_URL=postgres://todo:todo@localhost:5432/todo?sslmode=disable` ### 运行方式 ```bash go get github.com/jackc/pgx/v5 go build ./... ``` ### 完成标准 - 能说出“为什么先跑迁移再发代码”,能说出 SQL 注入是什么、怎么避免。 --- ## 第 4 天:事务、隔离级别、幂等写入 ### 学习内容 **1. 事务** - 说明:事务保证一组写操作“要么全成功,要么全失败”(原子性),并发时互不干扰(隔离性)。ACID:原子性、一致性、隔离性、持久性。 - 案例:给任务打标签:插入标签(已存在就复用)+ 写关联表,必须在一个事务里。 ```sql -- 文件:db/migrations/000003_users_and_tags.up.sql CREATE TABLE 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() ); CREATE TABLE tags (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE); CREATE TABLE task_tags ( task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE, tag_id BIGINT REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (task_id, tag_id) ); ``` ```go // 文件:internal/store/pg_tx.go package store import ( "context" "github.com/jackc/pgx/v5" ) // withTx 开启事务执行 fn:fn 返回错误就回滚,否则提交。 func (s *PgStore) withTx(ctx context.Context, fn func(tx pgx.Tx) error) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) // 已经提交后再调用 Rollback 不会有任何影响 if err := fn(tx); err != nil { return err } return tx.Commit(ctx) } // AddTags 给任务打标签;标签不存在就创建(幂等:重复调用结果一样)。 func (s *PgStore) AddTags(ctx context.Context, taskID int64, names []string) error { return s.withTx(ctx, func(tx pgx.Tx) error { for _, name := range names { var tagID int64 err := tx.QueryRow(ctx, `INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, name).Scan(&tagID) if err != nil { return err } if _, err := tx.Exec(ctx, `INSERT INTO task_tags (task_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, taskID, tagID); err != nil { return err } } return nil }) } ``` - 常见坑:事务里用 `s.pool` 而不是 `tx` 执行语句,那条语句就不在事务里了。 - 小实验:在循环里故意 `return errors.New("boom")`,确认标签和关联都没写进去。 **2. 隔离级别(面试高频)** - 说明:并发事务可能出现三类问题:**脏读**(读到别人没提交的数据)、**不可重复读**(同一事务里两次读同一行结果不同)、**幻读**(两次范围查询行数不同)。四个隔离级别从低到高:读未提交、读已提交、可重复读、串行化。PostgreSQL 默认读已提交,MySQL InnoDB 默认可重复读(通过 MVCC 多版本 + 间隙锁在很大程度上避免幻读)。 - 案例:开两个 `psql` 窗口亲手观察“不可重复读”。 ```sql -- 窗口 A BEGIN; -- 默认读已提交 SELECT title FROM tasks WHERE id = 1; -- 看到旧标题 -- 窗口 B UPDATE tasks SET title = '被 B 改了' WHERE id = 1; -- 自动提交 -- 窗口 A SELECT title FROM tasks WHERE id = 1; -- 看到新标题:不可重复读 COMMIT; -- 再来一次,A 改用可重复读 BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT title FROM tasks WHERE id = 1; -- B 再改一次后,A 再查:仍是事务开始时的值 COMMIT; ``` - 面试常问:MVCC 是什么(每行保留多个版本,读不加锁);乐观锁(版本号字段 `UPDATE ... WHERE version = ?`)和悲观锁(`SELECT ... FOR UPDATE`)各适合什么场景。 ### 要建的文件 - `db/migrations/000003_users_and_tags.up.sql` / `.down.sql`、`internal/store/pg_tx.go` ### 运行方式 ```bash make migrate-up && go build ./... ``` ### 完成标准 - 亲手复现过不可重复读;能讲清四个隔离级别和 MySQL、PostgreSQL 的默认值。 --- ## 第 5 天:密码哈希和 JWT ### 学习内容 **1. 密码只存哈希** - 说明:数据库泄露时,明文密码会直接暴露。bcrypt 是“故意很慢”且自带随机盐的哈希算法,暴力破解成本高;MD5、SHA256 太快,不适合存密码。 - 案例: ```go // 文件:internal/auth/password.go package auth import "golang.org/x/crypto/bcrypt" func HashPassword(plain string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) return string(b), err } func CheckPassword(hash, plain string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil } ``` **2. JWT** - 说明:JWT 是“头部.载荷.签名”三段 Base64 字符串。服务端用密钥签名,之后只要验签就知道是谁、是否过期,不用查会话表。注意:载荷**只是编码不是加密**,不能放敏感信息;签发后在过期前很难作废,所以过期时间要短(配合刷新令牌,本周不实现)。 - 案例:签发和解析。 ```go // 文件:internal/auth/jwt.go package auth import ( "time" "github.com/golang-jwt/jwt/v5" ) 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 { return &TokenManager{secret: []byte(secret), ttl: ttl} } func (m *TokenManager) Issue(userID int64) (string, error) { now := time.Now() claims := Claims{ UserID: userID, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(m.ttl)), }, } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret) } func (m *TokenManager) Parse(token string) (int64, error) { var c Claims _, err := jwt.ParseWithClaims(token, &c, func(*jwt.Token) (any, error) { return m.secret, nil }, jwt.WithValidMethods([]string{"HS256"}), // 只接受 HS256,防止“算法替换”攻击 ) if err != nil { return 0, err } return c.UserID, nil } ``` ```go // 文件:internal/auth/jwt_test.go package auth import ( "testing" "time" ) func TestToken(t *testing.T) { m := NewTokenManager("a-very-long-secret-for-testing-only-123", time.Hour) tok, _ := m.Issue(42) if id, err := m.Parse(tok); err != nil || id != 42 { t.Fatalf("parse: id=%d err=%v", id, err) } expired := NewTokenManager("a-very-long-secret-for-testing-only-123", -time.Minute) tok2, _ := expired.Issue(1) if _, err := m.Parse(tok2); err == nil { t.Fatal("过期令牌应该解析失败") } other := NewTokenManager("another-secret-another-secret-12345678", time.Hour) tok3, _ := other.Issue(1) if _, err := m.Parse(tok3); err == nil { t.Fatal("别的密钥签的令牌应该解析失败") } } func TestPassword(t *testing.T) { h, _ := HashPassword("secret123") if !CheckPassword(h, "secret123") || CheckPassword(h, "wrong") { t.Fatal("密码校验不对") } } ``` - 面试常问:JWT 和服务端 Session 的优缺点;令牌放 `localStorage`(怕 XSS)还是 `httpOnly` Cookie(要防 CSRF)。 ### 要建的文件 - `internal/auth/password.go`、`jwt.go`、`jwt_test.go` ### 运行方式 ```bash go get golang.org/x/crypto/bcrypt github.com/golang-jwt/jwt/v5 go test ./internal/auth -v ``` ### 完成标准 - 测试通过;能说清 JWT 三段分别是什么,为什么载荷不能放密码。 --- ## 第 6 天:注册、登录、鉴权中间件 ### 学习内容 **1. 用户存储和注册登录** - 说明:用户存储同样先定义接口;邮箱唯一冲突转成 409;登录时“邮箱不存在”和“密码错误”返回**同一个**错误,避免别人试探哪些邮箱注册过。 - 案例: ```go // 文件:internal/model/user.go package model import "time" type User struct { ID int64 `json:"id"` Email string `json:"email"` Name string `json:"name"` PasswordHash string `json:"-"` // 永远不输出到 JSON CreatedAt time.Time `json:"createdAt"` } ``` ```go // 文件:internal/store/user_store.go package store import ( "context" "errors" "sync" "time" "todo-api/internal/model" ) var ErrEmailTaken = errors.New("email already registered") type UserStore interface { CreateUser(ctx context.Context, u model.User) (model.User, error) GetUserByEmail(ctx context.Context, email string) (model.User, error) GetUser(ctx context.Context, id int64) (model.User, error) } type MemoryUserStore struct { mu sync.Mutex nextID int64 byID map[int64]model.User } func NewMemoryUserStore() *MemoryUserStore { return &MemoryUserStore{nextID: 1, byID: map[int64]model.User{}} } func (s *MemoryUserStore) CreateUser(_ context.Context, u model.User) (model.User, error) { s.mu.Lock() defer s.mu.Unlock() for _, x := range s.byID { if x.Email == u.Email { return model.User{}, ErrEmailTaken } } u.ID, u.CreatedAt = s.nextID, time.Now() s.nextID++ s.byID[u.ID] = u return u, nil } func (s *MemoryUserStore) GetUserByEmail(_ context.Context, email string) (model.User, error) { s.mu.Lock() defer s.mu.Unlock() for _, x := range s.byID { if x.Email == email { return x, nil } } return model.User{}, ErrNotFound } func (s *MemoryUserStore) GetUser(_ context.Context, id int64) (model.User, error) { s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[id] if !ok { return model.User{}, ErrNotFound } return u, nil } ``` ```go // 文件:internal/store/pg_user_store.go package store import ( "context" "errors" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "todo-api/internal/model" ) type PgUserStore struct{ pool *pgxpool.Pool } func NewPgUserStore(pool *pgxpool.Pool) *PgUserStore { return &PgUserStore{pool: pool} } const userCols = "id, email, name, password_hash, created_at" func scanUser(row pgx.Row) (model.User, error) { var u model.User err := row.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return u, ErrNotFound } return u, err } func (s *PgUserStore) CreateUser(ctx context.Context, u model.User) (model.User, error) { created, err := scanUser(s.pool.QueryRow(ctx, `INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING `+userCols, u.Email, u.Name, u.PasswordHash)) var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { // 唯一约束冲突 return model.User{}, ErrEmailTaken } return created, err } func (s *PgUserStore) GetUserByEmail(ctx context.Context, email string) (model.User, error) { return scanUser(s.pool.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE email = $1`, email)) } func (s *PgUserStore) GetUser(ctx context.Context, id int64) (model.User, error) { return scanUser(s.pool.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE id = $1`, id)) } ``` ```go // 文件:internal/handler/auth_handler.go package handler import ( "errors" "net/http" "net/mail" "strings" "github.com/gin-gonic/gin" "todo-api/internal/auth" "todo-api/internal/middleware" "todo-api/internal/model" "todo-api/internal/store" ) type AuthHandler struct { Users store.UserStore Tokens *auth.TokenManager } type credentials struct { Email string `json:"email"` Name string `json:"name"` Password string `json:"password"` } func (h *AuthHandler) Register(c *gin.Context) { var req credentials if err := c.ShouldBindJSON(&req); err != nil { Fail(c, http.StatusBadRequest, 40001, "请求体格式错误") return } req.Email = strings.ToLower(strings.TrimSpace(req.Email)) if _, err := mail.ParseAddress(req.Email); err != nil || len(req.Password) < 8 || req.Name == "" { Fail(c, http.StatusBadRequest, 40002, "邮箱格式不对、名字为空或密码少于 8 位") return } hash, err := auth.HashPassword(req.Password) if err != nil { Fail(c, http.StatusInternalServerError, 50000, "服务器错误") return } u, err := h.Users.CreateUser(c.Request.Context(), model.User{Email: req.Email, Name: req.Name, PasswordHash: hash}) if errors.Is(err, store.ErrEmailTaken) { Fail(c, http.StatusConflict, 40901, "邮箱已注册") return } if err != nil { Fail(c, http.StatusInternalServerError, 50000, "服务器错误") return } Created(c, u) } func (h *AuthHandler) Login(c *gin.Context) { var req credentials if err := c.ShouldBindJSON(&req); err != nil { Fail(c, http.StatusBadRequest, 40001, "请求体格式错误") return } u, err := h.Users.GetUserByEmail(c.Request.Context(), strings.ToLower(strings.TrimSpace(req.Email))) if err != nil || !auth.CheckPassword(u.PasswordHash, req.Password) { Fail(c, http.StatusUnauthorized, 40101, "邮箱或密码错误") // 两种情况同一个提示 return } token, err := h.Tokens.Issue(u.ID) if err != nil { Fail(c, http.StatusInternalServerError, 50000, "服务器错误") return } OK(c, gin.H{"token": token, "user": u}) } func (h *AuthHandler) Me(c *gin.Context) { u, err := h.Users.GetUser(c.Request.Context(), middleware.CurrentUserID(c)) if err != nil { Fail(c, http.StatusNotFound, 40402, "用户不存在") return } OK(c, u) } ``` **2. 鉴权中间件** - 说明:从 `Authorization: Bearer ` 取令牌并校验,把用户 ID 放进上下文;失败返回 401 并中止。401 表示“你是谁我不知道”,403 表示“我知道你是谁,但你没权限”。 - 案例: ```go // 文件:internal/middleware/auth.go package middleware import ( "net/http" "strings" "github.com/gin-gonic/gin" "todo-api/internal/auth" ) const userIDKey = "userID" func RequireAuth(tm *auth.TokenManager) gin.HandlerFunc { return func(c *gin.Context) { raw, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ") if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40100, "message": "未登录"}) return } uid, err := tm.Parse(raw) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40100, "message": "登录已失效"}) return } c.Set(userIDKey, uid) c.Next() } } func CurrentUserID(c *gin.Context) int64 { return c.GetInt64(userIDKey) } ``` ```go // 文件:internal/router/router.go package router import ( "net/http" "github.com/gin-gonic/gin" "todo-api/internal/auth" "todo-api/internal/handler" "todo-api/internal/middleware" ) type Deps struct { Tasks *handler.TaskHandler Auth *handler.AuthHandler Tokens *auth.TokenManager } func New(d Deps) *gin.Engine { r := gin.New() r.Use(gin.Recovery(), middleware.Logger(), middleware.CORS("http://localhost:5173")) api := r.Group("/api") api.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) api.POST("/auth/register", d.Auth.Register) api.POST("/auth/login", d.Auth.Login) authed := api.Group("", middleware.RequireAuth(d.Tokens)) authed.GET("/me", d.Auth.Me) authed.GET("/tasks", d.Tasks.List) authed.GET("/tasks/:id", d.Tasks.Get) authed.POST("/tasks", d.Tasks.Create) authed.PATCH("/tasks/:id", d.Tasks.Update) authed.DELETE("/tasks/:id", d.Tasks.Delete) return r } ``` **易错点 / 面试常问(补充)** - 易错点:登录失败不要区分“用户不存在”和“密码错误”,统一返回“用户名或密码错误”,防止被枚举账号。 - 易错点:鉴权中间件解析令牌时要指定允许的签名算法,否则可能被“改算法”攻击;令牌过期时间不要太长。 - 易错点:注册接口的邮箱唯一靠数据库唯一索引保证,不要只靠“先查再插”(并发时会重复),捕获唯一约束错误后返回 409。 - 面试常问:401 和 403 的区别(没登录 / 登录了但没权限);令牌被盗怎么办(短有效期 + 刷新令牌 + 服务端黑名单或版本号)。 ### 要建的文件 - 上面 6 个文件;更新 `api.http`:注册 → 登录 → 把令牌存成变量 → 带令牌访问任务接口。 ### 运行方式 ```bash go build ./... && go test ./... ``` ### 完成标准 - 不带令牌访问 `/api/tasks` 返回 401;登录后带令牌返回 200。 --- ## 第 7 天:配置切换、接口测试、集成测试、收尾 ### 学习内容 **1. 配置和依赖组装** - 说明:用环境变量选择存储实现(`STORE=memory|postgres`),本地没装数据库也能跑;JWT 密钥必须从环境变量读,长度不够就拒绝启动。 - 案例: ```go // 文件:internal/config/config.go package config import ( "errors" "os" "time" ) type Config struct { Port string Store string // memory 或 postgres DatabaseURL string JWTSecret string JWTTTL time.Duration } func Load() (Config, error) { c := Config{ Port: getenv("PORT", "8080"), Store: getenv("STORE", "memory"), DatabaseURL: os.Getenv("DATABASE_URL"), JWTSecret: os.Getenv("JWT_SECRET"), JWTTTL: 2 * time.Hour, } if len(c.JWTSecret) < 32 { return c, errors.New("JWT_SECRET 至少 32 个字符") } if c.Store == "postgres" && c.DatabaseURL == "" { return c, errors.New("STORE=postgres 时必须设置 DATABASE_URL") } return c, nil } func getenv(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } ``` ```go // 文件:main.go package main import ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/jackc/pgx/v5/pgxpool" "todo-api/internal/auth" "todo-api/internal/config" "todo-api/internal/handler" "todo-api/internal/router" "todo-api/internal/store" ) func main() { cfg, err := config.Load() if err != nil { slog.Error("config", "err", err) os.Exit(1) } var tasks store.TaskStore = store.NewMemoryStore() var users store.UserStore = store.NewMemoryUserStore() if cfg.Store == "postgres" { pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL) if err == nil { err = pool.Ping(context.Background()) } if err != nil { slog.Error("database", "err", err) os.Exit(1) } defer pool.Close() tasks, users = store.NewPgStore(pool), store.NewPgUserStore(pool) } tm := auth.NewTokenManager(cfg.JWTSecret, cfg.JWTTTL) r := router.New(router.Deps{ Tasks: &handler.TaskHandler{Store: tasks}, Auth: &handler.AuthHandler{Users: users, Tokens: tm}, Tokens: tm, }) srv := &http.Server{Addr: ":" + cfg.Port, Handler: r} go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("listen", "err", err) os.Exit(1) } }() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx) } ``` **2. 接口测试改为带登录** - 说明:路由加了鉴权,上周的测试要先拿到令牌。测试里直接用 `TokenManager` 签一个,比走注册登录更快。 - 案例: ```go // 文件:internal/handler/task_handler_test.go package handler_test import ( "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" "todo-api/internal/auth" "todo-api/internal/handler" "todo-api/internal/router" "todo-api/internal/store" ) func setup() (*gin.Engine, string) { gin.SetMode(gin.TestMode) tm := auth.NewTokenManager("test-secret-test-secret-test-secret!!", time.Hour) r := router.New(router.Deps{ Tasks: &handler.TaskHandler{Store: store.NewMemoryStore()}, Auth: &handler.AuthHandler{Users: store.NewMemoryUserStore(), Tokens: tm}, Tokens: tm, }) token, _ := tm.Issue(1) return r, token } func do(r *gin.Engine, method, path, token, body string) *httptest.ResponseRecorder { w := httptest.NewRecorder() req := httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } r.ServeHTTP(w, req) return w } func TestTasksRequireAuth(t *testing.T) { r, token := setup() if w := do(r, http.MethodGet, "/api/tasks", "", ""); w.Code != http.StatusUnauthorized { t.Fatalf("无令牌 got %d", w.Code) } if w := do(r, http.MethodPost, "/api/tasks", token, `{"title":"学 SQL"}`); w.Code != http.StatusCreated { t.Fatalf("创建 got %d", w.Code) } if w := do(r, http.MethodGet, "/api/tasks/999", token, ""); w.Code != http.StatusNotFound { t.Fatalf("不存在 got %d", w.Code) } } func TestRegisterLogin(t *testing.T) { r, _ := setup() body := `{"email":"a@example.com","name":"小王","password":"secret123"}` if w := do(r, http.MethodPost, "/api/auth/register", "", body); w.Code != http.StatusCreated { t.Fatalf("注册 got %d", w.Code) } if w := do(r, http.MethodPost, "/api/auth/register", "", body); w.Code != http.StatusConflict { t.Fatalf("重复注册 got %d", w.Code) } if w := do(r, http.MethodPost, "/api/auth/login", "", `{"email":"a@example.com","password":"bad"}`); w.Code != http.StatusUnauthorized { t.Fatalf("错误密码 got %d", w.Code) } } ``` **3. 集成测试(连真数据库)** - 说明:内存版测试快,但发现不了 SQL 写错;集成测试连真数据库。没设置 `TEST_DATABASE_URL` 时自动跳过,这样没有数据库的环境也能跑 `go test`。 - 案例: ```go // 文件:internal/store/pg_store_test.go package store import ( "context" "errors" "os" "testing" "github.com/jackc/pgx/v5/pgxpool" "todo-api/internal/model" ) func setupPg(t *testing.T) *PgStore { url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("未设置 TEST_DATABASE_URL,跳过集成测试") } pool, err := pgxpool.New(context.Background(), url) if err != nil { t.Fatal(err) } t.Cleanup(pool.Close) if _, err := pool.Exec(context.Background(), "TRUNCATE tasks RESTART IDENTITY CASCADE"); err != nil { t.Fatal(err) } return NewPgStore(pool) } func TestPgStoreCRUD(t *testing.T) { s := setupPg(t) ctx := context.Background() created, err := s.Create(ctx, model.Task{Title: "集成测试"}) if err != nil { t.Fatal(err) } created.Done = true if _, err := s.Update(ctx, created); err != nil { t.Fatal(err) } done := true list, _ := s.List(ctx, &done) if len(list) != 1 { t.Fatalf("got %d", len(list)) } if err := s.AddTags(ctx, created.ID, []string{"学习", "学习"}); err != nil { t.Fatal(err) } if err := s.Delete(ctx, created.ID); err != nil { t.Fatal(err) } if _, err := s.Get(ctx, created.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("want ErrNotFound, got %v", err) } } ``` **易错点 / 面试常问(补充)** - 易错点:集成测试每次开始前要清理数据(本周用 `TRUNCATE ... RESTART IDENTITY CASCADE`),否则测试之间互相影响、结果不稳定。 - 易错点:`go test ./... -race` 能发现数据竞争,但只在测试实际跑到的代码路径上。 - 面试常问:单元测试、集成测试、端到端测试的区别和比例;为什么用接口(`Store`)隔离存储层——测试可以换成内存实现,业务代码不用改。 ### 要建的文件 - `internal/config/config.go`、更新 `main.go`、更新 `internal/handler/task_handler_test.go`、`internal/store/pg_store_test.go` - `todo-api/README.md` 补充:数据库启动、迁移、切换存储、鉴权说明。 - 提交推送(确认 `.env` 没提交)。 ### 运行方式 ```bash export JWT_SECRET=$(openssl rand -hex 32) STORE=postgres DATABASE_URL=postgres://todo:todo@localhost:5432/todo?sslmode=disable go run . TEST_DATABASE_URL=postgres://todo:todo@localhost:5432/todo?sslmode=disable go test ./... -race ``` ### 完成标准 - 切换 `STORE` 后接口行为一致,重启后数据还在;全部测试通过;`go vet` 无警告。 --- ## 本周相关专题 > 本节由 `tools/gen_week_topics.py` 根据专题文件的 `week` 字段自动生成,请勿手改;想调整就修改对应专题文件的 frontmatter 后重新生成。 - **AI 编程练习(每周)**:本周至少一个功能用 AI 编程工具(Cursor / Claude Code / Codex 等)完成,按 [AI 编程:工作流](../topics/ai/ai-coding/01-workflow-rules-skills.md) 写清需求和规则,按 [验证与证据](../topics/ai/ai-coding/02-verify-evidence.md) 保存测试结果和会话记录(脱敏后),第 15 周整理成“AI 编程 + 验证方法”的应聘案例。 | 技术栈 | 课程 / 场景 / 面试题 | | --- | --- | | [Gin Web 框架](../topics/backend/gin/index.md) | 课程:[中间件、JWT 鉴权和统一错误处理](../topics/backend/gin/02-middleware-auth.md);场景:[企业后台接口规范](../topics/backend/gin/scenarios/unified-api-response.md);[面试题](../topics/backend/gin/interview.md) | | [Go 语言](../topics/backend/go/index.md) | 课程:[测试和工程化](../topics/backend/go/03-testing-engineering.md) | | [MySQL](../topics/backend/mysql/index.md) | 课程:[InnoDB 索引](../topics/backend/mysql/01-index-explain.md);课程:[事务、隔离级别、MVCC 和锁(行锁、间隙锁、死锁)](../topics/backend/mysql/02-tx-locks-mvcc.md);场景:[大表深分页和历史数据](../topics/backend/mysql/scenarios/deep-pagination.md);[面试题](../topics/backend/mysql/interview.md) | | [PostgreSQL](../topics/backend/postgresql/index.md) | 课程:[表设计和 SQL](../topics/backend/postgresql/01-schema-sql.md);课程:[索引、EXPLAIN、事务、隔离级别和 MVCC](../topics/backend/postgresql/02-index-tx-mvcc.md);场景:[订单状态机](../topics/backend/postgresql/scenarios/order-state-audit.md);[面试题](../topics/backend/postgresql/interview.md) | ## 本周自查清单 - [ ] 会建表、加约束,会设计一对多和多对多 - [ ] 会写 `JOIN`、`GROUP BY`、`HAVING`,会看 PostgreSQL 和 MySQL 的执行计划 - [ ] 能说出索引失效的常见情况、B+ 树、回表和覆盖索引 - [ ] 会写迁移,理解“先迁移再发代码” - [ ] 会用 pgx 写参数化查询和事务,会用 `ON CONFLICT` 做幂等写入 - [ ] 能讲清四个隔离级别、MVCC、乐观锁和悲观锁 - [ ] 能说出至少 5 条 MySQL 和 PostgreSQL 的差异 - [ ] 会用 bcrypt 存密码,会签发和校验 JWT,分得清 401 和 403 - [ ] 在总纲进度记录里勾掉第 2 周