Compare commits
10 Commits
9c00e06b1f
...
1b74c82ed4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b74c82ed4 | ||
|
|
88196db47f | ||
|
|
8a3d18b097 | ||
|
|
8a3433b5d2 | ||
|
|
cbfdb73fcb | ||
|
|
589116b141 | ||
|
|
33c0bd418c | ||
|
|
28408606f4 | ||
|
|
d8bca16362 | ||
|
|
229446e93b |
64
.github/workflows/ci.yml
vendored
Normal file
64
.github/workflows/ci.yml
vendored
Normal 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
|
||||
70
README.md
70
README.md
@@ -1,6 +1,6 @@
|
||||
# feedsystem_video_go
|
||||
|
||||
基于 Go + Vue 3 的短视频 Feed 系统,含账号、视频、点赞、评论、关注、Feed 流,支持 Redis 缓存与 RabbitMQ 异步 Worker(API 与 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` 返回后端健康状态。
|
||||
- 本地配置默认开启 pprof:API `localhost:6060`,Worker `localhost:6061`。
|
||||
- 上传文件写入 `backend/.run/uploads`;Docker 环境挂载到 `backend_uploads` volume。
|
||||
- Redis 用于 Token 缓存、视频实体缓存、Feed 时间线、热榜窗口、分片上传会话。
|
||||
- RabbitMQ Topic Exchange 覆盖点赞、评论、关注、热度、视频时间线事件,并配置 DLX。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// RabbitMQ 只管理 Connection,Channel 由各组件按需创建
|
||||
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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# feedsystem_video_go
|
||||
|
||||
> 项目介绍:本项目是一款由Go开发的高性能短视频Feed流系统,提供账号、视频、点赞、评论、关注(Social)与 Feed 等接口,并用Redis和RabbitMQ优化,同时编写了docker文件使其方便部署。
|
||||
> 项目介绍:本项目是一款由 Go + Vue 3 开发的短视频 Feed 流系统,提供账号、视频、点赞、评论、关注(Social)、Feed、私信与通知接口,并通过 Redis、RabbitMQ、分片上传、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,支持异常降级直写。 |
|
||||
| 异步执行 | Worker(Go) | `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、异常降级直写。 |
|
||||
| 异步执行 | Worker(Go) | `cmd/worker`:LikeWorker/CommentWorker/SocialWorker/PopularityWorker;API 进程内启动 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 token;Redis 写 `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(快照+offset);Redis 不可用回退 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(快照+offset);Redis 不可用时回退 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 投递 MQ;TimelineConsumer 写 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 为可选依赖:连接失败自动降级走 MySQL;Redis 恢复后通过请求自愈回填缓存。 | 提升环境适应性与容灾能力,基础设施异常时核心业务仍可用。 |
|
||||
| 异步架构 | 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 等运行时问题。 |
|
||||
|
||||
@@ -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` 反向代理到后端服务。
|
||||
|
||||
Reference in New Issue
Block a user