Compare commits

...

10 Commits

Author SHA1 Message Date
leonincs
1b74c82ed4 fix(compose): 修复前端健康检查地址
Some checks failed
CI / Backend (push) Has been cancelled
CI / Frontend (push) Has been cancelled
2026-05-24 00:19:47 +08:00
leonincs
88196db47f ci: 搭建前后端持续集成 2026-05-24 00:19:34 +08:00
Chaoqian Xian
8a3d18b097 Merge pull request #14 from yiyiis/fix/system-reliability
Fix/system reliability
2026-05-23 23:16:31 +08:00
Chaoqian Xian
8a3433b5d2 Merge pull request #13 from yiyiis/fix/separate-rabbitmq-channels
Fix/separate rabbitmq channels
2026-05-23 23:14:58 +08:00
yiyiis
cbfdb73fcb fix: Notification 重连 + Social 修复 + Following 缓存失效
- Notification Worker 加重连循环,Channel 断开不再静默死亡
- Social: 先写 DB 再发 MQ,修复 DB 失败产生幽灵通知的问题
- Social: MQ 发布失败记日志,不再静默忽略
- 关注/取关后失效该用户的 Following Feed 缓存(24h TTL)
- Redis Client 新增 DelByPattern 按模式批量删除缓存
2026-05-23 09:23:47 +08:00
yiyiis
589116b141 fix: Worker 进程独立 Channel + 进程内指数退避重试
- 每个 Worker 消费者使用独立 AMQP Channel,互不影响
- 用 runWorkerWithRetry 包装每个 Worker,断开自动重连
- 用进程内指数退避重试替代失效的 Nack+DLX 重试机制
- 拓扑声明改用临时 Channel,声明完即关闭
2026-05-23 09:23:14 +08:00
yiyiis
33c0bd418c fix: 点赞后失效推荐列表的 video:entity 缓存
UpdatePopularityCache 只失效了 video:detail 缓存,
未失效 feed 使用的 video:entity 缓存(1h TTL),
导致推荐列表 likes_count 长时间显示为旧值
2026-05-23 00:36:02 +08:00
yiyiis
28408606f4 fix: Worker 进程加载 .env 配置
Worker 缺少 godotenv.Load() 导致 CONFIG_PATH 未生效,
始终使用默认 config.yaml(MySQL 3306)而非 compose-local(3307)
2026-05-23 00:35:55 +08:00
yiyiis
d8bca16362 fix: 拆分 RabbitMQ Channel,修复新视频不出现在推荐列表的问题
- RabbitMQ 结构体移除共享 Ch 字段,仅管理 Connection
- 每个MQ组件(Like/Comment/Social/Popularity/Timeline)持有独立 Channel
- Timeline Consumer 使用独立 Channel 并加入自动重连机制
- Redis 操作超时从 50ms 调整为 500ms,避免 NACK 循环
- router.go 中 notification 相关逻辑适配新 API
2026-05-22 23:57:06 +08:00
leonincs
229446e93b docs: 同步新增功能文档 2026-05-22 19:27:18 +08:00
22 changed files with 630 additions and 288 deletions

64
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: CI
on:
pull_request:
push:
branches:
- main
- master
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.x"
cache-dependency-path: backend/go.sum
- name: Download dependencies
run: go mod download
- name: Vet
run: go vet ./...
- name: Test
run: go test -race -count=1 ./...
frontend:
name: Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build

View File

@@ -1,6 +1,6 @@
# feedsystem_video_go
基于 Go + Vue 3 的短视频 Feed 系统含账号、视频、点赞、评论、关注、Feed 流,支持 Redis 缓存RabbitMQ 异步 WorkerAPI 与 Worker 可拆分部署
基于 Go + Vue 3 的短视频 Feed 系统含账号、视频、点赞、评论、关注、Feed 流、私信、通知,支持 Redis 缓存RabbitMQ 异步 Worker、分片上传、SSE 实时推送、Docker Compose 部署。
## 更完整的视频 Feed 流系统项目
@@ -10,14 +10,15 @@
| 模块 | 功能 |
|------|------|
| 账号 | 注册/登录/改名/改密/登出头像上传个人简介Refresh Token 双 Token 鉴权 |
| 视频 | 上传/发布/删除,按作者查看,详情(三级缓存),#话题标签 |
| 点赞 | 点赞/取消/是否已赞/已赞列表SSE 实时通知 |
| 评论 | 发布/删除/列表@提及 通知 |
| 关注 | 关注/取关/粉丝列表/关注列表/粉丝计数SSE 实时通知 |
| Feed | 最新/点赞榜/热度榜/关注流/话题标签流,冷热分离+游标分页,虚拟滚动 |
| 私信 | 发送/对话列表 |
| 通知 | SSE 实时推送未读计数已读标记 |
| 账号 | 注册登录、Refresh Token、改名改密登出头像上传个人简介、主页统计 |
| 视频 | 普通上传、5MB 分片上传、断点续传、封面上传发布、作者作品、详情缓存、#话题标签 |
| 点赞 | 点赞取消点赞、是否已赞已赞列表、RabbitMQ 异步落库、热度更新、SSE 通知 |
| 评论 | 发布删除列表@username 提及通知、RabbitMQ 异步落库、热度更新 |
| 关注 | 关注取关粉丝列表关注列表粉丝/关注计数SSE 通知 |
| Feed | 推荐流、关注流、点赞榜、热榜、话题流、冷热分离游标分页、短视频沉浸播放 |
| 私信 | 发送私信、按对端用户查看最近 50 条会话 |
| 通知 | 点赞/评论/关注事件通知、提及通知、SSE 实时推送、通知列表、未读计数已读标记 |
| 工程 | Docker Compose、`start.sh`、API/Worker 拆分运行、限流、pprof、健康检查 |
## Docker Compose 一键启动
@@ -30,11 +31,22 @@ docker compose up -d --build
- 后端 API`http://localhost:8080`
- RabbitMQ 管理台:`http://localhost:15672``admin` / `password123`
默认 `.env` 自动生成 JWT 密钥。生产环境请修改 `JWT_SECRET`
Docker Compose 会读取 `.env`,缺省使用 `feedsystem-dev-secret-key`。生产环境请修改 `JWT_SECRET`
## 测试数据
## 脚本启动
启动后内置 100 个测试用户(`user001` ~ `user100`,密码均为 `123456``user001` 已发布视频并拥有粉丝/点赞数据。
```bash
./start.sh
```
`start.sh` 默认启动 RabbitMQ、Redis、后端 API、Worker 与前端。常用开关:
```bash
START_FRONTEND=0 ./start.sh # API + Worker
START_WORKER=0 ./start.sh # API + 前端
STOP_DOCKER=1 ./start.sh # 退出时停止脚本拉起的 compose 服务
CONFIG_PATH=configs/config.yaml ./start.sh
```
## 本地开发
@@ -54,6 +66,13 @@ cd frontend
npm install && npm run dev
```
## CI
GitHub Actions 配置位于 `.github/workflows/ci.yml`,在 Pull Request 以及推送到 `main``master` 时运行。
- 后端Go 1.24.x执行 `go mod download``go vet ./...``go test -race -count=1 ./...`
- 前端Node.js 22执行 `npm ci``npm run build`
## 接口清单
### 账号 `/account`
@@ -77,8 +96,12 @@ npm install && npm run dev
| POST | `/publish` | JWT | 发布视频(自动提取 #话题 |
| POST | `/uploadVideo` | JWT | 上传视频文件mp4≤200MB |
| POST | `/uploadCover` | JWT | 上传封面jpg/png/webp≤10MB |
| POST | `/chunk/init` | JWT | 初始化分片上传(文件 MD5、大小、分片数 |
| POST | `/chunk/upload` | JWT | 上传单个分片multipart含分片 MD5 校验) |
| POST | `/chunk/status` | JWT | 查询已上传分片 |
| POST | `/chunk/complete` | JWT | 合并分片并返回 play_url |
| POST | `/listByAuthorID` | 否 | 按作者查视频 |
| POST | `/getDetail` | 否 | 视频详情(三级缓存 |
| POST | `/getDetail` | 否 | 视频详情缓存 |
### 点赞 `/like`
| 方法 | 路径 | 鉴权 | 说明 |
@@ -116,9 +139,9 @@ npm install && npm run dev
### 通知 `/notification`
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | `/stream?token=` | 是 | SSE 实时推送 |
| POST | `/list` | 是 | 通知列表 |
| POST | `/markRead` | 是 | 标记已读id 单条,不传全标) |
| GET | `/stream?token=<access_token>` | 是 | SSE 实时推送,也支持 `Authorization: Bearer <token>` |
| POST | `/list` | 是 | 最近 50 条通知 |
| POST | `/markRead` | 是 | 标记已读`id` 标记单条,省略 `id` 标记全部 |
| POST | `/unreadCount` | 是 | 未读计数 |
### 私信 `/message`
@@ -132,8 +155,23 @@ npm install && npm run dev
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `JWT_SECRET` | `feedsystem-dev-secret-key` | JWT 签名密钥,生产须改 |
| `SERVER_PORT` | `8080` | 后端监听端口 |
| `MYSQL_HOST` / `MYSQL_PORT` | 配置文件值 | MySQL 地址 |
| `MYSQL_USER` / `MYSQL_PASSWORD` | 配置文件值 | MySQL 账号密码 |
| `MYSQL_ROOT_PASSWORD` | `123456` | MySQL root 密码 |
| `MYSQL_DATABASE` | `feedsystem` | MySQL 数据库名 |
| `REDIS_HOST` / `REDIS_PORT` | 配置文件值 | Redis 地址 |
| `REDIS_PASSWORD` | `123456` | Redis 密码 |
| `REDIS_DB` | `0` | Redis DB |
| `RABBITMQ_HOST` / `RABBITMQ_PORT` | 配置文件值 | RabbitMQ 地址 |
| `RABBITMQ_USER` / `RABBITMQ_PASS` | `admin` / `password123` | RabbitMQ 账号 |
详见 `.env.example`
## 运维与可观测性
- `GET /healthz` 返回后端健康状态。
- 本地配置默认开启 pprofAPI `localhost:6060`Worker `localhost:6061`
- 上传文件写入 `backend/.run/uploads`Docker 环境挂载到 `backend_uploads` volume。
- Redis 用于 Token 缓存、视频实体缓存、Feed 时间线、热榜窗口、分片上传会话。
- RabbitMQ Topic Exchange 覆盖点赞、评论、关注、热度、视频时间线事件,并配置 DLX。

View File

@@ -18,6 +18,7 @@ import (
"time"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/joho/godotenv"
"gorm.io/gorm"
)
@@ -54,7 +55,43 @@ func connectWithRetry(name string, maxRetries int, fn func() error) {
log.Fatalf("%s: 超过最大重试次数", name)
}
// runWorkerWithRetry 为每个 Worker 创建独立 Channel断开后自动重连
func runWorkerWithRetry(ctx context.Context, name string, conn *amqp.Connection, fn func(*amqp.Channel) error) {
for {
select {
case <-ctx.Done():
return
default:
}
ch, err := conn.Channel()
if err != nil {
log.Printf("%s: 创建 Channel 失败: %v, 5秒后重试", name, err)
time.Sleep(5 * time.Second)
continue
}
if err := ch.Qos(50, 0, false); err != nil {
log.Printf("%s: QoS 设置失败: %v", name, err)
}
log.Printf("%s started, consuming", name)
if err := fn(ch); err != nil {
if ctx.Err() != nil {
ch.Close()
return
}
log.Printf("%s: %v, 5秒后重连...", name, err)
}
ch.Close()
time.Sleep(5 * time.Second)
}
}
func main() {
// 加载 .env本地开发
if err := godotenv.Load(); err != nil {
log.Println(".env not found; continuing")
}
// 加载配置
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
@@ -105,42 +142,33 @@ func main() {
return err
})
defer conn.Close()
// 创建 RabbitMQ 通道
ch, err := conn.Channel()
// 用临时 Channel 声明拓扑(持久化队列,声明一次即可)
topoCh, err := conn.Channel()
if err != nil {
log.Fatalf("Failed to open rabbitmq channel: %v", err)
log.Fatalf("Failed to open topology channel: %v", err)
}
defer ch.Close()
// 声明 Social 交换机和队列
if err := declareSocialTopology(ch); err != nil {
if err := declareSocialTopology(topoCh); err != nil {
log.Fatalf("Failed to declare social topology: %v", err)
}
if err := declareLikeTopology(ch); err != nil {
if err := declareLikeTopology(topoCh); err != nil {
log.Fatalf("Failed to declare like topology: %v", err)
}
if err := declareCommentTopology(ch); err != nil {
if err := declareCommentTopology(topoCh); err != nil {
log.Fatalf("Failed to declare comment topology: %v", err)
}
if cache != nil {
if err := declarePopularityTopology(ch); err != nil {
if err := declarePopularityTopology(topoCh); err != nil {
log.Fatalf("Failed to declare popularity topology: %v", err)
}
}
if err := ch.Qos(50, 0, false); err != nil {
log.Fatalf("Failed to set qos: %v", err)
}
topoCh.Close()
repo := social.NewSocialRepository(sqlDB)
socialWorker := worker.NewSocialWorker(ch, repo, socialQueue)
// 准备 repo
socialRepo := social.NewSocialRepository(sqlDB)
videoRepo := video.NewVideoRepository(sqlDB)
likeRepo := video.NewLikeRepository(sqlDB)
commentRepo := video.NewCommentRepository(sqlDB)
likeWorker := worker.NewLikeWorker(ch, likeRepo, videoRepo, likeQueue)
commentWorker := worker.NewCommentWorker(ch, commentRepo, videoRepo, commentQueue)
var popularityWorker *worker.PopularityWorker
if cache != nil {
popularityWorker = worker.NewPopularityWorker(ch, cache, popularityQueue)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -157,22 +185,26 @@ func main() {
defer pprofServer.Close()
}
errCh := make(chan error, 4)
log.Printf("Worker started, consuming queue=%s", socialQueue)
go func() { errCh <- socialWorker.Run(ctx) }()
log.Printf("Worker started, consuming queue=%s", likeQueue)
go func() { errCh <- likeWorker.Run(ctx) }()
log.Printf("Worker started, consuming queue=%s", commentQueue)
go func() { errCh <- commentWorker.Run(ctx) }()
if popularityWorker != nil {
log.Printf("Worker started, consuming queue=%s", popularityQueue)
go func() { errCh <- popularityWorker.Run(ctx) }()
// 每个 Worker 独立 Channel + 自动重连
go runWorkerWithRetry(ctx, "SocialWorker", conn, func(ch *amqp.Channel) error {
return worker.NewSocialWorker(ch, socialRepo, socialQueue).Run(ctx)
})
go runWorkerWithRetry(ctx, "LikeWorker", conn, func(ch *amqp.Channel) error {
return worker.NewLikeWorker(ch, likeRepo, videoRepo, likeQueue).Run(ctx)
})
go runWorkerWithRetry(ctx, "CommentWorker", conn, func(ch *amqp.Channel) error {
return worker.NewCommentWorker(ch, commentRepo, videoRepo, commentQueue).Run(ctx)
})
if cache != nil {
go runWorkerWithRetry(ctx, "PopularityWorker", conn, func(ch *amqp.Channel) error {
return worker.NewPopularityWorker(ch, cache, popularityQueue).Run(ctx)
})
}
err = <-errCh
if err != nil && err != context.Canceled {
log.Fatalf("Worker stopped: %v", err)
}
// 等待退出信号
<-ctx.Done()
log.Printf("Worker shutting down...")
time.Sleep(2 * time.Second) // 等待正在处理的消息完成
log.Printf("Worker stopped")
}

View File

@@ -127,7 +127,7 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g
socialMQ = nil
}
socialRepository := social.NewSocialRepository(db)
socialService := social.NewSocialService(socialRepository, accountRepository, socialMQ)
socialService := social.NewSocialService(socialRepository, accountRepository, socialMQ, cache)
socialHandler := social.NewSocialHandler(socialService)
socialGroup := r.Group("/social")
protectedSocialGroup := socialGroup.Group("")
@@ -201,18 +201,21 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g
timelineMQ = nil
}
worker.StartOutboxPoller(db, timelineMQ)
worker.StartConsumer(timelineMQ, "video.timeline.update.queue", cache)
worker.StartConsumer(timelineMQ, "video.timeline.update.queue", cache, rmq)
// SSE notification
if rmq != nil && rmq.Ch != nil {
if err := rmq.DeclareTopic("like.events", "notification.like", "like.like"); err != nil {
log.Printf("notification like topic init failed: %v", err)
}
if err := rmq.DeclareTopic("comment.events", "notification.comment", "comment.publish"); err != nil {
log.Printf("notification comment topic init failed: %v", err)
}
if err := rmq.DeclareTopic("social.events", "notification.social", "social.follow"); err != nil {
log.Printf("notification social topic init failed: %v", err)
if rmq != nil {
if notifCh, err := rmq.NewChannel(); err == nil {
if err := rabbitmq.DeclareTopic(notifCh, "like.events", "notification.like", "like.like"); err != nil {
log.Printf("notification like topic init failed: %v", err)
}
if err := rabbitmq.DeclareTopic(notifCh, "comment.events", "notification.comment", "comment.publish"); err != nil {
log.Printf("notification comment topic init failed: %v", err)
}
if err := rabbitmq.DeclareTopic(notifCh, "social.events", "notification.social", "social.follow"); err != nil {
log.Printf("notification social topic init failed: %v", err)
}
notifCh.Close()
}
}
sseHub := worker.NewSSEHub(db)
@@ -221,46 +224,28 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g
sseHub.RegisterRoutes(r, notifGroup)
go func() {
if rmq != nil && rmq.Ch != nil {
if rmq != nil {
hub := sseHub
ctx := context.Background()
// consume from like queue
go func() {
ch, err := rmq.Conn.Channel()
if err != nil {
log.Printf("notification-like channel: %v", err)
return
}
defer ch.Close()
w := worker.NewNotificationWorker(ch, db, "notification.like", hub)
if err := w.Run(ctx); err != nil {
log.Printf("notification-like worker: %v", err)
}
}()
go func() {
ch, err := rmq.Conn.Channel()
if err != nil {
log.Printf("notification-comment channel: %v", err)
return
}
defer ch.Close()
w := worker.NewNotificationWorker(ch, db, "notification.comment", hub)
if err := w.Run(ctx); err != nil {
log.Printf("notification-comment worker: %v", err)
}
}()
go func() {
ch, err := rmq.Conn.Channel()
if err != nil {
log.Printf("notification-social channel: %v", err)
return
}
defer ch.Close()
w := worker.NewNotificationWorker(ch, db, "notification.social", hub)
if err := w.Run(ctx); err != nil {
log.Printf("notification-social worker: %v", err)
}
}()
// 每个 notification worker 独立 Channel + 自动重连
for _, q := range []string{"notification.like", "notification.comment", "notification.social"} {
go func(queue string) {
for {
ch, err := rmq.NewChannel()
if err != nil {
log.Printf("notification-%s: 创建 Channel 失败: %v, 5秒后重试", queue, err)
time.Sleep(5 * time.Second)
continue
}
w := worker.NewNotificationWorker(ch, db, queue, hub)
if err := w.Run(ctx); err != nil {
log.Printf("notification-%s: %v, 5秒后重连...", queue, err)
}
ch.Close()
time.Sleep(5 * time.Second)
}
}(q)
}
} else {
log.Printf("Notification SSE disabled (MQ not available)")
}

View File

@@ -4,10 +4,12 @@ import (
"context"
"errors"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type CommentMQ struct {
*RabbitMQ
ch *amqp.Channel
}
const (
@@ -34,10 +36,15 @@ func NewCommentMQ(base *RabbitMQ) (*CommentMQ, error) {
if base == nil {
return nil, errors.New("rabbitmq base is nil")
}
if err := base.DeclareTopic(commentExchange, commentQueue, commentBindingKey); err != nil {
ch, err := base.NewChannel()
if err != nil {
return nil, err
}
return &CommentMQ{RabbitMQ: base}, nil
if err := DeclareTopic(ch, commentExchange, commentQueue, commentBindingKey); err != nil {
ch.Close()
return nil, err
}
return &CommentMQ{ch: ch}, nil
}
func (c *CommentMQ) Publish(ctx context.Context, username string, videoID, authorID uint, content string) error {
@@ -56,7 +63,7 @@ func (c *CommentMQ) Delete(ctx context.Context, commentID uint) error {
}
func (c *CommentMQ) publish(ctx context.Context, action, routingKey string, evt CommentEvent) error {
if c == nil || c.RabbitMQ == nil {
if c == nil || c.ch == nil {
return errors.New("comment mq is not initialized")
}
id, err := newEventID(16)
@@ -66,5 +73,5 @@ func (c *CommentMQ) publish(ctx context.Context, action, routingKey string, evt
evt.EventID = id
evt.Action = action
evt.OccurredAt = time.Now().UTC()
return c.PublishJSON(ctx, commentExchange, routingKey, evt)
return PublishJSON(ctx, c.ch, commentExchange, routingKey, evt)
}

View File

@@ -4,10 +4,12 @@ import (
"context"
"errors"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type LikeMQ struct {
*RabbitMQ
ch *amqp.Channel
}
const (
@@ -31,10 +33,15 @@ func NewLikeMQ(base *RabbitMQ) (*LikeMQ, error) {
if base == nil {
return nil, errors.New("rabbitmq base is nil")
}
if err := base.DeclareTopic(likeExchange, likeQueue, likeBindingKey); err != nil {
ch, err := base.NewChannel()
if err != nil {
return nil, err
}
return &LikeMQ{RabbitMQ: base}, nil
if err := DeclareTopic(ch, likeExchange, likeQueue, likeBindingKey); err != nil {
ch.Close()
return nil, err
}
return &LikeMQ{ch: ch}, nil
}
func (l *LikeMQ) Like(ctx context.Context, userID, videoID uint) error {
@@ -46,7 +53,7 @@ func (l *LikeMQ) Unlike(ctx context.Context, userID, videoID uint) error {
}
func (l *LikeMQ) publish(ctx context.Context, action, routingKey string, userID, videoID uint) error {
if l == nil || l.RabbitMQ == nil {
if l == nil || l.ch == nil {
return errors.New("like mq is not initialized")
}
if userID == 0 || videoID == 0 {
@@ -63,5 +70,5 @@ func (l *LikeMQ) publish(ctx context.Context, action, routingKey string, userID,
VideoID: videoID,
OccurredAt: time.Now(),
}
return l.PublishJSON(ctx, likeExchange, routingKey, event)
return PublishJSON(ctx, l.ch, likeExchange, routingKey, event)
}

View File

@@ -4,10 +4,12 @@ import (
"context"
"errors"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type PopularityMQ struct {
*RabbitMQ
ch *amqp.Channel
}
const (
@@ -29,14 +31,19 @@ func NewPopularityMQ(base *RabbitMQ) (*PopularityMQ, error) {
if base == nil {
return nil, errors.New("rabbitmq base is nil")
}
if err := base.DeclareTopic(popularityExchange, popularityQueue, popularityBindingKey); err != nil {
ch, err := base.NewChannel()
if err != nil {
return nil, err
}
return &PopularityMQ{RabbitMQ: base}, nil
if err := DeclareTopic(ch, popularityExchange, popularityQueue, popularityBindingKey); err != nil {
ch.Close()
return nil, err
}
return &PopularityMQ{ch: ch}, nil
}
func (p *PopularityMQ) Update(ctx context.Context, videoID uint, change int64) error {
if p == nil || p.RabbitMQ == nil {
if p == nil || p.ch == nil {
return errors.New("popularity mq is not initialized")
}
if videoID == 0 || change == 0 {
@@ -52,5 +59,5 @@ func (p *PopularityMQ) Update(ctx context.Context, videoID uint, change int64) e
Change: change,
OccurredAt: time.Now().UTC(),
}
return p.PublishJSON(ctx, popularityExchange, popularityUpdateRK, event)
return PublishJSON(ctx, p.ch, popularityExchange, popularityUpdateRK, event)
}

View File

@@ -14,9 +14,9 @@ import (
amqp "github.com/rabbitmq/amqp091-go"
)
// RabbitMQ 只管理 ConnectionChannel 由各组件按需创建
type RabbitMQ struct {
Conn *amqp.Connection
Ch *amqp.Channel
}
func NewRabbitMQ(cfg *config.RabbitMQConfig) (*RabbitMQ, error) {
@@ -28,41 +28,35 @@ func NewRabbitMQ(cfg *config.RabbitMQConfig) (*RabbitMQ, error) {
if err != nil {
return nil, err
}
ch, err := conn.Channel()
if err != nil {
_ = conn.Close()
return nil, err
}
return &RabbitMQ{Conn: conn, Ch: ch}, nil
return &RabbitMQ{Conn: conn}, nil
}
func (r *RabbitMQ) Close() error {
if r == nil {
return nil
}
var closeErr error
if r.Ch != nil {
if err := r.Ch.Close(); err != nil {
closeErr = err
}
}
if r.Conn != nil {
if err := r.Conn.Close(); closeErr == nil && err != nil {
closeErr = err
}
return r.Conn.Close()
}
return closeErr
return nil
}
func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string) error {
if r == nil || r.Ch == nil {
return errors.New("rabbitmq is not initialized")
func (r *RabbitMQ) NewChannel() (*amqp.Channel, error) {
if r == nil || r.Conn == nil {
return nil, errors.New("rabbitmq connection is not initialized")
}
return r.Conn.Channel()
}
func DeclareTopic(ch *amqp.Channel, exchange string, queue string, bindingKey string) error {
if ch == nil {
return errors.New("channel is not initialized")
}
if exchange == "" || queue == "" || bindingKey == "" {
return errors.New("exchange/queue/bindingKey is required")
}
if err := r.Ch.ExchangeDeclare(
if err := ch.ExchangeDeclare(
exchange,
"topic",
true,
@@ -74,7 +68,7 @@ func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string
return err
}
q, err := r.Ch.QueueDeclare(
q, err := ch.QueueDeclare(
queue,
true,
false,
@@ -86,7 +80,7 @@ func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string
return err
}
if err := r.Ch.QueueBind(
if err := ch.QueueBind(
q.Name,
bindingKey,
exchange,
@@ -95,15 +89,15 @@ func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string
); err != nil {
return err
}
if err := DeclareDLX(r.Ch, queue); err != nil {
if err := DeclareDLX(ch, queue); err != nil {
log.Printf("DLX declare failed for %s: %v", queue, err)
}
return nil
}
func (r *RabbitMQ) PublishJSON(ctx context.Context, exchange string, routingKey string, payload any) error {
if r == nil || r.Ch == nil {
return errors.New("rabbitmq is not initialized")
func PublishJSON(ctx context.Context, ch *amqp.Channel, exchange string, routingKey string, payload any) error {
if ch == nil {
return errors.New("channel is not initialized")
}
if exchange == "" || routingKey == "" {
return errors.New("exchange and routingKey are required")
@@ -112,7 +106,7 @@ func (r *RabbitMQ) PublishJSON(ctx context.Context, exchange string, routingKey
if err != nil {
return err
}
return r.Ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{
return ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),

View File

@@ -4,10 +4,12 @@ import (
"context"
"errors"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type SocialMQ struct {
*RabbitMQ
ch *amqp.Channel
}
const (
@@ -31,10 +33,15 @@ func NewSocialMQ(base *RabbitMQ) (*SocialMQ, error) {
if base == nil {
return nil, errors.New("rabbitmq base is nil")
}
if err := base.DeclareTopic(socialExchange, socialQueue, socialBindingKey); err != nil {
ch, err := base.NewChannel()
if err != nil {
return nil, err
}
return &SocialMQ{RabbitMQ: base}, nil
if err := DeclareTopic(ch, socialExchange, socialQueue, socialBindingKey); err != nil {
ch.Close()
return nil, err
}
return &SocialMQ{ch: ch}, nil
}
func (s *SocialMQ) Follow(ctx context.Context, followerID, vloggerID uint) error {
@@ -46,7 +53,7 @@ func (s *SocialMQ) UnFollow(ctx context.Context, followerID, vloggerID uint) err
}
func (s *SocialMQ) publish(ctx context.Context, action, routingKey string, followerID, vloggerID uint) error {
if s == nil || s.RabbitMQ == nil {
if s == nil || s.ch == nil {
return errors.New("social mq is not initialized")
}
if followerID == 0 || vloggerID == 0 {
@@ -63,5 +70,5 @@ func (s *SocialMQ) publish(ctx context.Context, action, routingKey string, follo
VloggerID: vloggerID,
OccurredAt: time.Now().UTC(),
}
return s.PublishJSON(ctx, socialExchange, routingKey, evt)
return PublishJSON(ctx, s.ch, socialExchange, routingKey, evt)
}

View File

@@ -4,10 +4,12 @@ import (
"context"
"errors"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type TimelineMQ struct {
*RabbitMQ
ch *amqp.Channel
}
const (
@@ -28,14 +30,19 @@ func NewTimelineMQ(base *RabbitMQ) (*TimelineMQ, error) {
if base == nil {
return nil, errors.New("rabbitmq base is nil")
}
if err := base.DeclareTopic(timelineExchange, timelineQueue, timelineBindingKey); err != nil {
ch, err := base.NewChannel()
if err != nil {
return nil, err
}
return &TimelineMQ{RabbitMQ: base}, nil
if err := DeclareTopic(ch, timelineExchange, timelineQueue, timelineBindingKey); err != nil {
ch.Close()
return nil, err
}
return &TimelineMQ{ch: ch}, nil
}
func (t *TimelineMQ) PublishVideo(ctx context.Context, videoID uint, createTime time.Time) error {
if t == nil || t.RabbitMQ == nil {
if t == nil || t.ch == nil {
return errors.New("timeline mq is not initialized")
}
if videoID == 0 {
@@ -51,5 +58,5 @@ func (t *TimelineMQ) PublishVideo(ctx context.Context, videoID uint, createTime
CreateTime: createTime.UnixMilli(),
OccurredAt: time.Now(),
}
return t.PublishJSON(ctx, timelineExchange, timelinePublishRK, timeline)
return PublishJSON(ctx, t.ch, timelineExchange, timelinePublishRK, timeline)
}

View File

@@ -27,6 +27,17 @@ func (c *Client) Del(ctx context.Context, key string) error {
return c.rdb.Del(ctx, key).Err()
}
func (c *Client) DelByPattern(ctx context.Context, pattern string) error {
if c == nil || c.rdb == nil {
return nil
}
iter := c.rdb.Scan(ctx, 0, pattern, 0).Iterator()
for iter.Next(ctx) {
_ = c.rdb.Del(ctx, iter.Val())
}
return iter.Err()
}
func (c *Client) MGet(cacheCtx context.Context, cacheKeys ...string) ([]interface{}, error) {
if c == nil || c.rdb == nil {
return nil, errors.New("redis client not initialized")

View File

@@ -5,16 +5,19 @@ import (
"errors"
"feedsystem_video_go/internal/account"
"feedsystem_video_go/internal/middleware/rabbitmq"
rediscache "feedsystem_video_go/internal/middleware/redis"
"log"
)
type SocialService struct {
repo *SocialRepository
accountrepo *account.AccountRepository
socialMQ *rabbitmq.SocialMQ
cache *rediscache.Client
}
func NewSocialService(repo *SocialRepository, accountrepo *account.AccountRepository, socialMQ *rabbitmq.SocialMQ) *SocialService {
return &SocialService{repo: repo, accountrepo: accountrepo, socialMQ: socialMQ}
func NewSocialService(repo *SocialRepository, accountrepo *account.AccountRepository, socialMQ *rabbitmq.SocialMQ, cache *rediscache.Client) *SocialService {
return &SocialService{repo: repo, accountrepo: accountrepo, socialMQ: socialMQ, cache: cache}
}
func (s *SocialService) Follow(ctx context.Context, social *Social) error {
@@ -36,10 +39,22 @@ func (s *SocialService) Follow(ctx context.Context, social *Social) error {
if isFollowed {
return errors.New("already followed")
}
if s.socialMQ != nil {
s.socialMQ.Follow(ctx, social.FollowerID, social.VloggerID)
// 先写 DB确保数据持久化
if err := s.repo.Follow(ctx, social); err != nil {
return err
}
return s.repo.Follow(ctx, social)
// DB 成功后,失效该用户的关注列表缓存
s.invalidateFollowingFeedCache(context.Background(), social.FollowerID)
// 最后发 MQ用于通知失败只记日志不影响业务
if s.socialMQ != nil {
if err := s.socialMQ.Follow(ctx, social.FollowerID, social.VloggerID); err != nil {
log.Printf("social MQ Follow 发布失败: %v", err)
}
}
return nil
}
func (s *SocialService) Unfollow(ctx context.Context, social *Social) error {
@@ -58,10 +73,32 @@ func (s *SocialService) Unfollow(ctx context.Context, social *Social) error {
if !isFollowed {
return errors.New("not followed")
}
if s.socialMQ != nil {
s.socialMQ.UnFollow(ctx, social.FollowerID, social.VloggerID)
// 先写 DB
if err := s.repo.Unfollow(ctx, social); err != nil {
return err
}
// 失效缓存
s.invalidateFollowingFeedCache(context.Background(), social.FollowerID)
// 最后发 MQ
if s.socialMQ != nil {
if err := s.socialMQ.UnFollow(ctx, social.FollowerID, social.VloggerID); err != nil {
log.Printf("social MQ UnFollow 发布失败: %v", err)
}
}
return nil
}
func (s *SocialService) invalidateFollowingFeedCache(ctx context.Context, accountID uint) {
if s.cache == nil {
return
}
pattern := s.cache.Key("feed:listByFollowing:*:accountID=%d:*", accountID)
if err := s.cache.DelByPattern(ctx, pattern); err != nil {
log.Printf("失效 Following 缓存失败: accountID=%d, err=%v", accountID, err)
}
return s.repo.Unfollow(ctx, social)
}
func (s *SocialService) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) {

View File

@@ -15,6 +15,7 @@ func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uin
}
_ = cache.Del(context.Background(), cache.Key("video:detail:id=%d", id))
_ = cache.Del(context.Background(), cache.Key("video:entity:%d", id))
now := time.Now().UTC().Truncate(time.Minute)
windowKey := cache.Key("hot:video:1m:%s", now.Format("200601021504"))

View File

@@ -8,6 +8,7 @@ import (
"feedsystem_video_go/internal/video"
"log"
"strings"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
@@ -58,18 +59,28 @@ func (w *CommentWorker) Run(ctx context.Context) error {
}
func (w *CommentWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("comment worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
const maxRetries = 3
for i := 0; i <= maxRetries; i++ {
select {
case <-ctx.Done():
_ = d.Nack(false, true)
return
default:
}
log.Printf("comment worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
if err := w.process(ctx, d.Body); err != nil {
if i >= maxRetries {
log.Printf("comment worker: 重试 %d 次后仍失败, 丢弃: %v", maxRetries, err)
_ = d.Ack(false)
return
}
wait := time.Duration(1<<uint(i)) * time.Second
log.Printf("comment worker: 处理失败, %v 后重试 (%d/%d): %v", wait, i+1, maxRetries, err)
time.Sleep(wait)
continue
}
_ = d.Ack(false)
return
}
_ = d.Ack(false)
}
func (w *CommentWorker) process(ctx context.Context, body []byte) error {

View File

@@ -58,18 +58,28 @@ func (w *LikeWorker) Run(ctx context.Context) error {
}
func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("like worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
const maxRetries = 3
for i := 0; i <= maxRetries; i++ {
select {
case <-ctx.Done():
_ = d.Nack(false, true)
return
default:
}
log.Printf("like worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
if err := w.process(ctx, d.Body); err != nil {
if i >= maxRetries {
log.Printf("like worker: 重试 %d 次后仍失败, 丢弃: %v", maxRetries, err)
_ = d.Ack(false)
return
}
wait := time.Duration(1<<uint(i)) * time.Second
log.Printf("like worker: 处理失败, %v 后重试 (%d/%d): %v", wait, i+1, maxRetries, err)
time.Sleep(wait)
continue
}
_ = d.Ack(false)
return
}
_ = d.Ack(false)
}
func (w *LikeWorker) process(ctx context.Context, body []byte) error {

View File

@@ -66,18 +66,28 @@ func (w *NotificationWorker) Run(ctx context.Context) error {
}
func (w *NotificationWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
retryCount := rabbitmq.GetRetryCount(d)
if err := w.process(ctx, d); err != nil {
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("notification worker: max retries, dropping: %v", err)
_ = d.Ack(false)
const maxRetries = 3
for i := 0; i <= maxRetries; i++ {
select {
case <-ctx.Done():
_ = d.Nack(false, true)
return
default:
}
log.Printf("notification worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
if err := w.process(ctx, d); err != nil {
if i >= maxRetries {
log.Printf("notification worker: 重试 %d 次后仍失败, 丢弃: %v", maxRetries, err)
_ = d.Ack(false)
return
}
wait := time.Duration(1<<uint(i)) * time.Second
log.Printf("notification worker: 处理失败, %v 后重试 (%d/%d): %v", wait, i+1, maxRetries, err)
time.Sleep(wait)
continue
}
_ = d.Ack(false)
return
}
_ = d.Ack(false)
}
func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error {

View File

@@ -4,7 +4,7 @@ import (
"context"
"encoding/json"
"feedsystem_video_go/internal/middleware/rabbitmq"
"feedsystem_video_go/internal/middleware/redis"
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/video"
"fmt"
"log"
@@ -15,7 +15,7 @@ import (
)
func StartOutboxPoller(db *gorm.DB, tmq *rabbitmq.TimelineMQ) {
if db == nil || tmq == nil || tmq.RabbitMQ == nil || tmq.Ch == nil {
if db == nil || tmq == nil {
log.Printf("Outbox poller disabled: timeline mq is not initialized")
return
}
@@ -46,9 +46,9 @@ func StartOutboxPoller(db *gorm.DB, tmq *rabbitmq.TimelineMQ) {
}()
}
func StartConsumer(tmq *rabbitmq.TimelineMQ, queueName string, redisClient *redis.Client) {
if tmq == nil || tmq.RabbitMQ == nil || tmq.Ch == nil {
log.Printf("Timeline consumer disabled: timeline mq is not initialized")
func StartConsumer(tmq *rabbitmq.TimelineMQ, queueName string, redisClient *rediscache.Client, rmq *rabbitmq.RabbitMQ) {
if tmq == nil || rmq == nil || rmq.Conn == nil {
log.Printf("Timeline consumer disabled: rabbitmq is not initialized")
return
}
if redisClient == nil {
@@ -56,54 +56,64 @@ func StartConsumer(tmq *rabbitmq.TimelineMQ, queueName string, redisClient *redi
return
}
msgs, err := tmq.Ch.Consume(
queueName,
"",
false,
false,
false,
false,
nil,
)
if err != nil {
log.Printf("注册消费失败")
return
}
go func() {
for msg := range msgs {
var event rabbitmq.TimelineEvent
err := json.Unmarshal(msg.Body, &event)
for {
// 每次重连创建独立的 Channel不与发布者共用
ch, err := rmq.NewChannel()
if err != nil {
log.Printf("反序列化失败")
log.Printf("Timeline consumer: 创建 Channel 失败: %v, 5秒后重试", err)
time.Sleep(5 * time.Second)
continue
}
if err := ch.Qos(10, 0, false); err != nil {
log.Printf("Timeline consumer: QoS 设置失败: %v", err)
}
msgs, err := ch.Consume(queueName, "", false, false, false, false, nil)
if err != nil {
log.Printf("Timeline consumer: 注册消费失败: %v, 5秒后重试", err)
ch.Close()
time.Sleep(5 * time.Second)
continue
}
log.Printf("Timeline consumer 已启动, queue=%s", queueName)
for msg := range msgs {
var event rabbitmq.TimelineEvent
if err := json.Unmarshal(msg.Body, &event); err != nil {
log.Printf("Timeline consumer: 反序列化失败: %v", err)
msg.Ack(false)
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
timelineKey := redisClient.Key("feed:global_timeline")
err = redisClient.ZAdd(ctx, timelineKey, oredis.Z{
Score: float64(event.CreateTime),
Member: fmt.Sprintf("%d", event.VideoID),
})
if err != nil {
log.Printf("Timeline consumer: 写入Zset失败: %v", err)
msg.Nack(false, true)
cancel()
continue
}
if err := redisClient.ZRemRangeByRank(ctx, timelineKey, 0, -1001); err != nil {
log.Printf("Timeline consumer: ZRem失败: %v", err)
}
msg.Ack(false)
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
timelineKey := redisClient.Key("feed:global_timeline")
err = redisClient.ZAdd(ctx, timelineKey, oredis.Z{
Score: float64(event.CreateTime),
Member: fmt.Sprintf("%d", event.VideoID),
})
if err != nil {
log.Printf("写入Zset失败")
msg.Nack(false, true)
cancel()
continue
}
err = redisClient.ZRemRangeByRank(ctx, timelineKey, 0, -1001)
if err != nil {
log.Printf("ZRem失败")
}
msg.Ack(false)
cancel()
// msgs channel 关闭说明 AMQP Channel 断开,关闭并重连
ch.Close()
log.Printf("Timeline consumer: Channel 断开, 5秒后重连...")
time.Sleep(5 * time.Second)
}
}()
}

View File

@@ -8,6 +8,7 @@ import (
rediscache "feedsystem_video_go/internal/middleware/redis"
"feedsystem_video_go/internal/video"
"log"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
@@ -57,18 +58,28 @@ func (w *PopularityWorker) Run(ctx context.Context) error {
}
func (w *PopularityWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("popularity worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
const maxRetries = 3
for i := 0; i <= maxRetries; i++ {
select {
case <-ctx.Done():
_ = d.Nack(false, true)
return
default:
}
log.Printf("popularity worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
if err := w.process(ctx, d.Body); err != nil {
if i >= maxRetries {
log.Printf("popularity worker: 重试 %d 次后仍失败, 丢弃: %v", maxRetries, err)
_ = d.Ack(false)
return
}
wait := time.Duration(1<<uint(i)) * time.Second
log.Printf("popularity worker: 处理失败, %v 后重试 (%d/%d): %v", wait, i+1, maxRetries, err)
time.Sleep(wait)
continue
}
_ = d.Ack(false)
return
}
_ = d.Ack(false)
}
func (w *PopularityWorker) process(ctx context.Context, body []byte) error {

View File

@@ -7,6 +7,7 @@ import (
"feedsystem_video_go/internal/middleware/rabbitmq"
"feedsystem_video_go/internal/social"
"log"
"time"
"github.com/go-sql-driver/mysql"
amqp "github.com/rabbitmq/amqp091-go"
@@ -57,18 +58,28 @@ func (w *SocialWorker) Run(ctx context.Context) error {
}
func (w *SocialWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
if err := w.process(ctx, d.Body); err != nil {
retryCount := rabbitmq.GetRetryCount(d)
if retryCount >= rabbitmq.MaxRetryCount {
log.Printf("social worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
_ = d.Ack(false)
const maxRetries = 3
for i := 0; i <= maxRetries; i++ {
select {
case <-ctx.Done():
_ = d.Nack(false, true)
return
default:
}
log.Printf("social worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
_ = d.Nack(false, true)
if err := w.process(ctx, d.Body); err != nil {
if i >= maxRetries {
log.Printf("social worker: 重试 %d 次后仍失败, 丢弃: %v", maxRetries, err)
_ = d.Ack(false)
return
}
wait := time.Duration(1<<uint(i)) * time.Second
log.Printf("social worker: 处理失败, %v 后重试 (%d/%d): %v", wait, i+1, maxRetries, err)
time.Sleep(wait)
continue
}
_ = d.Ack(false)
return
}
_ = d.Ack(false)
}
func (w *SocialWorker) process(ctx context.Context, body []byte) error {

View File

@@ -124,7 +124,7 @@ services:
depends_on:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:80/ || exit 1"]
interval: 10s
timeout: 5s
retries: 3
@@ -134,4 +134,3 @@ volumes:
redis_data:
rabbitmq_data:
backend_uploads:

View File

@@ -1,6 +1,6 @@
# feedsystem_video_go
> 项目介绍本项目是一款由Go开发的高性能短视频Feed流系统提供账号、视频、点赞、评论、关注SocialFeed接口,并RedisRabbitMQ优化同时编写了docker文件使其方便部署
> 项目介绍:本项目是一款由 Go + Vue 3 开发的短视频 Feed 流系统提供账号、视频、点赞、评论、关注SocialFeed、私信与通知接口,并通过 RedisRabbitMQ、分片上传、SSE 与 Docker Compose 提升性能、体验和部署效率
# 技术栈
@@ -9,14 +9,15 @@
| 开发语言 | Go (Golang) | 后端核心业务逻辑实现API + Worker。 |
| Web 框架 | Gin | HTTP 路由注册、参数绑定、统一返回与中间件链JWTAuth / SoftJWTAuth。 |
| ORM 框架 | GORM | 模型定义、CRUD、启动时 AutoMigrate 自动迁移表结构。 |
| 持久化 | MySQL | 存储 `Account / Video / Like / Comment / Social` 五张核心表及相关统计字段(如 likes_count、popularity。 |
| 缓存/排行榜 | Redis可选 | Token 校验缓存(`account:<id>`、Feed 匿名流缓存、视频详情缓存、热榜 ZSET(滑动窗口聚合/快照分页)。 |
| 消息队列 | RabbitMQ可选 | Topic 事件总线:`like.events``comment.events``social.events``video.popularity.events`;由 Worker 异步消费写 MySQL/Redis支持异常降级直写。 |
| 异步执行 | WorkerGo | `cmd/worker`LikeWorker/CommentWorker/SocialWorker/PopularityWorker,异步落库、更新计数/热度、更新热榜与缓存失效。 |
| 持久化 | MySQL | 存储 `Account / Video / Like / Comment / Social / Tag / VideoTag / Message / Notification / OutboxMsg` 等表及统计字段(如 likes_count、popularity。 |
| 缓存/排行榜 | Redis可选 | Token/Refresh Token 缓存、Feed 时间线、视频实体缓存、视频详情缓存、热榜 ZSET、分片上传会话、接口限流计数。 |
| 消息队列 | RabbitMQ可选 | Topic 事件总线:`like.events``comment.events``social.events``video.popularity.events``video.timeline.events`;支持 DLX、异常降级直写。 |
| 异步执行 | WorkerGo | `cmd/worker`LikeWorker/CommentWorker/SocialWorker/PopularityWorkerAPI 进程内启动 OutboxPoller、TimelineConsumer 与 NotificationWorker。 |
| 文件存储 | Local Disk可替换对象存储 | 视频与封面文件存放本地目录(生产可替换为 OSS/S3/MinIO。 |
| 容器化/依赖编排 | Docker / Docker Compose | 一键拉起 RabbitMQ 等依赖(可配合 `./start.sh`),便于本地联调与部署环境一致性。 |
| 接口调试 | Postman Collection | `test/postman.json`:预置变量、批量跑接口与部分断言脚本。 |
| 前端 | Vue + Vite | 前端工程 `frontend/`,通过 Vite 代理 `/api` 对接后端 |
| 前端 | Vue 3 + Vite + Pinia | 前端工程 `frontend/`,通过 Vite 代理 `/api` 对接后端,提供沉浸式播放、发布、主页、私信等页面。 |
| 可观测性 | pprof / healthz | `GET /healthz` 健康检查;本地 pprof 默认 API `localhost:6060`、Worker `localhost:6061`。 |
## 模块设计
@@ -31,13 +32,17 @@
| 层级 | 方法/路由 | 输入 -> 输出 | 存储(MySQL/Redis) | 核心说明 |
| ----------------- | ------------------------------------------------------------ | ---------------------------------------------- | ----------------- | ------------------------------------------------------------ |
| Handler | POST `/account/register` | `{username,password}` -> `{account}` | MySQL ✅ | 注册账号;密码 bcrypt 哈希入库。 |
| Handler | POST `/account/login` | `{username,password}` -> `{token}` | MySQL ✅ / Redis ✅ | 登录成功写 `account.token`Redis 写 `account:<id>` TTL 24h可选。 |
| Handler | POST `/account/changePassword` | `{username,old_password,new_password}` -> `{}` | MySQL ✅ / Redis ✅ | 修改密码成功后清空 token强制下线删除 Redis token 缓存。 |
| Handler | POST `/account/login` | `{username,password}` -> `{token,refresh_token,account_id,username}` | MySQL ✅ / Redis ✅ | 登录成功写 access token 与 refresh tokenRedis 写 `account:<id>``account:<id>:refresh``refresh:<token>`。 |
| Handler | POST `/account/refresh` | `{refresh_token}` -> `{token,account_id,username}` | MySQL ✅ / Redis ✅ | Refresh Token 换取新的 access token优先查 Redis失败回源 MySQL。 |
| Handler | POST `/account/changePassword` | `{username,old_password,new_password}` -> `{}` | MySQL ✅ / Redis ✅ | 修改密码成功后清空 token 与 refresh token触发强制下线。 |
| Handler | POST `/account/findByID` | `{id}` -> `{account}` | MySQL ✅ | 按 ID 查用户。 |
| Handler | POST `/account/findByUsername` | `{username}` -> `{account}` | MySQL ✅ | 按用户名查用户(前端常用保存 accountId/vloggerId。 |
| Handler | POST `/account/getProfile` | `{account_id}` -> `{account,video_count,total_likes,follower_count,vlogger_count}` | MySQL ✅ | 用户主页聚合统计。 |
| Handler | POST `/account/rename` | `{new_username}` -> `{token}` | MySQL ✅ / Redis ✅ | 改名并**生成新 JWT**;旧 token 立即失效;更新 DB/Redis。 |
| Handler | POST `/account/logout` | `{}` -> `{}` | MySQL ✅ / Redis ✅ | 清空 DB token 并删除 Redis token旧 token 立即失效。 |
| Service(建议命名) | `Register/Login/ChangePassword/FindByID/FindByUsername/Rename/Logout` | - | - | 对应 Handler 的业务实现。 |
| Handler | POST `/account/uploadAvatar` | multipart `file` -> `{avatar_url}` | Local Disk ✅ / MySQL ✅ | 支持 `.jpg/.jpeg/.png/.webp`,最大 10MB写入 `.run/uploads/avatars/<accountID>/` |
| Handler | POST `/account/updateProfile` | `{avatar_url,bio}` -> `{}` | MySQL ✅ | 更新头像 URL 与个人简介。 |
| Service(建议命名) | `Register/Login/Refresh/ChangePassword/FindByID/FindByUsername/GetProfile/Rename/Logout/UpdateProfile` | - | - | Access Token 15 分钟过期Refresh Token 7 天有效DB 存储当前有效 token支持撤销。 |
### 视频系统
@@ -49,10 +54,16 @@
| 层级 | 方法/路由 | 输入 -> 输出 | 存储(MySQL/Redis) | 核心说明 |
| ----------------- | ---------------------------------- | ----------------------------------------------------- | ----------------- | -------------------------------------------------- |
| Handler | POST `/video/publish` | `{title,description,play_url,cover_url}` -> `{video}` | MySQL ✅ | JWT 保护;写视频记录;热度字段初始化。 |
| Handler | POST `/video/uploadVideo` | multipart `file` -> `{url,play_url}` | Local Disk ✅ | 普通视频上传,支持 `.mp4`,最大 200MB写入 `.run/uploads/videos/<accountID>/<date>/` |
| Handler | POST `/video/uploadCover` | multipart `file` -> `{url,cover_url}` | Local Disk ✅ | 封面上传,支持 `.jpg/.jpeg/.png/.webp`,最大 10MB。 |
| Handler | POST `/video/chunk/init` | `{filename,file_size,chunk_size,total_chunks,file_hash}` -> `{upload_id,uploaded_chunks}` | Redis ✅ / Local Disk ✅ | 初始化分片上传;同一用户同一文件 hash 返回已有会话,用于断点续传。 |
| Handler | POST `/video/chunk/upload` | multipart `{upload_id,chunk_index,chunk_hash,file}` -> `{chunk_index}` | Redis ✅ / Local Disk ✅ | 单分片 MD5 校验后落盘到 `.run/uploads/tmp/<uploadID>/`。 |
| Handler | POST `/video/chunk/status` | `{upload_id}` -> `{upload_id,uploaded_chunks,total_chunks}` | Redis ✅ | 查询已上传分片,前端恢复进度。 |
| Handler | POST `/video/chunk/complete` | `{upload_id}` -> `{url,play_url}` | Redis ✅ / Local Disk ✅ | 检查分片完整后按顺序合并为 `.mp4`,清理临时文件与 Redis 会话。 |
| Handler | POST `/video/publish` | `{title,description,play_url,cover_url}` -> `{video}` | MySQL ✅ / Redis ✅ / MQ ✅ | JWT 保护;写视频记录与 Outbox 消息;从标题/描述提取 `#话题` 写入 `tags/video_tags`。 |
| Handler | POST `/video/listByAuthorID` | `{author_id}` -> `{videos[]}` | MySQL ✅ | 作者主页视频列表。 |
| Handler | POST `/video/getDetail` | `{id}` -> `{video_detail}` | MySQL ✅ / Redis ✅ | 视频详情可走缓存Redis 可选);变更时失效。 |
| Service(建议命名) | `Publish/ListByAuthorID/GetDetail` | - | - | `GetDetail`优先 Redis未命中回源 MySQL 后回填。 |
| Handler | POST `/video/getDetail` | `{id}` -> `{video_detail}` | MySQL ✅ / Redis ✅ | 视频详情缓存,使用互斥锁防击穿,变更时主动失效。 |
| Service(建议命名) | `UploadVideo/UploadCover/ChunkInit/ChunkUpload/ChunkStatus/ChunkComplete/Publish/ListByAuthorID/GetDetail` | - | - | `Publish` 通过事务同时写视频、Outbox 与标签关系;`GetDetail` 优先 Redis未命中回源 MySQL 后回填。 |
### 点赞系统
@@ -67,7 +78,8 @@
| Handler | POST `/like/isLiked` | `{video_id}` -> `{is_liked}` | MySQL ✅ | JWT 保护;判断当前用户是否点赞该视频。 |
| Handler | POST `/like/like` | `{video_id}` -> `{}` | MQ ✅(可选) / MySQL ✅ / Redis ✅ | 优先发布 `like.events``like.like`)与热度增量事件;发布失败降级直写。 |
| Handler | POST `/like/unlike` | `{video_id}` -> `{}` | MQ ✅(可选) / MySQL ✅ / Redis ✅ | 同上(`like.unlike`);更新 likes_count 与 popularity。 |
| Service(建议命名) | `IsLiked/Like/Unlike` | - | - | 与 MQ 降级策略绑定:任一发布失败则对失败目标直写。 |
| Handler | POST `/like/listMyLikedVideos` | `{}` -> `{videos[]}` | MySQL ✅ | 当前用户点赞过的视频列表。 |
| Service(建议命名) | `IsLiked/Like/Unlike/ListLikedVideos` | - | - | 与 MQ 降级策略绑定:任一发布失败则对失败目标直写;点赞事件同步触发作者通知。 |
### 评论系统
@@ -79,10 +91,10 @@
| 层级 | 方法/路由 | 输入 -> 输出 | 存储(MySQL/Redis/MQ) | 核心说明 |
| ----------------- | ------------------------ | ----------------------------------- | ------------------------------ | ------------------------------------------------------------ |
| Handler | POST `/comment/listAll` | `{video_id}` -> `{comments[]}` | MySQL ✅ | 列出某视频全部评论。 |
| Handler | POST `/comment/publish` | `{video_id,content}` -> `{comment}` | MQ ✅(可选) / MySQL ✅ / Redis ✅ | 发布 `comment.events``comment.publish`)并触发 `popularity + 1`发布失败降级直写。 |
| Handler | POST `/comment/listAll` | `{video_id}` -> `{comments[]}` | MySQL ✅ | 列出某视频最多 200 条评论,按 `created_at ASC` 排序。 |
| Handler | POST `/comment/publish` | `{video_id,content}` -> `{}` | MQ ✅(可选) / MySQL ✅ / Redis ✅ | 发布 `comment.events``comment.publish`)并触发 `popularity + 1`内容中的 `@username` 会写提及通知。 |
| Handler | POST `/comment/delete` | `{comment_id}` -> `{}` | MQ ✅(可选) / MySQL ✅ / Redis ✅ | 仅作者可删;发布 `comment.delete`;必要时失效缓存/更新热度。 |
| Service(建议命名) | `ListAll/Publish/Delete` | - | - | 评论写入与热度增量解耦到 MQ/Worker |
| Service(建议命名) | `ListAll/Publish/Delete/NotifyMentions` | - | - | 评论写入与热度增量解耦到 MQ/Worker;提及通知直接写 `notifications` 表。 |
### 关注系统
@@ -98,7 +110,8 @@
| Handler | POST `/social/unfollow` | `{vlogger_id}` -> `{}` | MQ ✅(可选) / MySQL ✅ | 取关事件可异步写入。 |
| Handler | POST `/social/getAllFollowers` | `{vlogger_id?}` -> `{followers[]}` | MySQL ✅ | vlogger_id 可空:默认当前登录账号。 |
| Handler | POST `/social/getAllVloggers` | `{follower_id?}` -> `{vloggers[]}` | MySQL ✅ | follower_id 可空:默认当前登录账号。 |
| Service(建议命名) | `Follow/Unfollow/GetAllFollowers/GetAllVloggers` | - | - | follow/unfollow 可走 MQ 异步,异常降级直写。 |
| Handler | POST `/social/getCounts` | `{}` -> `{follower_count,vlogger_count}` | MySQL ✅ | 当前登录用户的粉丝数与关注数。 |
| Service(建议命名) | `Follow/Unfollow/GetAllFollowers/GetAllVloggers/GetCounts` | - | - | follow/unfollow 可走 MQ 异步,异常降级直写;关注事件同步触发通知。 |
### Feed系统
@@ -112,9 +125,33 @@
| ----------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- | ------------------------------------------------------------ |
| Handler | POST `/feed/listLatest` | `{limit,latest_time}` -> `{videos[], next_time}` | MySQL ✅ / Redis ✅ | 匿名流可缓存(短 TTL`latest_time` 游标分页。 |
| Handler | POST `/feed/listLikesCount` | `{limit,likes_count_before,id_before}` -> `{videos[], next_likes_count_before,next_id_before}` | MySQL ✅ | 复合游标分页:`likes_count + id` 保证稳定不重不漏。 |
| Handler | POST `/feed/listByPopularity` | `{limit,as_of,offset}` -> `{videos[], as_of,next_offset}` | Redis ✅ / MySQL ✅ | 热榜优先 Redis ZSET快照+offsetRedis 不可用回退 MySQL/简化逻辑。 |
| Handler | POST `/feed/listByFollowing` | `{limit}` -> `{videos[]}` | MySQL ✅ | 需要登录(关注流);按关注关系聚合视频。 |
| Service(建议命名) | `ListLatest/ListLikesCount/ListByPopularity/ListByFollowing` | - | - | `ListByPopularity`:滑动窗口聚合 + 快照分页;`ListLatest`:匿名缓存。 |
| Handler | POST `/feed/listByPopularity` | `{limit,as_of,offset,latest_popularity?,latest_before?,latest_id_before?}` -> `{videos[],as_of,next_offset,next_latest_*}` | Redis ✅ / MySQL ✅ | 热榜优先 Redis ZSET快照+offsetRedis 不可用回退 MySQL 复合游标。 |
| Handler | POST `/feed/listByFollowing` | `{limit,latest_time}` -> `{videos[],next_time,has_more}` | MySQL ✅ / Redis ✅ | 需要登录;按关注关系聚合视频,支持短缓存。 |
| Handler | POST `/feed/listByTag` | `{tag_name,limit}` -> `{video_list}` | MySQL ✅ | 按发布时提取的 `#话题` 查询视频。 |
| Service(建议命名) | `ListLatest/ListLikesCount/ListByPopularity/ListByFollowing/ListByTag/GetVideoByIDs` | - | - | `ListLatest` 使用 Redis `feed:global_timeline` 热时间线 + MySQL 冷数据拼接;视频实体使用 L1 本地缓存、L2 Redis、L3 MySQL。 |
### 私信系统
#### 相关方法
| 层级 | 方法/路由 | 输入 -> 输出 | 存储(MySQL) | 核心说明 |
| ----------------- | --------------------- | ------------------------------------ | ----------- | --------------------------------------------- |
| Handler | POST `/message/send` | `{to_id,content}` -> `{message}` | MySQL ✅ | JWT 保护;发送私信,内容去除首尾空白。 |
| Handler | POST `/message/list` | `{peer_id}` -> `{messages[]}` | MySQL ✅ | JWT 保护;按当前用户与对端用户查询最近 50 条。 |
| Service(建议命名) | `Send/List` | - | - | 当前为同步写入 MySQL前端按时间正序渲染。 |
### 通知系统
#### 相关方法
| 层级 | 方法/路由 | 输入 -> 输出 | 存储(MySQL/MQ/SSE) | 核心说明 |
| ----------------- | ---------------------------------------------- | ------------------------------------ | ------------------ | ---------------------------------------------------------- |
| Handler | GET `/notification/stream?token=<accessToken>` | SSE `data: Notification` | SSE ✅ | 支持 query token 与 `Authorization: Bearer`30 秒 keepalive。 |
| Handler | POST `/notification/list` | `{}` -> `{notifications[]}` | MySQL ✅ | 查询当前用户最近 50 条通知。 |
| Handler | POST `/notification/markRead` | `{id?}` -> `{message}` | MySQL ✅ | 传 `id` 标记单条,省略 `id` 标记当前用户全部通知。 |
| Handler | POST `/notification/unreadCount` | `{}` -> `{count}` | MySQL ✅ | 当前用户未读通知计数。 |
| Worker | `NotificationWorker` | MQ 事件 -> `notifications` + SSE Push | MQ ✅ / MySQL ✅ / SSE ✅ | 消费点赞、评论、关注事件,生成通知并推送在线连接。 |
| Service(建议命名) | `SSEHub/List/MarkRead/UnreadCount/Push` | - | - | `SSEHub` 在 API 进程内维护每个用户的连接通道。 |
### 各个模块的关系
@@ -127,20 +164,27 @@
| 业务模块 | 数据类型 | Key 模式 | Value 内容 | TTL有效期 | 备注 / 高可用策略 |
| ----------------------- | -------- | ------------------------------------------------- | --------------------------------- | ------------- | ------------------------------------------------------------ |
| 鉴权 Token | STRING | `account:<accountID>` | `jwt_token` | 24h | **自愈机制**:鉴权优先查 Redis未命中/失败回退 MySQL 校验 `account.token`;通过后回填 Redis。 |
| Feed 匿名流缓存 | STRING | `feed:listLatest:limit=<n>:before=<u>` | `ListLatestResponse`JSON | 5s | **防击穿**:缓存未命中时用 `lock:<cacheKey>``SETNX`)互斥回源(如 500ms/短等待),避免并发打爆 DB。 |
| Feed 关注流缓存(可选) | STRING | `feed:listByFollow:limit=<n>:uid=<id>:before=<u>` | `ListByFollowResponse`JSON | 5s | **防击穿**:同样使用 `lock:<cacheKey>` 互斥回源(短等待/快速失败兜底)。 |
| Refresh Token | STRING | `account:<accountID>:refresh` / `refresh:<token>` | refresh token / accountID | 7d | 刷新 access token登出/改密时删除。 |
| 分片上传会话 | STRING | `chunk_upload:<uploadID>` | `ChunkUploadSession`JSON | 24h | 记录文件 hash、总分片数、已上传分片用于断点续传。 |
| 分片上传索引 | STRING | `chunk_upload_hash:<accountID>:<fileHash>` | `uploadID` | 24h | 同一用户同一文件 hash 可复用上传会话。 |
| Feed 全局时间线 | ZSET | `feed:global_timeline` | member=`videoID` score=`createTime(ms)` | 常驻/修剪 | Outbox + TimelineConsumer 写入;保留最近约 1000 条热数据。 |
| Feed 关注流缓存 | STRING | `feed:listByFollowing:limit=<n>:accountID=<id>:before=<u>` | `ListByFollowingResponse`JSON | 24h | 使用 `lock:<cacheKey>` 互斥回源;关注流读多写少场景加速。 |
| 视频实体缓存 | STRING | `video:entity:<videoID>` | `Video`JSON | 1h | Feed 批量取详情使用 L1 本地缓存 5s + L2 Redis + L3 MySQL。 |
| 视频详情缓存 | STRING | `video:detail:id=<videoID>` | `Video`JSON | 5m | **一致性**:视频删除/更新时主动 `DEL`**防击穿**:详情回源可加互斥锁(如 2s 锁 TTL。 |
| 实时热榜窗 | ZSET | `hot:video:1m:<yyyyMMddHHmm>` | member=`videoID` score=`热度增量` | 2h | **滚动窗口**:按分钟分桶写入;用 `ZINCRBY` 更新热度,减少单 Key 竞争。 |
| 热榜快照 | ZSET | `hot:video:merge:1m:<as_of>` | `ZUNIONSTORE` 合并结果 | 2m | **聚合查询**:合并最近 60 个分钟窗生成快照;快照分页读取,保证分页一致性与稳定性。 |
| 接口限流计数 | STRING | `feedsystem:ratelimit:<scope>:<subject>` | 计数值 | 窗口 TTL | 登录 10次/分钟/IP注册 5次/小时/IP点赞 30次/分钟/账号;评论 10次/分钟/账号;关注 20次/分钟/账号。 |
## RabbitMQ优化部分
| 业务模块 | Exchange / RoutingKey | 事件类型 | Payload示例字段 | 消费者Worker | 失败/降级策略 |
| -------- | ----------------------------------------------------- | ------------- | --------------------------------------------------- | ------------------ | ------------------------------------------------------------ |
| 点赞 | `like.events` / `like.like` `like.unlike` | 点赞/取消点赞 | `{account_id, video_id, ts}` | `LikeWorker` | 发布失败对失败目标降级直写MySQL 或 Redis确保 `likes` 与计数可落地。 |
| 评论 | `comment.events` / `comment.publish` `comment.delete` | 发布/删除评论 | `{account_id, video_id, comment_id?, content?, ts}` | `CommentWorker` | 发布失败:降级直写 MySQL热度增量事件失败则直接更新 Redis(或同步更新 popularity。 |
| 关注 | `social.events` / `social.follow` `social.unfollow` | 关注/取关 | `{follower_id, vlogger_id, ts}` | `SocialWorker` | 发布失败:降级直写 MySQL保证关注关系即时生效。 |
| 点赞 | `like.events` / `like.like` `like.unlike` | 点赞/取消点赞 | `{account_id/user_id, video_id, ts}` | `LikeWorker` / `NotificationWorker` | 发布失败对失败目标降级直写MySQL 或 Redis点赞成功给视频作者生成通知。 |
| 评论 | `comment.events` / `comment.publish` `comment.delete` | 发布/删除评论 | `{author_id, username, video_id, comment_id?, content?, ts}` | `CommentWorker` / `NotificationWorker` | 发布失败:降级直写 MySQL热度增量事件失败则直接更新 Redis;评论成功给视频作者生成通知。 |
| 关注 | `social.events` / `social.follow` `social.unfollow` | 关注/取关 | `{follower_id, vlogger_id, ts}` | `SocialWorker` / `NotificationWorker` | 发布失败:降级直写 MySQL关注成功给被关注者生成通知。 |
| 热度增量 | `video.popularity.events` / `video.popularity.update` | 热度更新 | `{video_id, delta, reason, ts}` | `PopularityWorker` | `UpdatePopularity` 发布失败:直接更新 Redis 热榜;并触发详情缓存失效(如需要)。 |
| 时间线 | `video.timeline.events` / `video.timeline.publish` | 新视频发布时间线 | `{event_id, video_id, create_time, occurred_at}` | `TimelineConsumer` | `Publish` 事务写 `outbox_msgs`OutboxPoller 投递 MQTimelineConsumer 写 Redis `feed:global_timeline`。 |
| 死信 | `dlx.events` / `#` | 失败消息 | 原始消息 | DLX Queue | RabbitMQ 队列声明 `x-dead-letter-exchange`Worker 按 `x-death` 统计最多重试 3 次。 |
# 整体架构
@@ -171,15 +215,23 @@
| 维度 | 亮点名称 | 技术实现与设计细节 | 业务价值与优势 |
| ---------- | --------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| 缓存架构 | 鉴权缓存自愈机制 | 鉴权中间件优先查 Redis`account:<accountID>`);若失效/不可用则回退 MySQL 校验 `account.token`;通过后自动回填 Redis自愈。 | 兼顾高性能与鲁棒性Redis 宕机不影响鉴权;恢复后可自动“热启动”缓存,降低 DB 压力。 |
| 缓存架构 | 双 Token 与撤销机制 | Access Token 15 分钟过期Refresh Token 7 天有效;服务端保存当前有效 token登出、改密、改名会更新或清除 token 缓存与 DB 记录。 | 支持短期访问凭证、长期刷新凭证与服务端主动撤销,兼顾体验与安全。 |
| 缓存架构 | 分布式锁防击穿 | Feed 匿名流/视频详情等缓存未命中时,用 Redis `SETNX` 做互斥锁控制,仅允许一个请求回源构建缓存,其余等待/返回兜底结果。 | 避免热点 Key 过期瞬间大量并发回源,保护 MySQL提升高峰期稳定性。 |
| 缓存架构 | Feed 冷热分离时间线 | 发布视频写本地 Outbox异步投递 `video.timeline.events` 后写 Redis `feed:global_timeline`;查询推荐流时热数据走 Redis冷数据回源 MySQL 拼接。 | 高并发浏览场景下减少最新流 DB 压力,并保证发布链路最终可达。 |
| 缓存架构 | 滑动窗口热榜快照 | 互动/热度按分钟写入 ZSET查询时用 `ZUNIONSTORE` 聚合最近 N 个时间窗(如 60 分钟)生成“短期快照”并分页读取。 | 降低高频写 Key 竞争;利用快照保证分页一致性,减少“榜单抖动”。 |
| 缓存架构 | 主动失效一致性 | 视频删除/改名/点赞/评论导致数据变化时,主动 `DEL` 相关详情缓存、Feed 缓存或热榜相关缓存。 | 提升数据一致性与用户体验:避免看到已删除/过期/状态错误的旧数据。 |
| 上传体验 | 分片上传与断点续传 | 前端按 5MB 分片上传,计算文件 MD5 与分片 MD5后端 Redis 记录会话与已上传分片,完成后合并并清理临时文件。 | 大文件上传失败后可复用已上传分片,降低重传成本。 |
| 内容组织 | #话题标签 | 发布视频时从标题与描述中提取 `#tag`,写入 `tags``video_tags`Feed 提供 `/feed/listByTag` 查询。 | 支持按话题聚合内容,扩展搜索、推荐与运营入口。 |
| 实时互动 | SSE 通知推送 | 点赞、评论、关注事件经 `NotificationWorker` 写入 `notifications` 表并推送给在线用户;接口支持列表、未读计数、已读标记。 | 用户能实时感知互动事件,离线后仍可查看历史通知。 |
| 分页设计 | 双字段复合游标分页 | `/feed/listLikesCount` 使用 `likes_count_before + id_before` 作为复合游标(两者一起定位下一页)。 | 解决“点赞数相同”排序不稳定问题,确保不重复、不漏数据,分页稳定可复现。 |
| 分页设计 | 快照式稳定分页 | `/feed/listByPopularity` 首次请求生成 `as_of`(分钟级快照版本),后续分页携带相同 `as_of + offset`。 | 规避热度实时变化导致的“跳页/重复/缺失”,滚动浏览更稳定。 |
| 安全鉴权 | 软硬鉴权兼容模式 | 提供 `JWTAuth`(强制拦截)与 `SoftJWTAuth`(可不带 token带了必须合法否则 401。 | 既支持匿名浏览 Feed又支持登录态个性化如点赞/关注状态),体验与安全兼顾。 |
| 安全稳定 | Redis 限流 | 使用 `INCR + PEXPIRE` 原子脚本实现窗口限流,覆盖登录、注册、点赞、评论、关注写接口。 | 降低暴力登录、刷赞、刷评论、频繁关注对系统的冲击。 |
| 系统稳定性 | 多级存储降级设计 | Redis 为可选依赖:连接失败自动降级走 MySQLRedis 恢复后通过请求自愈回填缓存。 | 提升环境适应性与容灾能力,基础设施异常时核心业务仍可用。 |
| 异步架构 | RabbitMQ 事件驱动解耦 | 使用 RabbitMQ topic exchanges`like.events``comment.events``social.events``video.popularity.events`;后端接口仅负责发布事件,`cmd/worker` 内的 Like/Comment/Social/Popularity Worker 异步消费并更新 MySQL/Redis。 | 削峰填谷、降低接口响应时延;写扩散与热度计算解耦,提升吞吐与可维护性,便于后续扩展更多消费者(统计、风控等)。 |
| 异步架构 | Outbox 保证发布时间线 | `Publish` 事务内写 `videos``outbox_msgs`OutboxPoller 持续投递 MQ成功后删除消息。 | 避免视频记录已写入但时间线事件丢失的问题,提升最终一致性。 |
| 异步架构 | MQ 异常降级直写 | 点赞/评论:尝试同时发布“写 MySQL 的队列”+“写 Redis 热度队列”任一发布失败则对失败目标降级为直写MySQL 或 Redis`UpdatePopularity` 发布失败则直接更新 Redis。 | MQ 不可用时仍能保证核心数据正确落库/可见,避免“请求成功但数据不落地”的一致性风险。 |
| 工程交付 | Docker Compose 一键依赖拉起 | 通过 `docker compose up -d rabbitmq`(或 `./start.sh` 自动拉起)快速启动 RabbitMQ 等依赖;本地环境以容器化方式对齐。 | 降低环境搭建成本,减少“在我机器上没问题”;便于 CI/本地联调/演示,提升交付效率。 |
| 工程交付 | 脚本化一键启动与可拆分运行 | `./start.sh` 默认启动后端+前端,并可用 `START_FRONTEND=0` 仅启后端Worker 可单独运行 `go run ./cmd/worker`。 | 提升开发体验与部署灵活性:既能一键体验全链路,也能按需拆分进程满足生产部署(API/Worker 独立伸缩。 |
| 工程质量 | 自动化基础设施 | 服务启动时执行 GORM `AutoMigrate` 自动同步 `Account/Video/Like/Comment/Social` 等表结构。 | 简化部署与迭代成本,“开箱即用”,保证 Schema 与模型一致|
| 工程交付 | Docker Compose 一键拉起 | `docker compose up -d --build` 同时拉起 MySQL、Redis、RabbitMQ、API、Worker、Frontend并配置健康检查与 volume。 | 本地演示和部署入口统一,依赖健康状态明确。 |
| 工程交付 | 脚本化一键启动与可拆分运行 | `./start.sh` 默认启动后端、Worker、前端、Redis、RabbitMQ可用 `START_FRONTEND=0``START_WORKER=0``STOP_DOCKER=1` 等开关控制。 | 提升开发体验与部署灵活性,API/Worker 独立伸缩。 |
| 工程质量 | 自动化基础设施 | 服务启动时执行 GORM `AutoMigrate` 自动同步账号、视频、互动、标签、私信、通知、Outbox 等表结构。 | 简化部署与迭代成本,保证 Schema 与模型一致。 |
| 可观测性 | 健康检查与 pprof | `GET /healthz` 提供 API 健康检查;本地 pprof 默认 API `localhost:6060`、Worker `localhost:6061`。 | 便于定位 CPU、内存、goroutine 等运行时问题。 |

View File

@@ -1,23 +1,42 @@
# feedsystem_video_go frontend
这是对接 `backend/`Gin + GORM + MySQL + JWT的一套 Vue3 前端调试 UI覆盖全部后端路由
- Account注册 / 登录 / 改密码 / 查找 / 改名 / 登出
- Video发布 / 按作者列出 / 详情
- Like点赞 / 取消点赞 / 是否点赞
- Comment列表 / 发布 / 删除
- Social关注 / 取关 / 粉丝列表 / 关注列表
- Feed最新流 / 点赞数流 / 关注流
Vue 3 + Vite 前端,面向 `backend/` 的短视频 Feed 应用。默认通过 Vite 代理把 `/api/...` 转发到 `http://localhost:8080/...`
## 页面
| 路由 | 页面 | 说明 |
|------|------|------|
| `/` | 推荐页 | 沉浸式短视频播放,支持推荐、关注、点赞榜三个 Tab本地搜索标题/作者,滚动加载。 |
| `/hot` | 热榜 | 对接 `/feed/listByPopularity`,支持刷新与加载更多。 |
| `/video` | 发布页 | 登录后可发布视频;视频按 5MB 分片上传,支持 MD5 校验、并发上传、失败重试、断点续传。 |
| `/video/:id` | 视频详情 | 视频播放、点赞、评论抽屉、作者信息。 |
| `/account` | 账号页 | 登录、个人信息、粉丝/关注入口。 |
| `/account/register` | 注册页 | 创建账号。 |
| `/account/change-password` | 改密页 | 使用旧密码修改密码。 |
| `/settings` | 设置页 | 改名、退出登录、跳转改密。 |
| `/u/:id` | 用户主页 | 用户作品、粉丝/关注列表、关注/取关、私信入口。 |
| `/messages` | 私信联系人 | 从粉丝和关注用户中选择聊天对象。 |
| `/messages/:peerId` | 私信会话 | 查看最近 50 条私信并发送消息。 |
## 前端能力
- Pinia 保存 access token 与 refresh token。
- API Client 在 401 时自动调用 `/account/refresh` 并重试原请求。
- 发布页使用 `spark-md5` 计算文件 MD5 与分片 MD5。
- 播放页使用虚拟渲染窗口,只保留当前视频前后各一条 DOM降低长列表播放成本。
- 点赞、关注、评论、分享等交互通过 toast 提示结果。
- Vite 代理地址可通过 `VITE_API_BASE` 覆盖。
## 开发启动
先启动后端:
先启动后端 API
```bash
cd backend
go run ./cmd
CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd
```
启动前端:
启动前端:
```bash
cd frontend
@@ -25,4 +44,16 @@ npm install
npm run dev
```
默认通过 Vite 代理转发请求:前端访问 `/api/...``http://localhost:8080/...`(见 `frontend/vite.config.ts`)。
完整链路可在项目根目录执行:
```bash
./start.sh
```
## 构建
```bash
npm run build
```
Docker Compose 中前端容器使用 Nginx 托管构建产物,并把 `/api` 反向代理到后端服务。