Compare commits

...

14 Commits

Author SHA1 Message Date
5634ef3787 ci: 改为单 job 直接构建部署,消除 Gitea Registry 依赖
ci.yml: 合并为单 deploy job,runs-on aliyun,apk 安装工具,docker compose up -d --build 直接构建

docker-compose.prod.yml: backend/worker/frontend 改用 build: 块,移除 nginx bind mount

frontend/Dockerfile: 添加 ARG NGINX_CONFIG,生产构建传入 nginx.prod.conf 进镜像
2026-07-28 12:46:36 +08:00
3ca87aa159 ci: 完善 CI/CD 流程,添加实际生产部署工作流 2026-07-17 22:18:34 +08:00
ffd9b0c8f0 chore: 补充后端核心模块的中文注释 2026-07-15 11:54:01 +08:00
hhs
e3c68dd6d3 docs: 添加部分注释
Some checks failed
CI / Backend (push) Has been cancelled
CI / Frontend (push) Has been cancelled
2026-07-01 19:34:13 +08:00
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
33 changed files with 913 additions and 337 deletions

53
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,53 @@
name: CI/CD
on:
push:
branches:
- main
- master
concurrency:
group: deploy-${{ gitea.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Deploy
runs-on: aliyun
steps:
- name: Checkout
uses: actions/checkout@v4
# -- Backend: vet + test --
- name: Install Go
run: |
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
apk add --no-cache go
- name: Vet
working-directory: backend
run: go vet ./...
- name: Test
working-directory: backend
run: go test -race -count=1 ./...
# -- Frontend: install + build --
- name: Install Node
run: apk add --no-cache nodejs npm
- name: Build Frontend
working-directory: frontend
run: |
npm ci
npm run build
# -- Deploy --
- name: Deploy
run: |
cp docker-compose.prod.yml /opt/vloop/
cp -r backend /opt/vloop/
cp -r frontend /opt/vloop/
cd /opt/vloop
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
docker image prune -f

2
.gitignore vendored
View File

@@ -3,6 +3,7 @@ backend/.run/
data/
.sisyphus/
docs/
CLAUDE.md
#
# Binaries for programs and plugins
@@ -34,3 +35,4 @@ go.work.sum
# Editor/IDE
.idea/
.vscode/
.claude

View File

@@ -1,23 +1,20 @@
# feedsystem_video_go
基于 Go + Vue 3 的短视频 Feed 系统含账号、视频、点赞、评论、关注、Feed 流,支持 Redis 缓存RabbitMQ 异步 WorkerAPI 与 Worker 可拆分部署
## 更完整的视频 Feed 流系统项目
[LeoninCS/GCFeed](https://github.com/LeoninCS/GCFeed) 是更为全面完整的视频 Feed 流系统项目,覆盖更丰富的业务能力与工程实践。
基于 Go + Vue 3 的短视频 Feed 系统含账号、视频、点赞、评论、关注、Feed 流、私信、通知,支持 Redis 缓存RabbitMQ 异步 Worker、分片上传、SSE 实时推送、Docker Compose 部署。
## 功能
| 模块 | 功能 |
|------|------|
| 账号 | 注册/登录/改名/改密/登出头像上传个人简介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 +27,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 +62,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 +92,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 +135,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 +151,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

@@ -17,6 +17,7 @@ import (
"syscall"
"time"
"github.com/joho/godotenv"
amqp "github.com/rabbitmq/amqp091-go"
"gorm.io/gorm"
)
@@ -39,6 +40,7 @@ const (
popularityBindingKey = "video.popularity.*"
)
// 带重试机制的基础设施连接函数
func connectWithRetry(name string, maxRetries int, fn func() error) {
for i := 0; i < maxRetries; i++ {
if err := fn(); err == nil {
@@ -54,7 +56,43 @@ func connectWithRetry(name string, maxRetries int, fn func() error) {
log.Fatalf("%s: 超过最大重试次数", name)
}
// runWorkerWithRetry 为每个 Worker 创建独立 Channel 并设置 QoS断开后自动重连
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 +143,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 +186,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

@@ -168,6 +168,7 @@ func (f *FeedHandler) ListByPopularity(c *gin.Context) {
c.JSON(200, resp)
}
// 在返回的 FeedVideoItem 列表中,如果列表为 nil则返回空切片避免 JSON 序列化为 null
func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem {
if items == nil {
return []FeedVideoItem{}

View File

@@ -17,6 +17,7 @@ func NewFeedRepository(db *gorm.DB) *FeedRepository {
return &FeedRepository{db: db}
}
// 查询最新视频. limit: 查询数量 latestBefore: 查询早于此时间的视频(零值则不限制)
func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
@@ -30,6 +31,7 @@ func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBef
return videos, nil
}
// 查询点赞数最多的视频. limit: 查询数量 cursor: 游标
func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
@@ -49,6 +51,7 @@ func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit
return videos, nil
}
// 查询关注用户的视频. limit: 查询数量 viewerAccountID: 查看者账户ID latestBefore: 查询早于此时间的视频(零值则不限制)
func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
@@ -69,6 +72,7 @@ func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, view
return videos, nil
}
// 查询热门视频. limit: 查询数量 popularityBefore: 查询热度低于此值的视频 timeBefore: 查询早于此时间的视频 idBefore: 查询ID小于此值的视频
func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).

View File

@@ -33,9 +33,9 @@ func NewFeedService(repo *FeedRepository, likeRepo *video.LikeRepository, redisc
return &FeedService{repo: repo, likeRepo: likeRepo, rediscache: rediscache, localcache: cache.New(3*time.Second, 5*time.Second), cacheTTL: 24 * time.Hour}
}
// GetVideoByIDs 批量获取视频信息
// 采用 L1(本地缓存) -> L2(Redis) -> L3(MySQL) 三级架构
func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*video.Video, error) {
// GetVideoByIDs 批量获取视频信息
// 采用 L1(本地缓存) -> L2(Redis) -> L3(MySQL) 三级架构
if len(videoIDs) == 0 {
return []*video.Video{}, nil
}
@@ -109,6 +109,8 @@ func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*vi
wg.Add(1)
go func(videoID uint) {
defer wg.Done()
// singleflight 防止缓存击穿
sfKey := f.rediscache.Key("sf:entity:%d", videoID)
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
@@ -160,6 +162,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
isZsetEmpty := len(zsetTail) == 0
// ZSet 为空时尝试重建 ZSet
if isZsetEmpty {
//全局静态锁:无视所有用户的不同时间戳游标
sfKey := f.rediscache.Key("sf:fallback:global_timeline_rebuild")
@@ -195,10 +198,11 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
return ListLatestResponse{HasMore: false}, nil
}
// 让所有被阻塞的请求重新查一遍
// 递归调用自己,让所有被阻塞的请求重新查一遍
return f.ListLatest(ctx, limit, latestBefore, viewerAccountID)
}
// watermark 是 ZSET 中最老的一条数据的时间戳; reqTime 是本次请求的时间戳(如果没有传 latestBefore则使用当前时间)
watermark := int64(zsetTail[0].Score)
reqTime := time.Now().UnixMilli()
if !latestBefore.IsZero() {
@@ -228,11 +232,19 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
maxScore = fmt.Sprintf("%d", reqTime-1) // 防重复
}
videoIDsStr, err := f.rediscache.ZRevRangeByScore(ctx, f.rediscache.Key("feed:global_timeline"), maxScore, "-inf", 0, int64(limit))
videoIDsStr, err := f.rediscache.ZRevRangeByScore(
ctx,
f.rediscache.Key("feed:global_timeline"),
maxScore,
"-inf",
0,
int64(limit),
)
if err != nil {
return ListLatestResponse{}, err
}
// 将字符串 ID 转换为 uint
var videoIDs []uint
for _, idStr := range videoIDsStr {
if id, err := strconv.ParseUint(idStr, 10, 64); err == nil {
@@ -247,7 +259,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
}
}
// 刚好击穿了冷热边界
// 刚好击穿了冷热边界,从数据库中再拉一些冷数据补齐
if len(baseVideos) < limit {
remainLimit := limit - len(baseVideos) // 计算还差几个
@@ -381,6 +393,7 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo
token, locked, _ := f.rediscache.Lock(cacheCtx, lockKey, 500*time.Millisecond)
if locked {
defer func() { _ = f.rediscache.Unlock(context.Background(), lockKey, token) }()
// Double check再次检查缓存是否被其他请求回写
if b, err := f.rediscache.GetBytes(cacheCtx, cacheKey); err == nil {
var cached ListByFollowingResponse
if err := json.Unmarshal(b, &cached); err == nil {
@@ -396,7 +409,7 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo
}
return resp, nil
}
} else {
} else { // 加锁失败,循环等待缓存被其他请求回写
for i := 0; i < 5; i++ {
time.Sleep(20 * time.Millisecond)
if b, err := f.rediscache.GetBytes(cacheCtx, cacheKey); err == nil {
@@ -410,11 +423,12 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo
}
}
// 缓存未命中或 Redis 不可用,降级成数据库查询
resp, err := doListByFollowingFromDB()
if err != nil {
return ListByFollowingResponse{}, err
}
if cacheKey != "" {
if cacheKey != "" { // 缓存回写
if b, err := json.Marshal(resp); err == nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
@@ -427,22 +441,25 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo
func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf int64, offset int, viewerAccountID uint, latestPopularity int64, latestBefore time.Time, latestIDBefore uint) (ListByPopularityResponse, error) {
// Redis 热榜稳定分页as_of + offset
if f.rediscache != nil {
// 将 as_of 截断到分钟级
asOf := time.Now().UTC().Truncate(time.Minute)
if reqAsOf > 0 {
asOf = time.Unix(reqAsOf, 0).UTC().Truncate(time.Minute)
}
// 创建时间窗口,获取过去 60 分钟的 ZSET
const win = 60
keys := make([]string, 0, win)
for i := 0; i < win; i++ {
keys = append(keys, f.rediscache.Key("hot:video:1m:%s", asOf.Add(-time.Duration(i)*time.Minute).Format("200601021504")))
}
dest := f.rediscache.Key("hot:video:merge:1m:%s", asOf.Format("200601021504")) // 快照key同一个as_of页内复用
// 创建快照key同一个as_of页内复用
dest := f.rediscache.Key("hot:video:merge:1m:%s", asOf.Format("200601021504"))
opCtx, cancel := context.WithTimeout(ctx, 80*time.Millisecond)
defer cancel()
exists, _ := f.rediscache.Exists(opCtx, dest)
exists, _ := f.rediscache.Exists(opCtx, dest) // 检查合并快照是否已存在
if !exists {
_ = f.rediscache.ZUnionStore(opCtx, dest, keys, "SUM")
_ = f.rediscache.Expire(opCtx, dest, 2*time.Minute) // 给翻页留时间
@@ -462,6 +479,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
}
}
if err == nil && len(members) > 0 {
// 将字符串 ID 转换为 uint
ids := make([]uint, 0, len(members))
for _, m := range members {
u, err := strconv.ParseUint(m, 10, 64)
@@ -470,6 +488,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
}
}
// 根据 ID 批量获取视频信息
videos, err := f.repo.GetByIDs(ctx, ids)
if err == nil {
byID := make(map[uint]*video.Video, len(videos))
@@ -477,7 +496,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
byID[v.ID] = v
}
ordered := make([]*video.Video, 0, len(ids))
for _, id := range ids {
for _, id := range ids { // 按 Redis 返回的顺序重新排列
if v := byID[id]; v != nil {
ordered = append(ordered, v)
}
@@ -492,7 +511,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
NextOffset: offset + len(items),
HasMore: len(items) == limit,
}
if len(ordered) > 0 {
if len(ordered) > 0 { // 准备最后一条视频的游标信息,供 DB fallback 使用
last := ordered[len(ordered)-1]
nextPopularity := last.Popularity
nextBefore := last.CreateTime
@@ -506,6 +525,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
}
}
// DB fallback游标分页latestPopularity + latestBefore + latestIDBefore
videos, err := f.repo.ListByPopularity(ctx, limit, latestPopularity, latestBefore, latestIDBefore)
if err != nil {
return ListByPopularityResponse{}, err
@@ -532,6 +552,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
return resp, nil
}
// 将 Video 列表转换为 FeedVideoItem 列表,并批量查询填充当前用户对所有视频的点赞状态
func (f *FeedService) buildFeedVideos(ctx context.Context, videos []*video.Video, viewerAccountID uint) ([]FeedVideoItem, error) {
feedVideos := make([]FeedVideoItem, 0, len(videos))
videoIDs := make([]uint, len(videos))
@@ -558,6 +579,7 @@ func (f *FeedService) buildFeedVideos(ctx context.Context, videos []*video.Video
return feedVideos, nil
}
// 将视频列表按照给定的 ID 顺序(orderedIDs)重新排序
func buildOrderedResult(orderedIDs []uint, dataMap map[uint]*video.Video) []*video.Video {
res := make([]*video.Video, 0, len(orderedIDs))
for _, id := range orderedIDs {

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

@@ -23,7 +23,7 @@ func DeclareDLX(ch *amqp.Channel, queueName string) error {
}
dlxQueue := queueName + ".dlx"
_, err := ch.QueueDeclare(
dlxQueue, true, false, false, false, nil,
dlxQueue, true, false, false, false, nil, // 死信队列不设置 DLX
)
if err != nil {
return err

View File

@@ -4,10 +4,12 @@ import (
"context"
"errors"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type LikeMQ struct {
*RabbitMQ
ch *amqp.Channel
}
const (
@@ -15,8 +17,8 @@ const (
likeQueue = "like.events"
likeBindingKey = "like.*"
likeLikeRK = "like.like"
likeUnlikeRK = "like.unlike"
likeLikeRK = "like.like" // 点赞路由键
likeUnlikeRK = "like.unlike" // 取消点赞路由键
)
type LikeEvent struct {
@@ -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,82 +28,76 @@ 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,
false,
false,
false,
true, // 持久化参数
false, // autoDelete 是否在未使用时自动删除
false, // internal 是否交给其他交换机用而不直接收消息
false, // noWait 是否不等待 broker 确认
nil,
); err != nil {
return err
}
q, err := r.Ch.QueueDeclare(
queue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": DLXExchange},
q, err := ch.QueueDeclare(
queue, // 队列名称
true, // 持久化
false, // autoDelete 是否在未使用时自动删除
false, // exclusive 是否排他(允许多个消费者共享)
false, // noWait 是否不等待 broker 确认
amqp.Table{"x-dead-letter-exchange": DLXExchange}, // 死信交换机
)
if err != nil {
return err
}
if err := r.Ch.QueueBind(
if err := ch.QueueBind(
q.Name,
bindingKey,
exchange,
false,
nil,
false, // noWait
nil, // args
); 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,11 +106,11 @@ 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{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),
Body: b,
return ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{
ContentType: "application/json", // 消息格式
DeliveryMode: amqp.Persistent, // 消息持久化到磁盘, 值为 1 则不持久化, 值为 2 则持久化, amqp.Presistent 为 2
Timestamp: time.Now(), // 消息产生的时间戳
Body: b, // 实际消息内容(JSON字节)
})
}

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

@@ -8,6 +8,8 @@ import (
redis "github.com/redis/go-redis/v9"
)
// ZincrBy 将有序集合 key 中 member 的分数增加 score。
// 如果 member 不存在,则将其添加并设置初始分数为 score。
func (c *Client) ZincrBy(ctx context.Context, key string, member string, score float64) error {
if c == nil || c.rdb == nil {
return nil
@@ -15,6 +17,7 @@ func (c *Client) ZincrBy(ctx context.Context, key string, member string, score f
return c.rdb.ZIncrBy(ctx, key, score, member).Err()
}
// ZAdd 向有序集合 key 中添加一个或多个带分数的成员。
func (c *Client) ZAdd(ctx context.Context, key string, members ...redis.Z) error {
if c == nil || c.rdb == nil {
return nil
@@ -22,6 +25,7 @@ func (c *Client) ZAdd(ctx context.Context, key string, members ...redis.Z) error
return c.rdb.ZAdd(ctx, key, members...).Err()
}
// ZRemRangeByRank 移除有序集合 key 中排名在 [start, stop] 范围内的所有成员。
func (c *Client) ZRemRangeByRank(ctx context.Context, key string, start int64, stop int64) error {
if c == nil || c.rdb == nil {
return nil
@@ -29,6 +33,7 @@ func (c *Client) ZRemRangeByRank(ctx context.Context, key string, start int64, s
return c.rdb.ZRemRangeByRank(ctx, key, start, stop).Err()
}
// ZRangeWithScores 返回有序集合 key 中排名在指定范围内的成员及其分数。
func (c *Client) ZRangeWithScores(ctx context.Context, key string, start int64, stop int64) ([]redis.Z, error) {
if c == nil || c.rdb == nil {
return nil, errors.New("redis client not initialized")
@@ -36,6 +41,7 @@ func (c *Client) ZRangeWithScores(ctx context.Context, key string, start int64,
return c.rdb.ZRangeWithScores(ctx, key, start, stop).Result()
}
// Expire 为 key 设置过期时间TTL 过后 key 会被自动删除。
func (c *Client) Expire(ctx context.Context, key string, ttl time.Duration) error {
if c == nil || c.rdb == nil {
return nil
@@ -43,6 +49,8 @@ func (c *Client) Expire(ctx context.Context, key string, ttl time.Duration) erro
return c.rdb.Expire(ctx, key, ttl).Err()
}
// ZUnionStore 计算 keys 指定的多个有序集合的并集,将结果存入 dst。
// aggregate 控制分数聚合方式,可选 "SUM"、"MIN" 或 "MAX"。
func (c *Client) ZUnionStore(ctx context.Context, dst string, keys []string, aggregate string) error {
if c == nil || c.rdb == nil {
return nil
@@ -53,6 +61,7 @@ func (c *Client) ZUnionStore(ctx context.Context, dst string, keys []string, agg
}).Err()
}
// Exists 判断 key 是否存在于 Redis 中。
func (c *Client) Exists(ctx context.Context, key string) (bool, error) {
if c == nil || c.rdb == nil {
return false, nil
@@ -61,6 +70,7 @@ func (c *Client) Exists(ctx context.Context, key string) (bool, error) {
return n > 0, err
}
// ZRevRange 返回有序集合 key 中排名在 [start, stop] 范围内的成员,按分数从高到低排序。
func (c *Client) ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) {
if c == nil || c.rdb == nil {
return nil, nil
@@ -68,6 +78,8 @@ func (c *Client) ZRevRange(ctx context.Context, key string, start, stop int64) (
return c.rdb.ZRevRange(ctx, key, start, stop).Result()
}
// ZRevRangeByScore 返回有序集合 key 中分数在 [min, max] 范围内的成员,按分数从高到低排序。
// offset 和 count 用于分页控制。
func (c *Client) ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error) {
if c == nil || c.rdb == nil {
return nil, nil

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,33 @@ 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

@@ -62,6 +62,7 @@ func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (
return count > 0, nil
}
// 查询给定视频 ID 列表中,哪些视频被指定账户点赞过
func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) {
likeMap := make(map[uint]bool)
if len(videoIDs) == 0 {

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

@@ -32,13 +32,13 @@ func (w *LikeWorker) Run(ctx context.Context) error {
}
deliveries, err := w.ch.Consume(
w.queue,
"",
false,
false,
false,
false,
nil,
w.queue, // 队列名
"", // 消费者标签,空代表让 RabbitMQ 自动生成一个唯一的标签
false, // autoAck = false 采用手动确认模式
false, // exclusive = false 允许多个消费者同时消费同一个队列
false, // noLocal = false 允许消费者接收自己发送的消息
false, // noWait = false 阻塞等待 RabbitMQ 的响应
nil, // args
)
if err != nil {
return err
@@ -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 {
@@ -85,7 +95,7 @@ func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error
if len(body) == 0 {
return nil
}
routingKey := d.RoutingKey
routingKey := d.RoutingKey // 复用路由键充当事件类型标识
var notif *Notification

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"
@@ -14,8 +14,9 @@ import (
"gorm.io/gorm"
)
// 轮询器,轮询数据库中的 outbox 表,获取待投递的消息,投递到 MQ 中
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 +47,10 @@ 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")
// 消费者,消费 MQ 中的消息,写入 Redis 的 Zset 中
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 +58,66 @@ 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
}
// 设置当前 Channel 的 QoSQuality of Service参数控制消息的预取数量和大小
// prefetch count = 10 一次最多取10个消息, prefetch size = 0 不限制预取的字节大小, global = false Qos 设置只对当前 Channel 生效
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 {

133
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,133 @@
services:
mysql:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
TZ: "Asia/Shanghai"
volumes:
- mysql_data:/var/lib/mysql
command:
- --default-authentication-plugin=mysql_native_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
restart: always
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping"]
interval: 5s
timeout: 3s
retries: 20
rabbitmq:
image: rabbitmq:3-management
restart: always
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]
interval: 5s
timeout: 5s
retries: 20
backend:
build:
context: .
dockerfile: backend/Dockerfile
target: api
image: vloop-backend-api:latest
restart: always
environment:
CONFIG_PATH: /app/configs/config.yaml
MYSQL_HOST: mysql
REDIS_HOST: redis
RABBITMQ_HOST: rabbitmq
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
REDIS_PASSWORD: ${REDIS_PASSWORD}
RABBITMQ_USER: ${RABBITMQ_USER}
RABBITMQ_PASS: ${RABBITMQ_PASS}
JWT_SECRET: ${JWT_SECRET}
volumes:
- backend_uploads:/app/.run/uploads
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 5s
retries: 3
worker:
build:
context: .
dockerfile: backend/Dockerfile
target: worker
image: vloop-backend-worker:latest
restart: always
environment:
CONFIG_PATH: /app/configs/config.yaml
MYSQL_HOST: mysql
REDIS_HOST: redis
RABBITMQ_HOST: rabbitmq
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
REDIS_PASSWORD: ${REDIS_PASSWORD}
RABBITMQ_USER: ${RABBITMQ_USER}
RABBITMQ_PASS: ${RABBITMQ_PASS}
JWT_SECRET: ${JWT_SECRET}
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pgrep worker || exit 1"]
interval: 15s
timeout: 5s
retries: 3
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
args:
NGINX_CONFIG: nginx.prod.conf
image: vloop-frontend:latest
restart: always
ports:
- "127.0.0.1:9001:80"
depends_on:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:80/ || exit 1"]
interval: 10s
timeout: 5s
retries: 3
volumes:
mysql_data:
redis_data:
rabbitmq_data:
backend_uploads:

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

@@ -5,6 +5,8 @@
# docker build -f frontend/Dockerfile -t feedsystem-frontend .
#
ARG NGINX_CONFIG=nginx.conf
FROM node:24-alpine AS build
WORKDIR /src
@@ -15,7 +17,8 @@ COPY frontend/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
ARG NGINX_CONFIG
COPY frontend/${NGINX_CONFIG} /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

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` 反向代理到后端服务。

61
frontend/nginx.prod.conf Normal file
View File

@@ -0,0 +1,61 @@
server {
listen 80;
server_name _;
# Allow large uploads (e.g. videos)
client_max_body_size 300m;
root /usr/share/nginx/html;
index index.html;
# SSE notification stream — must disable buffering for real-time push
location /notification/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Health check
location /healthz {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA routing (Vue Router history mode)
location / {
try_files $uri $uri/ /index.html;
}
# Reverse proxy to backend (strip /api prefix)
location /api/ {
proxy_pass http://backend:8080/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Serve uploaded files via backend static route
location /static/ {
proxy_pass http://backend:8080/static/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}

View File

@@ -2,18 +2,20 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"lib": [
"ES2023"
],
"module": "ESNext",
"types": ["node"],
"types": [
"node"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
@@ -22,5 +24,7 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
"include": [
"vite.config.ts"
]
}