Files
zhuangang/plan/week03-project1.md
T

30 KiB
Raw Blame History

id, title, type, domain, stack, week, est_hours, order
id title type domain stack week est_hours order
plan.week03 第 3 周:项目一“团队任务看板”:权限 + Redis + 消息队列 + Docker 部署 week plan
go
gin
postgresql
redis
mq
docker
nginx
cicd
3
12 3

第 3 周:项目一“团队任务看板”:权限 + Redis + 消息队列 + Docker 部署

第一段“Go 全栈基础”的最后一周。上周 week02-db-auth.md,下周 week04-python.md。 前端界面放到第 7 周“前端工程化查漏”里用 Vue 3 / React 完整做,本周只做后端,并用 api.http 和测试验证。

本周目标:在 todo-api 的基础上做出能写进简历的后端项目:多人看板 + 成员权限;再补上招聘里常见的 Redis(缓存、限流、分布式锁)、消息队列(Redis Streams 入门,了解 RabbitMQ / Kafka)、Docker + Nginx 部署和 CI。

原则:Redis、消息队列、Docker 都“够用就好”,Go 部分到本周结束,之后主线是 Python 和 AI 应用。

参考资料

本周最终目录

task-board/                          # 把 todo-api/backend 复制过来改名,go.mod 改为 module task-board
├── docker-compose.yml               # postgres、redis、migrate、api、worker、nginx
├── docker-compose.dev.yml
├── Dockerfile
├── deploy/nginx.conf
├── deploy/deploy.md
├── .github/workflows/ci.yml         # 或你的托管平台对应的 CI 目录
├── cmd/server/main.go
├── cmd/worker/main.go
├── db/migrations/000004_boards.up.sql / .down.sql
└── internal/
    ├── service/
    │   ├── errors.go
    │   ├── permission.go            # 权限判断
    │   └── permission_test.go
    ├── cache/board_cache.go         # 旁路缓存 + singleflight + 降级
    ├── ratelimit/limiter.go         # Lua 固定窗口限流
    ├── lock/lock.go                 # Redis 分布式锁
    ├── queue/stream.go              # Redis Streams 生产和消费
    ├── queue/stream_test.go
    └── ...(沿用上周的 auth、handler、middleware、store)

第 1 天:看板建模和权限

学习内容

1. 从需求到表结构

  • 说明:先列“谁、有什么、能做什么”:用户可以建多个看板;看板有成员,成员分 owner 和 member;任务属于看板,分“待办 / 进行中 / 已完成”三列,可以指派给成员。用户和看板是多对多,角色放在关联表上。
  • 案例:
-- 文件:db/migrations/000004_boards.up.sql
CREATE TABLE boards (
  id         BIGSERIAL PRIMARY KEY,
  name       TEXT NOT NULL,
  owner_id   BIGINT NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE 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')),
  PRIMARY KEY (board_id, user_id)
);
CREATE INDEX idx_members_user ON board_members(user_id);

CREATE TABLE board_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),
  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),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_board_tasks ON board_tasks(board_id, status, position);

2. service 层和权限判断

  • 说明:权限判断必须在后端做(前端隐藏按钮只是体验)。放在 service 层,handler 只负责解析和响应。非成员访问看板返回 404 而不是 403,不暴露“这个看板存在”。
  • 案例:权限函数 + 表格驱动测试(用假的仓储,不需要数据库)。
// 文件:internal/service/errors.go
package service

import "errors"

var (
	ErrNotFound  = errors.New("not found")
	ErrForbidden = errors.New("forbidden")
)
// 文件:internal/service/permission.go
package service

import (
	"context"
	"errors"
)

type Role string

const (
	RoleOwner  Role = "owner"
	RoleMember Role = "member"
)

// MemberRepo 由数据库实现:SELECT role FROM board_members WHERE board_id=$1 AND user_id=$2
// 查不到时返回 ErrNotFound。
type MemberRepo interface {
	GetRole(ctx context.Context, boardID, userID int64) (Role, error)
}

func RequireRole(ctx context.Context, repo MemberRepo, boardID, userID int64, need Role) error {
	role, err := repo.GetRole(ctx, boardID, userID)
	if errors.Is(err, ErrNotFound) {
		return ErrNotFound // 不是成员:当作看板不存在
	}
	if err != nil {
		return err
	}
	if need == RoleOwner && role != RoleOwner {
		return ErrForbidden
	}
	return nil
}
// 文件:internal/service/permission_test.go
package service

import (
	"context"
	"errors"
	"testing"
)

type fakeRepo map[[2]int64]Role

func (f fakeRepo) GetRole(_ context.Context, b, u int64) (Role, error) {
	if r, ok := f[[2]int64{b, u}]; ok {
		return r, nil
	}
	return "", ErrNotFound
}

func TestRequireRole(t *testing.T) {
	repo := fakeRepo{{1, 10}: RoleOwner, {1, 20}: RoleMember}
	cases := []struct {
		user int64
		need Role
		want error
	}{
		{10, RoleOwner, nil},
		{20, RoleMember, nil},
		{20, RoleOwner, ErrForbidden},
		{30, RoleMember, ErrNotFound},
	}
	for _, c := range cases {
		got := RequireRole(context.Background(), repo, 1, c.user, c.need)
		if !errors.Is(got, c.want) && got != c.want {
			t.Errorf("user=%d need=%s got %v want %v", c.user, c.need, got, c.want)
		}
	}
}

易错点 / 面试常问(补充)

  • 易错点:权限判断要放在服务层(或中间件)统一做,不能只靠前端隐藏按钮;每个接口都要校验“当前用户是不是这个看板的成员、角色够不够”。
  • 易错点:查不到看板和没有权限时,对外可以都返回 404,避免泄露“这个看板存在”。
  • 面试常问:RBAC(基于角色的权限控制)是什么;多租户数据隔离怎么做(每条查询都带上 board_id / 租户条件)。

要建的文件

  • 复制 todo-api/backend 为 task-board/,改模块名;000004_boards 迁移;internal/service/*。

运行方式

make migrate-up && go test ./internal/service -v

完成标准

  • 能说清角色为什么放在关联表上、为什么非成员返回 404。

第 2 天:看板和任务接口、拖动排序的事务

学习内容

1. 接口设计

方法 路径 权限
GET / POST /api/boards 登录
GET /api/boards/:boardId 成员
POST / DELETE /api/boards/:boardId/members[/:userId] owner
GET / POST /api/boards/:boardId/tasks 成员
PATCH / DELETE /api/boards/:boardId/tasks/:taskId 成员(删除:创建者或 owner)
POST /api/boards/:boardId/tasks/:taskId/move 成员
  • 说明:所有任务查询都同时带上 board_id 条件,防止用 A 看板的权限去改 B 看板的任务(越权)。

2. 拖动排序用事务

  • 说明:把任务移动到某列的第 n 个位置,需要“目标位置之后的任务整体后移一位”+“更新这个任务”两步,必须在一个事务里,否则并发时位置会错乱。
  • 案例:
-- move 的两条 SQL(在 Go 里用 withTx 包起来)
UPDATE board_tasks SET position = position + 1
WHERE board_id = $1 AND status = $2 AND position >= $3 AND id <> $4;

UPDATE board_tasks SET status = $2, position = $3, updated_at = now()
WHERE id = $4 AND board_id = $1
RETURNING id, status, position;
  • 创建看板也要事务:插入看板 + 把自己加为 owner。

易错点 / 面试常问(补充)

  • 易错点:只用事务还不够。PostgreSQL 默认“读已提交”隔离级别下,两个人同时往同一列拖动,仍可能得到重复的位置。可以在事务开头执行 SELECT id FROM boards WHERE id = $1 FOR UPDATE 锁住看板这一行,让同一看板的移动排队执行(建议作为本天的加练)。
  • 易错点:事务里出错要回滚,Go 里常见写法是 defer tx.Rollback(ctx),提交成功后再调用回滚是无害的。
  • 面试常问:排序字段的几种设计(连续整数、间隔整数、小数 / 字符串排序键)和各自的代价;乐观更新失败时前端怎么回滚。

要建的文件

  • internal/store/board_store.go:CreateBoard(事务)、ListBoardsByUser、GetRole、AddMember、RemoveMember、ListTasks、CreateTask、MoveTask(事务)、DeleteTask。
  • internal/service/board_service.go、task_service.go:每个方法先 RequireRole。
  • internal/handler/board_handler.go、board_task_handler.go;handler/errors.go 把 service.ErrNotFound/ErrForbidden 映射到 404/403。
  • 接口测试:未登录 401;非成员 404;member 加成员 403;跨看板用别的 taskId 返回 404。

运行方式

go test ./... -race

完成标准

  • 两个账号在 api.http 里走通:A 建看板 → B 看不到 → A 加 B → B 能看到 → B 加成员得到 403。

第 3 天:Redis 基础和缓存

学习内容

1. Redis 是什么、五种常用结构

  • 说明:内存里的键值数据库,单线程执行命令(6.0 起网络 IO 可多线程),速度快。常用结构和典型用途:String(缓存、计数器)、Hash(对象字段)、List(简单队列、最近 N 条)、Set(去重、标签)、Sorted Set(排行榜、延时队列)。持久化有 RDB(快照)和 AOF(追加日志)两种。
  • 案例:用 redis-cli 体验。
docker compose -f docker-compose.dev.yml up -d redis   # compose 里加 redis:7 服务,端口 6379
docker compose -f docker-compose.dev.yml exec redis redis-cli
SET board:1:name "转岗看板" EX 60    # 60 秒后过期
TTL board:1:name
INCR page:views
HSET user:1 name 小王 city 上海
LPUSH recent:1 task3 task2 task1
LRANGE recent:1 0 9
SADD board:1:tags 学习 工作 学习     # 自动去重
ZADD rank 90 小王 85 小李
ZREVRANGE rank 0 9 WITHSCORES

2. 旁路缓存(Cache Aside)

  • 说明:读:先查缓存,没有再查数据库并写回;写:先更新数据库,再删除缓存(而不是更新缓存,避免并发写导致缓存是旧值)。三个经典问题:
    • 缓存穿透:查不存在的数据,每次都打到数据库 → 缓存空值 / 布隆过滤器;
    • 缓存击穿:热点 key 过期瞬间大量请求 → 互斥锁或 singleflight 合并请求;
    • 缓存雪崩:大量 key 同时过期 → 过期时间加随机值。
  • 另外要降级:Redis 挂了不能让接口跟着挂。
  • 案例:看板任务列表缓存。
// 文件:internal/cache/board_cache.go
package cache

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"math/rand/v2"
	"time"

	"github.com/redis/go-redis/v9"
	"golang.org/x/sync/singleflight"
)

type BoardCache struct {
	rdb *redis.Client
	ttl time.Duration
	sf  singleflight.Group
}

func NewBoardCache(rdb *redis.Client, ttl time.Duration) *BoardCache {
	return &BoardCache{rdb: rdb, ttl: ttl}
}

func key(boardID int64) string { return fmt.Sprintf("board:%d:tasks", boardID) }

// GetOrLoad 返回看板任务列表的 JSON;load 负责从数据库查询并序列化。
func (c *BoardCache) GetOrLoad(ctx context.Context, boardID int64, load func(context.Context) ([]byte, error)) ([]byte, error) {
	k := key(boardID)
	b, err := c.rdb.Get(ctx, k).Bytes()
	if err == nil {
		return b, nil // 命中
	}
	if !errors.Is(err, redis.Nil) {
		slog.Warn("redis 不可用,降级查数据库", "err", err)
		return load(ctx)
	}
	// 未命中:同一个看板的并发请求只查一次数据库(防击穿)
	v, err, _ := c.sf.Do(k, func() (any, error) {
		data, err := load(ctx)
		if err != nil {
			return nil, err
		}
		jitter := time.Duration(rand.Int64N(int64(30 * time.Second))) // 防雪崩
		if err := c.rdb.Set(ctx, k, data, c.ttl+jitter).Err(); err != nil {
			slog.Warn("写缓存失败", "err", err)
		}
		return data, nil
	})
	if err != nil {
		return nil, err
	}
	return v.([]byte), nil
}

// Invalidate 在任务增删改、移动成功后调用。
func (c *BoardCache) Invalidate(ctx context.Context, boardID int64) error {
	return c.rdb.Del(ctx, key(boardID)).Err()
}
  • 面试常问:为什么删缓存而不是更新缓存;“先删缓存再更新数据库”会有什么问题(并发读把旧值写回缓存);延迟双删是什么。

要建的文件

  • internal/cache/board_cache.go;task_service 的 List 走缓存,写操作后 Invalidate。

运行方式

go get github.com/redis/go-redis/v9 golang.org/x/sync/singleflight
go build ./...

完成标准

  • 第二次请求命中缓存(日志能看出来);停掉 Redis 后接口照常返回。

第 4 天:限流和分布式锁

学习内容

1. 固定窗口限流(Lua 保证原子性)

  • 说明:防暴力破解登录、防刷接口、保护下游(以后调大模型接口也要限流,有额度和费用)。“计数 +1”和“首次设置过期”必须原子执行,否则进程在两步之间崩溃会留下永不过期的 key;用 Lua 脚本在 Redis 里一次执行。其他算法:滑动窗口(更平滑)、令牌桶(允许一定突发),能讲清思路即可。
  • 案例:
// 文件:internal/ratelimit/limiter.go
package ratelimit

import (
	"context"
	"time"

	"github.com/redis/go-redis/v9"
)

var fixedWindow = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 then
  redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return n`)

type Limiter struct{ rdb *redis.Client }

func New(rdb *redis.Client) *Limiter { return &Limiter{rdb: rdb} }

// Allow 返回本次是否放行。Redis 出错时放行(fail-open),是否这样做要按业务决定。
func (l *Limiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, error) {
	n, err := fixedWindow.Run(ctx, l.rdb, []string{"rl:" + key}, window.Milliseconds()).Int()
	if err != nil {
		return true, err
	}
	return n <= limit, nil
}
  • 用法:登录接口按“IP + 邮箱”每分钟 5 次,超出返回 429 和 Retry-After 响应头(写一个 Gin 中间件包装它)。

2. 分布式锁

  • 说明:多个进程(比如部署了两个后端实例)要保证同一时间只有一个在做某件事(比如每天生成一次统计报表)。Redis 实现要点:SET key 随机值 NX PX 过期时间 加锁(不存在才设置,带过期防止死锁);释放时只删自己的锁(比较随机值后再删,也要用 Lua 保证原子)。业务执行时间可能超过过期时间,需要续期(看门狗)或保证任务本身幂等。
  • 案例:
// 文件:internal/lock/lock.go
package lock

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"time"

	"github.com/redis/go-redis/v9"
)

var unlockScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0`)

type Lock struct {
	rdb   *redis.Client
	key   string
	token string
}

// TryLock 拿到锁返回 (lock, true);被别人持有返回 (nil, false)。
func TryLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, bool, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return nil, false, err
	}
	token := hex.EncodeToString(b)
	ok, err := rdb.SetNX(ctx, "lock:"+key, token, ttl).Result()
	if err != nil || !ok {
		return nil, false, err
	}
	return &Lock{rdb: rdb, key: "lock:" + key, token: token}, true, nil
}

func (l *Lock) Unlock(ctx context.Context) error {
	return unlockScript.Run(ctx, l.rdb, []string{l.key}, l.token).Err()
}
  • 面试常问:为什么释放锁要比较随机值;锁过期了业务还没做完怎么办;Redis 主从切换时锁可能丢失(了解 Redlock 的争议即可),强一致需求可以用数据库行锁或 etcd / ZooKeeper。

要建的文件

  • internal/ratelimit/limiter.go、internal/middleware/ratelimit.go、internal/lock/lock.go

运行方式

go build ./...

完成标准

  • 连续错误登录 6 次,第 6 次返回 429;两个终端同时调用 TryLock,只有一个成功。

第 5 天:消息队列入门(Redis Streams),了解 RabbitMQ / Kafka

学习内容

1. 为什么要消息队列

  • 说明:解耦(指派任务的接口不关心通知怎么发)、异步(接口快速返回,耗时工作交给后台)、削峰(高峰时先排队)。代价:系统更复杂,消息可能重复、可能延迟。
  • 核心概念:生产者、消费者、消费者组、确认(ACK)、重试、死信队列。大多数队列提供“至少一次投递”,所以消费者必须幂等(同一条消息处理两次结果一样,常用唯一约束或“已处理 ID”记录)。

2. Redis Streams 实战

  • 说明:Redis 5 起的 Stream 类型支持消费者组和 ACK,小项目不用额外部署中间件。XADD 发消息,XREADGROUP 按组读取,XACK 确认;没确认的消息留在待处理列表(PEL)里,消费者挂了可以用 XAUTOCLAIM 认领。
  • 案例:“任务被指派”事件 → worker 写通知。
// 文件:internal/queue/stream.go
package queue

import (
	"context"
	"errors"
	"log/slog"
	"strings"
	"time"

	"github.com/redis/go-redis/v9"
)

func Publish(ctx context.Context, rdb *redis.Client, stream string, body []byte) (string, error) {
	return rdb.XAdd(ctx, &redis.XAddArgs{Stream: stream, Values: map[string]any{"body": body}}).Result()
}

type Handler func(ctx context.Context, id string, body []byte) error

// Consume 持续消费,直到 ctx 被取消。处理成功才 ACK;失败的消息留在 PEL 里等待重试。
func Consume(ctx context.Context, rdb *redis.Client, stream, group, consumer string, h Handler) error {
	err := rdb.XGroupCreateMkStream(ctx, stream, group, "0").Err()
	if err != nil && !strings.Contains(err.Error(), "BUSYGROUP") { // 组已存在不算错
		return err
	}
	for ctx.Err() == nil {
		res, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
			Group: group, Consumer: consumer,
			Streams: []string{stream, ">"}, // ">" 表示只读从未投递过的新消息
			Count:   10, Block: time.Second,
		}).Result()
		if errors.Is(err, redis.Nil) {
			continue // 超时没有新消息
		}
		if err != nil {
			if ctx.Err() != nil {
				return nil
			}
			return err
		}
		for _, s := range res {
			for _, m := range s.Messages {
				body, _ := m.Values["body"].(string)
				if err := h(ctx, m.ID, []byte(body)); err != nil {
					slog.Warn("处理失败,稍后重试", "id", m.ID, "err", err)
					continue
				}
				rdb.XAck(ctx, stream, group, m.ID)
			}
		}
	}
	return nil
}
// 文件:internal/queue/stream_test.go
package queue

import (
	"context"
	"os"
	"sync"
	"testing"
	"time"

	"github.com/redis/go-redis/v9"
)

func TestPublishConsumeIdempotent(t *testing.T) {
	url := os.Getenv("REDIS_URL")
	if url == "" {
		t.Skip("未设置 REDIS_URL,跳过")
	}
	opt, _ := redis.ParseURL(url)
	rdb := redis.NewClient(opt)
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	stream := "test:assigned:" + time.Now().Format("150405.000")
	defer rdb.Del(context.Background(), stream)

	// 同一个事件发两次,模拟“至少一次投递”造成的重复
	for i := 0; i < 2; i++ {
		if _, err := Publish(ctx, rdb, stream, []byte(`{"eventId":"e1","taskId":7}`)); err != nil {
			t.Fatal(err)
		}
	}

	var mu sync.Mutex
	processed := map[string]bool{} // 真实项目里用数据库唯一约束 event_id 实现幂等
	count := 0
	go Consume(ctx, rdb, stream, "notify", "c1", func(_ context.Context, _ string, body []byte) error {
		mu.Lock()
		defer mu.Unlock()
		count++
		processed[string(body)] = true
		if count == 2 {
			cancel()
		}
		return nil
	})
	<-ctx.Done()
	if count != 2 || len(processed) != 1 {
		t.Fatalf("收到 %d 条,去重后 %d 条", count, len(processed))
	}
}

3. RabbitMQ 和 Kafka(只学概念)

Redis Streams RabbitMQ Kafka
定位 轻量,复用已有 Redis 通用消息中间件,路由灵活 高吞吐的分布式日志,适合事件流和大数据
核心概念 stream、消费者组、PEL 交换机(direct/topic/fanout)、队列、路由键、ACK、死信 topic、分区、偏移量、消费者组、副本
消息保留 按长度或手动裁剪 消费确认后删除 按时间或大小保留,可回放
适合 小项目、简单异步任务 业务解耦、复杂路由、延迟和重试 日志收集、埋点、海量事件
  • 面试常问:如何保证消息不丢(生产者确认、持久化、消费成功再 ACK);如何处理重复消费(幂等);如何保证顺序(同一业务 key 发到同一分区 / 队列)。

4. gRPC(只学概念)

  • 说明:微服务之间常用 gRPC:用 .proto 文件定义接口和消息,生成多语言代码,基于 HTTP/2 和 Protobuf 二进制编码,比 JSON 接口更快、类型更严格;对外给浏览器的接口一般仍用 REST。读一遍官方 Go 快速入门(https://grpc.io/docs/languages/go/quickstart/),能说清 REST 和 gRPC 的取舍即可。

要建的文件

  • internal/queue/stream.go、stream_test.go;task_service 在负责人变化时 Publish;cmd/worker/main.go 调用 Consume,写 notifications 表(event_id 唯一约束做幂等,INSERT ... ON CONFLICT DO NOTHING)。

运行方式

REDIS_URL=redis://localhost:6379/0 go test ./internal/queue -v
go run ./cmd/server   # 一个终端
go run ./cmd/worker   # 另一个终端

完成标准

  • 停掉 worker 后指派任务,再启动 worker,通知能补上;重复消息只产生一条通知。

第 6 天:Docker、Compose、Nginx

学习内容

1. Dockerfile 多阶段构建

  • 说明:镜像是打包好的运行环境,容器是镜像的运行实例。多阶段构建:第一阶段用带编译器的镜像编译,第二阶段只拷贝编译好的二进制,镜像小、不含源码,并且用非 root 用户运行。
  • 案例:
# 文件:Dockerfile
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download                 # 依赖不变时这一层走缓存
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server && \
    CGO_ENABLED=0 go build -o /out/worker ./cmd/worker

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/ /app/
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/app/server"]

(Go 版本号按你本地 go version 改;国内拉取镜像慢可以配置镜像加速。)

2. Docker Compose 编排多个服务

  • 说明:一个文件描述所有服务、网络和卷;服务之间用服务名互相访问(postgres:5432);depends_on + 健康检查控制启动顺序;配置用 .env 注入。
  • 案例:
# 文件:docker-compose.yml
services:
  postgres:
    image: postgres:16
    environment: { POSTGRES_USER: board, POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}", POSTGRES_DB: board }
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck: { test: ["CMD-SHELL", "pg_isready -U board"], interval: 5s, retries: 10 }
    restart: unless-stopped
  redis:
    image: redis:7
    restart: unless-stopped
  migrate:
    image: migrate/migrate
    volumes: [./db/migrations:/migrations]
    command: ["-path", "/migrations", "-database", "postgres://board:${POSTGRES_PASSWORD}@postgres:5432/board?sslmode=disable", "up"]
    depends_on: { postgres: { condition: service_healthy } }
  api:
    build: .
    environment:
      STORE: postgres
      DATABASE_URL: postgres://board:${POSTGRES_PASSWORD}@postgres:5432/board?sslmode=disable
      REDIS_URL: redis://redis:6379/0
      JWT_SECRET: ${JWT_SECRET}
    depends_on:
      migrate: { condition: service_completed_successfully }
      redis: { condition: service_started }
    restart: unless-stopped
  worker:
    build: .
    entrypoint: ["/app/worker"]
    environment:
      DATABASE_URL: postgres://board:${POSTGRES_PASSWORD}@postgres:5432/board?sslmode=disable
      REDIS_URL: redis://redis:6379/0
    depends_on: [api]
    restart: unless-stopped
  nginx:
    image: nginx:1.27
    ports: ["80:80"]
    volumes:
      - ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./web-dist:/usr/share/nginx/html:ro      # 第 7 周前端构建产物放这里
    depends_on: [api]
    restart: unless-stopped
volumes:
  pgdata:

3. Nginx 反向代理

  • 说明:Nginx 对外只暴露 80/443,静态文件直接返回,/api/ 转发给后端;单页应用的前端路由要回退到 index.html。以后代理流式接口(SSE)时要关闭缓冲。
  • 案例:
# 文件:deploy/nginx.conf
server {
    listen 80;
    root /usr/share/nginx/html;

    location /api/ {
        proxy_pass http://api:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_buffering off;          # 流式接口需要
    }

    location / {
        try_files $uri $uri/ /index.html;   # 单页应用路由回退
    }
}
  • Linux 常用命令(部署和排查要用):ss -lntp(看端口)、df -h / free -h(磁盘和内存)、journalctl -u docker、docker compose logs -f api、docker stats、ufw allow 80。

易错点 / 面试常问(补充)

  • 易错点:Dockerfile 先复制 go.mod / go.sum 再下载依赖、最后复制源码,这样改代码不会让依赖层缓存失效。
  • 易错点:容器里服务要监听 0.0.0.0 而不是 127.0.0.1,否则宿主机访问不到;Compose 里服务之间用服务名访问(如 postgres:5432),不是 localhost。
  • 易错点:depends_on 只保证启动顺序,不保证数据库已经可以连接,要配合 healthcheck 和 condition: service_healthy。
  • 面试常问:镜像和容器的区别;多阶段构建为什么能让镜像变小;Nginx 反向代理做了什么(同域转发、静态文件、HTTPS、负载均衡)。

要建的文件

  • Dockerfile、.dockerignore、docker-compose.yml、deploy/nginx.conf、.env.example(POSTGRES_PASSWORD、JWT_SECRET)

运行方式

cp .env.example .env   # 改掉密码和密钥
docker compose up -d --build
curl -s localhost/api/health

完成标准

  • 一台只装了 Docker 的机器,一条命令跑起整套服务;后端镜像不含源码。

第 7 天:部署到服务器 + CI + 收尾

学习内容

1. 部署

  • 说明:最小流程:服务器装 Docker → 拉代码 → 配 .env(权限 600)→ docker compose up -d --build;更新时 git pull 再执行一次。数据库定期 pg_dump 备份。
  • 案例:deploy/deploy.md 写成可以照抄的步骤清单。

2. CI(持续集成)

  • 说明:每次推送自动跑检查和测试,坏代码进不了主分支。CD(持续部署)是测试通过后自动部署,本计划先手动部署。不同平台配置位置不同:GitHub Actions 是 .github/workflows/,Gitea Actions 写法和它基本兼容,GitLab 是 .gitlab-ci.yml。先确认你的 git.gozeros.com 是哪种平台、是否开启了 CI。
  • 案例(GitHub / Gitea Actions 写法):
# 文件:.github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_USER: test, POSTGRES_PASSWORD: test, POSTGRES_DB: test }
        ports: ["5432:5432"]
      redis:
        image: redis:7
        ports: ["6379:6379"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version-file: go.mod }
      - run: go vet ./...
      - run: go install -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@latest
      - run: migrate -path db/migrations -database "postgres://test:test@localhost:5432/test?sslmode=disable" up
      - run: go test ./... -race
        env:
          TEST_DATABASE_URL: postgres://test:test@localhost:5432/test?sslmode=disable
          REDIS_URL: redis://localhost:6379/0
      - run: docker build -t task-board .

易错点 / 面试常问(补充)

  • 易错点:服务器上的密钥放在 .env 或 CI 的密钥配置里,不写进仓库和镜像;部署后检查 docker compose ps 和日志,确认迁移执行成功再放流量。
  • 易错点:CI 里的测试要用 CI 提供的数据库服务(或跳过集成测试),不要连生产库。
  • 面试常问:CI 和 CD 的区别;一次发布出问题怎么回滚(保留上一个镜像标签,docker compose 切回去;数据库迁移要向后兼容才能安全回滚)。

要建的文件

  • deploy/deploy.md、CI 配置;task-board/README.md:功能、架构图(mermaid:Nginx → API → PostgreSQL / Redis,API → Stream → Worker)、接口表、权限设计、缓存 / 限流 / 锁 / 队列的取舍、本地运行和部署步骤、已知不足。
  • 提交推送,确认 .env 没有提交。

运行方式

在服务器上按 deploy.md 执行一遍。

完成标准

  • 服务器上能访问 /api/health;能用 3 分钟讲清项目一:分层、权限、缓存、限流、分布式锁、消息队列与幂等、部署。

本周自查清单

  • 权限在后端 service 层判断,有测试;能讲清 401 / 403 / 404 的选择
  • 会用事务处理“多步写入必须一起成功”
  • 会用 Redis 五种结构;能讲清旁路缓存、穿透 / 击穿 / 雪崩、降级
  • 会写 Lua 限流和分布式锁,知道锁的坑
  • 会用 Redis Streams 做消费者组,能讲清“至少一次 + 幂等”;能对比 RabbitMQ 和 Kafka;知道 gRPC 是什么
  • 会写多阶段 Dockerfile、Compose、Nginx 反向代理,会基本的 Linux 排查命令
  • 有 CI 配置,项目一已部署
  • 在总纲进度记录里勾掉第 3 周和“第一段 Go 全栈基础”