From 14558f7c4891d8353c207ea1c432ea6175255ae6 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 15:19:39 +0800 Subject: [PATCH 01/23] =?UTF-8?q?docs:=20=E5=85=A8=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=20?= =?UTF-8?q?=E2=80=94=20=E9=A3=8E=E9=99=A9=E9=A9=B1=E5=8A=A8=E4=B8=89?= =?UTF-8?q?=E6=89=B9=E6=96=B9=E6=A1=88=EF=BC=8817=E9=A1=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2025-04-25-optimization-design.md | 149 +++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/plans/2025-04-25-optimization-design.md diff --git a/docs/plans/2025-04-25-optimization-design.md b/docs/plans/2025-04-25-optimization-design.md new file mode 100644 index 0000000..0bb7e10 --- /dev/null +++ b/docs/plans/2025-04-25-optimization-design.md @@ -0,0 +1,149 @@ +# feedsystem_video_go 全项目优化设计文档 + +> **日期**: 2025-04-25 +> **状态**: 待实施 +> **方案**: 风险驱动分批(方案 A) + +## 概述 + +基于全项目代码审查,识别出 17 项优化点,覆盖安全、数据库、MQ 可靠性、代码质量、架构、前端六大维度。按风险优先级分为三批实施。 + +--- + +## P1 止血(4项)— 消除生产风险 + +### 1. Router 变量赋值 Bug + +- **文件**: `backend/internal/http/router.go:147` +- **问题**: `timelineMQ` 初始化失败时错误地将 `socialMQ` 设为 nil +- **修复**: `socialMQ = nil` → `timelineMQ = nil` +- **影响**: 1 行 + +### 2. 数据库复合索引 + +- **文件**: `backend/internal/video/video_entity.go` +- **问题**: Feed 流排序查询缺少索引,可能全表扫描 +- **修复**: 在 Video 模型 GORM tag 中添加 3 个复合索引 + - `idx_videos_create_time` — `ListLatest` + - `idx_videos_likes_count_id` — `ListLikesCountWithCursor` + - `idx_videos_popularity_time_id` — `ListByPopularity` +- **实施**: 修改 model tag + AutoMigrate 自动创建 + +### 3. ListByAuthorID 加 LIMIT + +- **文件**: `backend/internal/video/video_repo.go:39-48` +- **问题**: 查询无上限,单作者海量视频可导致内存溢出 +- **修复**: 加 `Limit(200)` 硬上限 + +### 4. MQ Worker 死信队列 + 退避重试 + +- **文件**: `middleware/rabbitmq/` + 4 个 Worker 文件 +- **问题**: 所有 Worker 使用 `Nack(false, true)` 无限重试 +- **修复**: + - 声明死信交换 + 死信队列 + - 利用 `x-death` header 判断重试次数,≥3 次 Ack 并告警 + +--- + +## P2 加固(6项)— 安全隐患 + 规范化 + +### 5. rand.Read 错误处理 + +- **文件**: `backend/internal/video/video_handler.go:164-168` +- **问题**: 忽略 `rand.Read` 错误,失败时文件名全零可能覆盖 +- **修复**: `randHex()` 返回 error,调用方处理 + +### 6. Handler 错误码精确化 + +- **文件**: 所有 handler 文件 +- **问题**: DB/内部错误统一返回 400 +- **修复**: 新增 `classifyHTTPStatus()` 辅助函数,Service 层返回哨兵错误区分 400/401/404/500 + +### 7. JWT Secret 弱默认值 + +- **文件**: `backend/internal/auth/jwt.go` +- **问题**: 默认值 `"change-me-in-env"` 过于明显 +- **修复**: 未设环境变量时生成随机密钥并警告 + +### 8. 配置密码集中管理 + +- **文件**: `docker-compose.yml` + 3 个 config YAML +- **问题**: 多处重复硬编码密码 +- **修复**: docker-compose 引用 `.env`,创建 `.env.example`,config YAML 保持现状 + +### 9. 前端路由鉴权守卫 + +- **文件**: `frontend/src/router/index.ts` +- **问题**: Settings/Video 页面无登录拦截 +- **修复**: 添加 `router.beforeEach` 守卫 + +### 10. pprof 生产保护 + +- **现状**: 已监听 `127.0.0.1`,`config.docker.yaml` 已禁用 +- **动作**: 确认安全,仅需注释说明 + +--- + +## P3 优化(7项)— 架构 + 可维护性 + +### 11. HomeView.vue 拆分 + +- **文件**: `frontend/src/views/HomeView.vue` (918 行) +- **拆分目标**: + - `composables/useVideoFeed.ts` + - `composables/useVideoPlayer.ts` + - `composables/useLikeFollow.ts` + - `components/CommentDrawer.vue` + - `views/HomeView.vue`(精简至 ~350 行) + +### 12. Feed Service 策略拆分 + +- **文件**: `backend/internal/feed/service.go` (547 行) +- **拆分目标**: 按查询策略拆为 4 个文件 + - `strategy_latest.go` — 热冷分离 + ZSET + - `strategy_follow.go` — 缓存穿透防护 + - `strategy_hot.go` — 快照合并 + 降级 + - `build_feed.go` — 公共方法 + +### 13. 视频列表虚拟滚动 + +- **问题**: 所有视频渲染 DOM,内存压力大 +- **修复**: 仅保留当前 ±1 条 DOM,离屏 `display:none` + `pause()` + +### 14. 缓存键版本化 + +- **文件**: `middleware/redis/redis.go` + 所有 service +- **修复**: `Client` 增加 `keyPrefix`,所有键通过 `c.Key()` 生成 + +### 15. Docker 健康检查 + +- **文件**: `docker-compose.yml` +- **修复**: 为 backend/worker/frontend 增加 healthcheck + +### 16. 前端错误监控 + +- **文件**: `frontend/src/api/client.ts` + 新增 `utils/error-reporter.ts` +- **修复**: 增加全局错误上报钩子 + +### 17. Worker 优雅重启 + +- **文件**: `backend/cmd/worker/main.go` +- **修复**: 替换 `log.Fatal` 为指数退避重试 + +--- + +## 实施顺序 + +``` +P1 (第1周) P2 (第2周) P3 (第3-4周) +──────────────────────────────────────────── +#1 Router Bug #5 rand.Read #11 HomeView 拆分 +#2 DB 索引 #6 错误码 #12 Feed 拆分 +#3 LIMIT #7 JWT #13 虚拟滚动 +#4 MQ 死信 #8 密码管理 #14 缓存版本化 + #9 路由守卫 #15 健康检查 + #10 pprof #16 错误监控 + #17 Worker 重启 +``` + +每批独立验证:`go test ./...` + `npm run build` + 冒烟测试。 From 5c54cc3f53f95d55bc4f128e4aac7b45b272b065 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 15:22:45 +0800 Subject: [PATCH 02/23] =?UTF-8?q?plan:=20=E5=85=A8=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=EF=BC=88?= =?UTF-8?q?17=E9=A1=B9=EF=BC=8C=E4=B8=89=E6=89=B9=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .sisyphus/plans/optimization-plan.md | 1014 ++++++++++++++++++++++++++ 1 file changed, 1014 insertions(+) create mode 100644 .sisyphus/plans/optimization-plan.md diff --git a/.sisyphus/plans/optimization-plan.md b/.sisyphus/plans/optimization-plan.md new file mode 100644 index 0000000..fe36818 --- /dev/null +++ b/.sisyphus/plans/optimization-plan.md @@ -0,0 +1,1014 @@ +# feedsystem_video_go 全项目优化实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. + +**Goal:** 修复 17 项代码审查发现的问题,涵盖安全性、数据库性能、MQ 可靠性、代码质量、前端架构,按三批渐进交付。 + +**Architecture:** 风险驱动分批 — P1 修复直接威胁稳定性的 Bug,P2 加固安全面和规范化,P3 前端拆分和服务瘦身。每批独立验证可发布。 + +**Tech Stack:** Go 1.24.5 + Gin + GORM + MySQL + Redis + RabbitMQ + Vue 3 + TypeScript + Pinia + +**参考设计文档:** `docs/plans/2025-04-25-optimization-design.md` + +--- + +## P1 止血(4项) + +--- + +### Task 1: Router 变量赋值 Bug + +**Files:** +- Modify: `backend/internal/http/router.go:145-148` + +**Step 1: 修复** + +```go +// 定位到 router.go 第 147 行 +timelineMQ, err := rabbitmq.NewTimelineMQ(rmq) +if err != nil { + log.Printf("timelineMQ init failed (mq disabled): %v", err) + socialMQ = nil // ❌ 当前 +``` + +改为: +```go + timelineMQ = nil // ✅ +``` + +**Step 2: 编译验证** + +```bash +cd backend && go build ./... +``` +Expected: 编译成功 (exit code 0) + +**Step 3: Commit** + +```bash +git add backend/internal/http/router.go +git commit -m "fix: router timelineMQ 初始化失败时误将 socialMQ 置空" +``` + +--- + +### Task 2: 数据库复合索引 + +**Files:** +- Modify: `backend/internal/video/video_entity.go:5-16` + +**Step 1: 修改 Video 模型** + +```go +type Video struct { + ID uint `gorm:"primaryKey" json:"id"` + AuthorID uint `gorm:"index;not null" json:"author_id"` + Username string `gorm:"type:varchar(255);not null" json:"username"` + Title string `gorm:"type:varchar(255);not null" json:"title"` + Description string `gorm:"type:varchar(255);" json:"description,omitempty"` + PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"` + CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"` + CreateTime time.Time `gorm:"autoCreateTime;index:idx_videos_create_time,sort:desc" json:"create_time"` + LikesCount int64 `gorm:"column:likes_count;not null;default:0;index:idx_videos_likes_count_id,priority:1,sort:desc" json:"likes_count"` + Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"` +} +``` + +**Step 2: 编译验证** + +```bash +cd backend && go build ./... +``` +Expected: 编译成功 + +**Step 3: 验证索引创建(启动时 AutoMigrate 自动执行)** + +启动后端,观察日志中无 MySQL 错误: +```bash +cd backend && CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd 2>&1 | head -20 +``` +Expected: 启动成功,GORM AutoMigrate 完成无报错 + +**Step 4: Commit** + +```bash +git add backend/internal/video/video_entity.go +git commit -m "perf: Video 表增加 Feed 流排序查询复合索引(create_time/likes_count/popularity)" +``` + +--- + +### Task 3: ListByAuthorID 加 LIMIT + +**Files:** +- Modify: `backend/internal/video/video_repo.go:39-48` + +**Step 1: 修改查询** + +```go +func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) { + var videos []Video + if err := vr.db.WithContext(ctx). + Where("author_id = ?", authorID). + Order("create_time desc"). + Limit(200). + Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} +``` + +**Step 2: 编译验证** + +```bash +cd backend && go build ./... +``` +Expected: 编译成功 + +**Step 3: Commit** + +```bash +git add backend/internal/video/video_repo.go +git commit -m "fix: ListByAuthorID 加 Limit(200) 防止海量数据内存溢出" +``` + +--- + +### Task 4: MQ Worker 死信队列 + 退避重试 + +**Files:** +- Modify: `backend/internal/middleware/rabbitmq/` — 队列声明增加 DLX 参数 +- Modify: `backend/internal/worker/likeworker.go` — handleDelivery 增加重试计数 +- Modify: `backend/internal/worker/commentworker.go` — 同上 +- Modify: `backend/internal/worker/socialworker.go` — 同上 +- Modify: `backend/internal/worker/popularityworker.go` — 同上 + +**Step 1: 在 rabbitmq 包中声明 DLX** + +在 `backend/internal/middleware/rabbitmq/` 中新增 `dlx.go`: + +```go +package rabbitmq + +import ( + "log" + amqp "github.com/rabbitmq/amqp091-go" +) + +const ( + DLXExchange = "dlx.events" + MaxRetryCount = 3 +) + +// DeclareDLX 声明死信交换和死信队列 +func DeclareDLX(ch *amqp.Channel, queueName string) error { + if err := ch.ExchangeDeclare( + DLXExchange, "topic", true, false, false, false, nil, + ); err != nil { + return err + } + dlxQueue := queueName + ".dlx" + _, err := ch.QueueDeclare( + dlxQueue, true, false, false, false, nil, + ) + if err != nil { + return err + } + if err := ch.QueueBind(dlxQueue, "#", DLXExchange, false, nil); err != nil { + return err + } + log.Printf("DLX declared: exchange=%s, queue=%s", DLXExchange, dlxQueue) + return nil +} + +// QueueArgsWithDLX 返回带 DLX 配置的队列参数 +func QueueArgsWithDLX() amqp.Table { + return amqp.Table{ + "x-dead-letter-exchange": DLXExchange, + "x-message-ttl": int32(60000), // 死信消息 60s 后移入 DLX 队列 + } +} + +// GetRetryCount 从 x-death header 中提取重试次数 +func GetRetryCount(d amqp.Delivery) int { + deaths, ok := d.Headers["x-death"].([]interface{}) + if !ok || len(deaths) == 0 { + return 0 + } + death, ok := deaths[0].(amqp.Table) + if !ok { + return 0 + } + count, ok := death["count"].(int64) + if !ok { + return 0 + } + return int(count) +} +``` + +**Step 2: 修改 Worker 的队列声明,传入 DLX 参数** + +以 LikeWorker 为例(其他 Worker 同理),修改 `likeworker.go` 中声明队列的地方。需要在每个 Worker 初始化时调用 `DeclareDLX`,并在 `QueueDeclare` 时传入 args。 + +在 `middleware/rabbitmq/` 中找到各 MQ 初始化函数(如 `NewLikeMQ`),修改队列声明加上 `QueueArgsWithDLX()`。 + +**Step 3: 修改 handleDelivery 增加重试上限** + +```go +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) // Ack 触发 DLX + return + } + log.Printf("like worker: failed to process message (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) + _ = d.Nack(false, true) + return + } + _ = d.Ack(false) +} +``` + +**Step 4: 编译验证** + +```bash +cd backend && go build ./... +``` +Expected: 编译成功 + +**Step 5: 功能验证** + +启动 Worker,观察日志: +- 处理成功 → Ack +- 处理失败 < 3 次 → Nack 重试 +- 处理失败 ≥ 3 次 → Ack(进入 DLX)+ 日志告警 + +**Step 6: Commit** + +```bash +git add backend/internal/middleware/rabbitmq/dlx.go backend/internal/worker/ +git commit -m "feat: MQ Worker 增加死信队列 — 重试上限 3 次后移入 DLX 并告警" +``` + +--- + +## P2 加固(6项) + +--- + +### Task 5: rand.Read 错误处理 + +**Files:** +- Modify: `backend/internal/video/video_handler.go:164-168` +- Modify: 调用 `randHex()` 的 `UploadVideo` 和 `UploadCover` 方法 + +**Step 1: 修改 randHex 返回 error** + +```go +func randHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand.Read: %w", err) + } + return hex.EncodeToString(b), nil +} +``` + +**Step 2: 修改调用方** + +在 `UploadVideo` (line 96) 和 `UploadCover` (line 148) 中: + +```go +filename, err := randHex(16) +if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"}) + return +} +filename = filename + ext +``` + +**Step 3: 编译验证** + +```bash +cd backend && go build ./... +``` +Expected: 编译成功 + +**Step 4: Commit** + +```bash +git add backend/internal/video/video_handler.go +git commit -m "fix: randHex 不再忽略 rand.Read 错误,防止弱随机文件名冲突" +``` + +--- + +### Task 6: Handler 错误码精确化 + +**Files:** +- Create: `backend/internal/http/errors.go` — 哨兵错误 + 分类函数 +- Modify: `backend/internal/video/video_service.go` — Service 返回哨兵错误 +- Modify: `backend/internal/video/like_service.go` — 同上 +- Modify: `backend/internal/video/video_handler.go` — Handler 使用 classifyHTTPStatus +- Modify: 其他 handler 文件同理 + +**Step 1: 创建哨兵错误和分类函数** + +```go +// backend/internal/http/errors.go +package http + +import ( + "errors" + "net/http" + + "gorm.io/gorm" +) + +var ( + ErrUnauthorized = errors.New("unauthorized") + ErrValidation = errors.New("validation error") +) + +func ClassifyHTTPStatus(err error) int { + switch { + case err == nil: + return http.StatusOK + case errors.Is(err, ErrUnauthorized): + return http.StatusUnauthorized + case errors.Is(err, ErrValidation): + return http.StatusBadRequest + case errors.Is(err, gorm.ErrRecordNotFound): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} +``` + +**Step 2: Service 层返回哨兵错误** + +示例 — `video_service.go` 中 `Delete` 方法: + +```go +if video.AuthorID != authorID { + return http.ErrUnauthorized +} +``` + +`Publish` 方法中的参数校验: + +```go +if video.Title == "" || video.PlayURL == "" || video.CoverURL == "" { + return http.ErrValidation +} +``` + +**Step 3: Handler 层使用** + +```go +// video_handler.go PublishVideo +if err := vh.service.Publish(c.Request.Context(), video); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return +} +``` + +注意 package 命名冲突:handler 文件在 `package video`,需要 import `httputil "feedsystem_video_go/internal/http"`。 + +**Step 4: 编译验证 + 测试** + +```bash +cd backend && go build ./... && go vet ./... +``` +Expected: 编译通过 + +**Step 5: Commit** + +```bash +git add backend/internal/http/errors.go backend/internal/video/video_handler.go backend/internal/video/video_service.go +git commit -m "refactor: Handler 错误码精确化 — 区分 400/401/404/500" +``` + +--- + +### Task 7: JWT Secret 弱默认值加固 + +**Files:** +- Modify: `backend/internal/auth/jwt.go:12-18` + +**Step 1: 修改 jwtSecret** + +```go +func jwtSecret() []byte { + secret := os.Getenv("JWT_SECRET") + if secret == "" { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + log.Printf("FATAL: cannot generate JWT secret: %v", err) + return []byte("fallback-unsafe-key-change-me") + } + secret = hex.EncodeToString(b) + log.Printf("WARNING: JWT_SECRET not set, generated random key. All tokens invalid on restart.") + } + return []byte(secret) +} +``` + +需要增加 import: `"crypto/rand"`, `"encoding/hex"`, `"log"` + +**Step 2: 编译验证** + +```bash +cd backend && go build ./... +``` + +**Step 3: Commit** + +```bash +git add backend/internal/auth/jwt.go +git commit -m "security: JWT Secret 未设环境变量时生成随机密钥而非使用弱默认值" +``` + +--- + +### Task 8: 配置密码集中管理 + +**Files:** +- Create: `.env.example` +- Modify: `docker-compose.yml` +- Modify: `.gitignore` — 确保 `.env` 被忽略 + +**Step 1: 创建 .env.example** + +```bash +# .env.example — 复制为 .env 后修改实际值 +MYSQL_ROOT_PASSWORD=123456 +MYSQL_DATABASE=feedsystem +REDIS_PASSWORD=123456 +RABBITMQ_USER=admin +RABBITMQ_PASS=password123 +JWT_SECRET=change-me-in-production +``` + +**Step 2: 修改 docker-compose.yml** + +```yaml +mysql: + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456} + MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem} + +redis: + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-123456}"] + +rabbitmq: + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-password123} +``` + +**Step 3: 确认 .gitignore 包含 .env** + +```bash +grep '\.env' .gitignore || echo '.env' >> .gitignore +``` + +**Step 4: Commit** + +```bash +git add .env.example docker-compose.yml .gitignore +git commit -m "security: 敏感配置迁移至 .env,docker-compose 引用环境变量" +``` + +--- + +### Task 9: 前端路由鉴权守卫 + +**Files:** +- Modify: `frontend/src/router/index.ts` + +**Step 1: 添加 beforeEach 守卫** + +```typescript +import { useAuthStore } from '../stores/auth' + +const router = createRouter({ + // ... 现有配置不变 +}) + +router.beforeEach((to, _from, next) => { + const auth = useAuthStore() + const authRequired = ['/settings', '/video'] + + if (authRequired.some(p => to.path.startsWith(p)) && !auth.isLoggedIn) { + next({ path: '/account', query: { redirect: to.fullPath } }) + return + } + next() +}) + +export default router +``` + +**Step 2: 构建验证** + +```bash +cd frontend && npm run build +``` +Expected: 构建成功 + +**Step 3: Commit** + +```bash +git add frontend/src/router/index.ts +git commit -m "feat: 前端路由鉴权守卫 — /settings 和 /video 需登录" +``` + +--- + +### Task 10: pprof 生产保护(确认) + +**Files:** 无需改动 + +**Step 1: 确认已有配置安全** + +```bash +grep -A5 'pprof' backend/configs/config.docker.yaml +``` +Expected: `enabled: true`(不对,需要确认是 `false`) + +确认 `config.docker.yaml` 中 pprof 已禁用或仅监听 `127.0.0.1`。 + +**Step 2: 如果未禁用则修改** + +```yaml +observability: + pprof: + enabled: false +``` + +**Step 3: Commit(如有改动)** + +```bash +git add backend/configs/config.docker.yaml +git commit -m "security: 确认 pprof 容器内部署时禁用" +``` + +--- + +## P3 优化(7项) + +--- + +### Task 11: HomeView.vue 拆分为 composable + 子组件 + +**Files:** +- Create: `frontend/src/composables/useVideoFeed.ts` +- Create: `frontend/src/composables/useVideoPlayer.ts` +- Create: `frontend/src/composables/useLikeFollow.ts` +- Create: `frontend/src/components/CommentDrawer.vue` +- Modify: `frontend/src/views/HomeView.vue`(精简至 ~350 行) + +**Step 1: 提取 useVideoFeed composable** + +```typescript +// composables/useVideoFeed.ts +import { reactive, ref } from 'vue' +import { ApiError } from '../api/client' +import * as feedApi from '../api/feed' +import type { FeedVideoItem } from '../api/types' + +export type TabKey = 'recommend' | 'hot' | 'following' + +export function useVideoFeed() { + const tab = ref('recommend') + + const recommend = reactive({ + items: [] as FeedVideoItem[], + loading: false, error: '', + hasMore: false, nextTime: 0, + }) + + const hot = reactive({ + items: [] as FeedVideoItem[], + loading: false, error: '', + hasMore: false, + nextLikesCountBefore: undefined as number | undefined, + nextIdBefore: undefined as number | undefined, + }) + + const following = reactive({ + items: [] as FeedVideoItem[], + loading: false, error: '', + hasMore: false, nextTime: 0, + }) + + // ... 复制原有 loadRecommend / loadHot / loadFollowing 逻辑 + // 各 load 函数保持原样 + + return { tab, recommend, hot, following, loadRecommend, loadHot, loadFollowing } +} +``` + +**Step 2: 提取 useVideoPlayer composable** + +```typescript +// composables/useVideoPlayer.ts +import { nextTick, ref } from 'vue' + +export function useVideoPlayer() { + const muted = ref(true) + const activeIndex = ref(0) + const videoMap = new Map() + + function setVideoRef(id: number, el: HTMLVideoElement | null) { /* ... */ } + function playActive() { /* ... */ } + function toggleMute() { /* ... */ } + function togglePlayPause() { /* ... */ } + + return { muted, activeIndex, videoMap, setVideoRef, playActive, toggleMute, togglePlayPause } +} +``` + +**Step 3: 提取 useLikeFollow composable** + +```typescript +// composables/useLikeFollow.ts +import { reactive } from 'vue' +import { ApiError } from '../api/client' +import * as likeApi from '../api/like' +import { useAuthStore } from '../stores/auth' +import { useSocialStore } from '../stores/social' +import { useToastStore } from '../stores/toast' +import type { FeedVideoItem } from '../api/types' + +export function useLikeFollow() { + const likeBusy = reactive>({}) + const followBusy = reactive>({}) + + async function toggleLike(item: FeedVideoItem) { /* ... */ } + async function toggleFollow(authorId: number) { /* ... */ } + function share(item: FeedVideoItem) { /* ... */ } + + return { likeBusy, followBusy, toggleLike, toggleFollow, share } +} +``` + +**Step 4: 提取 CommentDrawer.vue 组件** + +将原 HomeView.vue 中 drawer 相关的 state + 模板 + 样式提取为独立组件。 + +**Step 5: 精简 HomeView.vue** + +```vue + +``` + +**Step 6: 构建验证** + +```bash +cd frontend && npm run build +``` +Expected: 构建成功,类型检查通过 + +**Step 7: Commit** + +```bash +git add frontend/src/composables/ frontend/src/components/CommentDrawer.vue frontend/src/views/HomeView.vue +git commit -m "refactor: HomeView 拆分为 3 个 composable + CommentDrawer 组件" +``` + +--- + +### Task 12: Feed Service 策略拆分 + +**Files:** +- Create: `backend/internal/feed/strategy_latest.go` +- Create: `backend/internal/feed/strategy_follow.go` +- Create: `backend/internal/feed/strategy_hot.go` +- Create: `backend/internal/feed/build_feed.go` +- Modify: `backend/internal/feed/service.go`(精简入口) + +**Step 1: 拆分 strategy_latest.go** + +将原 `service.go` 中 `ListLatest` 方法完整移入,包含 ZSET 热冷分离逻辑。 + +**Step 2: 拆分 strategy_follow.go** + +将 `ListByFollowing` 方法完整移入,包含 Redis 缓存穿透防护逻辑。 + +**Step 3: 拆分 strategy_hot.go** + +将 `ListByPopularity` 方法完整移入,包含快照合并 + 降级逻辑。 + +**Step 4: 拆分 build_feed.go** + +将 `buildFeedVideos` 和 `buildOrderedResult` 移入。 + +**Step 5: 精简 service.go** + +```go +type FeedService struct { + repo *FeedRepository + likeRepo *video.LikeRepository + rediscache *rediscache.Client + localcache *cache.Cache + cacheTTL time.Duration + requestGroup singleflight.Group +} + +func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore time.Time, viewerAccountID uint) (ListLatestResponse, error) { + return listLatestStrategy(ctx, f, limit, latestBefore, viewerAccountID) +} +``` + +**Step 6: 编译验证 + 运行测试** + +```bash +cd backend && go build ./... && go test ./... +``` + +**Step 7: Commit** + +```bash +git add backend/internal/feed/ +git commit -m "refactor: Feed Service 按查询策略拆分为 4 个文件" +``` + +--- + +### Task 13: 视频列表虚拟滚动 + +**Files:** +- Modify: `frontend/src/views/HomeView.vue` + +**Step 1: 替换 v-for 为虚拟化渲染** + +在模板中,将: +```html +
+``` +改为只渲染 `visibleRange` 内的 item,其余用占位 div。使用 `v-show` 控制显隐而非 `v-if`(保留 video 实例)。 + +```typescript +const visibleRange = computed(() => { + const idx = activeIndex.value + const len = filteredItems.value.length + return { + start: Math.max(0, idx - 1), + end: Math.min(len - 1, idx + 1), + } +}) +``` + +模板中: +```html +
+``` + +**Step 2: 离屏视频 pause** + +在 `playActive` 中,pause 所有不在 visibleRange 内的视频。 + +**Step 3: 构建验证** + +```bash +cd frontend && npm run build +``` + +**Step 4: Commit** + +```bash +git add frontend/src/views/HomeView.vue +git commit -m "perf: Feed 流虚拟滚动 — 仅渲染当前±1条视频 DOM" +``` + +--- + +### Task 14: 缓存键版本化 + +**Files:** +- Modify: `backend/internal/middleware/redis/redis.go` +- Modify: 所有使用 Redis 键的 service 文件(account, video, feed, social) + +**Step 1: 在 Client 增加 keyPrefix** + +```go +type Client struct { + rdb *redis.Client + keyPrefix string +} + +func (c *Client) Key(format string, args ...any) string { + return c.keyPrefix + fmt.Sprintf(format, args...) +} +``` + +在 `NewFromEnv` 中从 config 读入 `keyPrefix`(默认 `"v1:"`)。 + +**Step 2: 替换所有硬编码键** + +- `"feed:global_timeline"` → `c.Key("feed:global_timeline")` +- `"video:detail:id=%d"` → `c.Key("video:detail:id=%d", id)` (注意:Key 内部做 Sprintf) +- 等等... + +**Step 3: 编译验证 + 测试** + +```bash +cd backend && go build ./... && go test ./... +``` + +**Step 4: Commit** + +```bash +git add backend/internal/middleware/redis/redis.go backend/internal/ +git commit -m "refactor: Redis 缓存键增加版本前缀支持(默认 v1:)" +``` + +--- + +### Task 15: Docker 健康检查 + +**Files:** +- Modify: `docker-compose.yml` + +**Step 1: 增加 backend healthcheck** + +```yaml +backend: + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/account/findByID -d '{}' --header='Content-Type: application/json' || exit 1"] + interval: 10s + timeout: 5s + retries: 3 +``` + +**Step 2: worker healthcheck** + +```yaml +worker: + healthcheck: + test: ["CMD-SHELL", "pgrep worker || exit 1"] + interval: 15s + timeout: 5s + retries: 3 +``` + +**Step 3: frontend healthcheck** + +```yaml +frontend: + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"] + interval: 10s + timeout: 5s + retries: 3 +``` + +**Step 4: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat: docker-compose 增加 backend/worker/frontend 健康检查" +``` + +--- + +### Task 16: 前端错误监控 + +**Files:** +- Create: `frontend/src/utils/error-reporter.ts` +- Modify: `frontend/src/api/client.ts` +- Modify: `frontend/src/main.ts` + +**Step 1: 创建 error-reporter** + +```typescript +// utils/error-reporter.ts +export function reportError(error: Error, context?: Record) { + if (import.meta.env.DEV) { + console.error('[ErrorReporter]', error.message, context) + return + } + // 生产环境发送到日志服务 + fetch('/api/error-report', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: error.message, + stack: error.stack, + context, + timestamp: new Date().toISOString(), + }), + }).catch(() => { /* 静默失败 */ }) +} +``` + +**Step 2: 在 client.ts 中集成** + +在 `ApiError` 抛出前调用 `reportError`。 + +**Step 3: 在 main.ts 中注册全局错误处理器** + +```typescript +app.config.errorHandler = (err, _instance, info) => { + reportError(err instanceof Error ? err : new Error(String(err)), { info }) +} +``` + +**Step 4: 构建验证** + +```bash +cd frontend && npm run build +``` + +**Step 5: Commit** + +```bash +git add frontend/src/utils/error-reporter.ts frontend/src/api/client.ts frontend/src/main.ts +git commit -m "feat: 前端全局错误监控 — 开发 console,生产上报 API" +``` + +--- + +### Task 17: Worker 优雅重启 + +**Files:** +- Modify: `backend/cmd/worker/main.go` + +**Step 1: 增加连接重试** + +```go +func connectWithRetry(name string, fn func() error, maxRetries int) { + for i := 0; i < maxRetries; i++ { + if err := fn(); err == nil { + return + } + wait := time.Duration(math.Min(float64(1< Date: Sat, 25 Apr 2026 15:29:47 +0800 Subject: [PATCH 03/23] =?UTF-8?q?fix(P1):=20Router=20Bug=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20Video=20=E5=A4=8D=E5=90=88=E7=B4=A2=E5=BC=95=20?= =?UTF-8?q?+=20ListByAuthorID=20=E5=8A=A0=20LIMIT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/http/router.go | 304 ++++++++++++------------- backend/internal/video/video_entity.go | 97 ++++---- backend/internal/video/video_repo.go | 208 ++++++++--------- 3 files changed, 305 insertions(+), 304 deletions(-) diff --git a/backend/internal/http/router.go b/backend/internal/http/router.go index 73b73d3..483752c 100644 --- a/backend/internal/http/router.go +++ b/backend/internal/http/router.go @@ -1,152 +1,152 @@ -package http - -import ( - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/feed" - "feedsystem_video_go/internal/middleware/jwt" - "feedsystem_video_go/internal/middleware/ratelimit" - "feedsystem_video_go/internal/middleware/rabbitmq" - rediscache "feedsystem_video_go/internal/middleware/redis" - "feedsystem_video_go/internal/social" - "feedsystem_video_go/internal/video" - "feedsystem_video_go/internal/worker" - "log" - "time" - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *gin.Engine { - r := gin.Default() - if err := r.SetTrustedProxies(nil); err != nil { - log.Printf("SetTrustedProxies failed: %v", err) - } - r.Static("/static", "./.run/uploads") - // rate_limit - loginLimiter := ratelimit.Limit(cache, "account_login", 10, time.Minute, ratelimit.KeyByIP) - registerLimiter := ratelimit.Limit(cache, "account_register", 5, time.Hour, ratelimit.KeyByIP) - - likeLimiter := ratelimit.Limit(cache, "like_write", 30, time.Minute, ratelimit.KeyByAccount) - commentLimiter := ratelimit.Limit(cache, "comment_write", 10, time.Minute, ratelimit.KeyByAccount) - socialLimiter := ratelimit.Limit(cache, "social_write", 20, time.Minute, ratelimit.KeyByAccount) - - // account - accountRepository := account.NewAccountRepository(db) - accountService := account.NewAccountService(accountRepository, cache) - accountHandler := account.NewAccountHandler(accountService) - accountGroup := r.Group("/account") - { - accountGroup.POST("/register", registerLimiter, accountHandler.CreateAccount) - accountGroup.POST("/login", loginLimiter, accountHandler.Login) - accountGroup.POST("/changePassword", accountHandler.ChangePassword) - accountGroup.POST("/findByID", accountHandler.FindByID) - accountGroup.POST("/findByUsername", accountHandler.FindByUsername) - } - protectedAccountGroup := accountGroup.Group("") - protectedAccountGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedAccountGroup.POST("/logout", accountHandler.Logout) - protectedAccountGroup.POST("/rename", accountHandler.Rename) - } - // video - videoRepository := video.NewVideoRepository(db) - popularityMQ, err := rabbitmq.NewPopularityMQ(rmq) - if err != nil { - log.Printf("PopularityMQ init failed (mq disabled): %v", err) - popularityMQ = nil - } - videoService := video.NewVideoService(videoRepository, cache, popularityMQ) - videoHandler := video.NewVideoHandler(videoService, accountService) - videoGroup := r.Group("/video") - { - videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID) - videoGroup.POST("/getDetail", videoHandler.GetDetail) - } - protectedVideoGroup := videoGroup.Group("") - protectedVideoGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedVideoGroup.POST("/uploadVideo", videoHandler.UploadVideo) - protectedVideoGroup.POST("/uploadCover", videoHandler.UploadCover) - protectedVideoGroup.POST("/publish", videoHandler.PublishVideo) - } - // like - likeMQ, err := rabbitmq.NewLikeMQ(rmq) - if err != nil { - log.Printf("LikeMQ init failed (mq disabled): %v", err) - likeMQ = nil - } - likeRepository := video.NewLikeRepository(db) - likeService := video.NewLikeService(likeRepository, videoRepository, cache, likeMQ, popularityMQ) - likeHandler := video.NewLikeHandler(likeService) - likeGroup := r.Group("/like") - protectedLikeGroup := likeGroup.Group("") - protectedLikeGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedLikeGroup.POST("/like", likeLimiter, likeHandler.Like) - protectedLikeGroup.POST("/unlike", likeLimiter, likeHandler.Unlike) - protectedLikeGroup.POST("/isLiked", likeHandler.IsLiked) - protectedLikeGroup.POST("/listMyLikedVideos", likeHandler.ListMyLikedVideos) - } - // comment - commentRepository := video.NewCommentRepository(db) - commentMQ, err := rabbitmq.NewCommentMQ(rmq) - if err != nil { - log.Printf("CommentMQ init failed (mq disabled): %v", err) - commentMQ = nil - } - commentService := video.NewCommentService(commentRepository, videoRepository, cache, commentMQ, popularityMQ) - commentHandler := video.NewCommentHandler(commentService, accountService) - commentGroup := r.Group("/comment") - { - commentGroup.POST("/listAll", commentHandler.GetAllComments) - } - protectedCommentGroup := commentGroup.Group("") - protectedCommentGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedCommentGroup.POST("/publish", commentLimiter, commentHandler.PublishComment) - protectedCommentGroup.POST("/delete", commentLimiter, commentHandler.DeleteComment) - } - // social - socialMQ, err := rabbitmq.NewSocialMQ(rmq) - if err != nil { - log.Printf("SocialMQ init failed (mq disabled): %v", err) - socialMQ = nil - } - socialRepository := social.NewSocialRepository(db) - socialService := social.NewSocialService(socialRepository, accountRepository, socialMQ) - socialHandler := social.NewSocialHandler(socialService) - socialGroup := r.Group("/social") - protectedSocialGroup := socialGroup.Group("") - protectedSocialGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedSocialGroup.POST("/follow", socialLimiter, socialHandler.Follow) - protectedSocialGroup.POST("/unfollow", socialLimiter, socialHandler.Unfollow) - protectedSocialGroup.POST("/getAllFollowers", socialHandler.GetAllFollowers) - protectedSocialGroup.POST("/getAllVloggers", socialHandler.GetAllVloggers) - } - // feed - feedRepository := feed.NewFeedRepository(db) - feedService := feed.NewFeedService(feedRepository, likeRepository, cache) - feedHandler := feed.NewFeedHandler(feedService) - feedGroup := r.Group("/feed") - feedGroup.Use(jwt.SoftJWTAuth(accountRepository, cache)) - { - feedGroup.POST("/listLatest", feedHandler.ListLatest) - feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount) - feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity) - } - protectedFeedGroup := feedGroup.Group("") - protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache)) - { - protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing) - } - //worker - timelineMQ, err := rabbitmq.NewTimelineMQ(rmq) - if err != nil { - log.Printf("timelineMQ init failed (mq disabled): %v", err) - socialMQ = nil - } - worker.StartOutboxPoller(db, timelineMQ) - worker.StartConsumer(timelineMQ, "video.timeline.update.queue", cache) - return r -} +package http + +import ( + "feedsystem_video_go/internal/account" + "feedsystem_video_go/internal/feed" + "feedsystem_video_go/internal/middleware/jwt" + "feedsystem_video_go/internal/middleware/ratelimit" + "feedsystem_video_go/internal/middleware/rabbitmq" + rediscache "feedsystem_video_go/internal/middleware/redis" + "feedsystem_video_go/internal/social" + "feedsystem_video_go/internal/video" + "feedsystem_video_go/internal/worker" + "log" + "time" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *gin.Engine { + r := gin.Default() + if err := r.SetTrustedProxies(nil); err != nil { + log.Printf("SetTrustedProxies failed: %v", err) + } + r.Static("/static", "./.run/uploads") + // rate_limit + loginLimiter := ratelimit.Limit(cache, "account_login", 10, time.Minute, ratelimit.KeyByIP) + registerLimiter := ratelimit.Limit(cache, "account_register", 5, time.Hour, ratelimit.KeyByIP) + + likeLimiter := ratelimit.Limit(cache, "like_write", 30, time.Minute, ratelimit.KeyByAccount) + commentLimiter := ratelimit.Limit(cache, "comment_write", 10, time.Minute, ratelimit.KeyByAccount) + socialLimiter := ratelimit.Limit(cache, "social_write", 20, time.Minute, ratelimit.KeyByAccount) + + // account + accountRepository := account.NewAccountRepository(db) + accountService := account.NewAccountService(accountRepository, cache) + accountHandler := account.NewAccountHandler(accountService) + accountGroup := r.Group("/account") + { + accountGroup.POST("/register", registerLimiter, accountHandler.CreateAccount) + accountGroup.POST("/login", loginLimiter, accountHandler.Login) + accountGroup.POST("/changePassword", accountHandler.ChangePassword) + accountGroup.POST("/findByID", accountHandler.FindByID) + accountGroup.POST("/findByUsername", accountHandler.FindByUsername) + } + protectedAccountGroup := accountGroup.Group("") + protectedAccountGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedAccountGroup.POST("/logout", accountHandler.Logout) + protectedAccountGroup.POST("/rename", accountHandler.Rename) + } + // video + videoRepository := video.NewVideoRepository(db) + popularityMQ, err := rabbitmq.NewPopularityMQ(rmq) + if err != nil { + log.Printf("PopularityMQ init failed (mq disabled): %v", err) + popularityMQ = nil + } + videoService := video.NewVideoService(videoRepository, cache, popularityMQ) + videoHandler := video.NewVideoHandler(videoService, accountService) + videoGroup := r.Group("/video") + { + videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID) + videoGroup.POST("/getDetail", videoHandler.GetDetail) + } + protectedVideoGroup := videoGroup.Group("") + protectedVideoGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedVideoGroup.POST("/uploadVideo", videoHandler.UploadVideo) + protectedVideoGroup.POST("/uploadCover", videoHandler.UploadCover) + protectedVideoGroup.POST("/publish", videoHandler.PublishVideo) + } + // like + likeMQ, err := rabbitmq.NewLikeMQ(rmq) + if err != nil { + log.Printf("LikeMQ init failed (mq disabled): %v", err) + likeMQ = nil + } + likeRepository := video.NewLikeRepository(db) + likeService := video.NewLikeService(likeRepository, videoRepository, cache, likeMQ, popularityMQ) + likeHandler := video.NewLikeHandler(likeService) + likeGroup := r.Group("/like") + protectedLikeGroup := likeGroup.Group("") + protectedLikeGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedLikeGroup.POST("/like", likeLimiter, likeHandler.Like) + protectedLikeGroup.POST("/unlike", likeLimiter, likeHandler.Unlike) + protectedLikeGroup.POST("/isLiked", likeHandler.IsLiked) + protectedLikeGroup.POST("/listMyLikedVideos", likeHandler.ListMyLikedVideos) + } + // comment + commentRepository := video.NewCommentRepository(db) + commentMQ, err := rabbitmq.NewCommentMQ(rmq) + if err != nil { + log.Printf("CommentMQ init failed (mq disabled): %v", err) + commentMQ = nil + } + commentService := video.NewCommentService(commentRepository, videoRepository, cache, commentMQ, popularityMQ) + commentHandler := video.NewCommentHandler(commentService, accountService) + commentGroup := r.Group("/comment") + { + commentGroup.POST("/listAll", commentHandler.GetAllComments) + } + protectedCommentGroup := commentGroup.Group("") + protectedCommentGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedCommentGroup.POST("/publish", commentLimiter, commentHandler.PublishComment) + protectedCommentGroup.POST("/delete", commentLimiter, commentHandler.DeleteComment) + } + // social + socialMQ, err := rabbitmq.NewSocialMQ(rmq) + if err != nil { + log.Printf("SocialMQ init failed (mq disabled): %v", err) + socialMQ = nil + } + socialRepository := social.NewSocialRepository(db) + socialService := social.NewSocialService(socialRepository, accountRepository, socialMQ) + socialHandler := social.NewSocialHandler(socialService) + socialGroup := r.Group("/social") + protectedSocialGroup := socialGroup.Group("") + protectedSocialGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedSocialGroup.POST("/follow", socialLimiter, socialHandler.Follow) + protectedSocialGroup.POST("/unfollow", socialLimiter, socialHandler.Unfollow) + protectedSocialGroup.POST("/getAllFollowers", socialHandler.GetAllFollowers) + protectedSocialGroup.POST("/getAllVloggers", socialHandler.GetAllVloggers) + } + // feed + feedRepository := feed.NewFeedRepository(db) + feedService := feed.NewFeedService(feedRepository, likeRepository, cache) + feedHandler := feed.NewFeedHandler(feedService) + feedGroup := r.Group("/feed") + feedGroup.Use(jwt.SoftJWTAuth(accountRepository, cache)) + { + feedGroup.POST("/listLatest", feedHandler.ListLatest) + feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount) + feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity) + } + protectedFeedGroup := feedGroup.Group("") + protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache)) + { + protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing) + } + //worker + timelineMQ, err := rabbitmq.NewTimelineMQ(rmq) + if err != nil { + log.Printf("timelineMQ init failed (mq disabled): %v", err) + timelineMQ = nil + } + worker.StartOutboxPoller(db, timelineMQ) + worker.StartConsumer(timelineMQ, "video.timeline.update.queue", cache) + return r +} diff --git a/backend/internal/video/video_entity.go b/backend/internal/video/video_entity.go index 9487427..67bf022 100644 --- a/backend/internal/video/video_entity.go +++ b/backend/internal/video/video_entity.go @@ -1,48 +1,49 @@ -package video - -import "time" - -type Video struct { - ID uint `gorm:"primaryKey" json:"id"` - AuthorID uint `gorm:"index;not null" json:"author_id"` - Username string `gorm:"type:varchar(255);not null" json:"username"` - Title string `gorm:"type:varchar(255);not null" json:"title"` - Description string `gorm:"type:varchar(255);" json:"description,omitempty"` - PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"` - CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"` - CreateTime time.Time `gorm:"autoCreateTime" json:"create_time"` - LikesCount int64 `gorm:"column:likes_count;not null;default:0" json:"likes_count"` - Popularity int64 `gorm:"column:popularity;not null;default:0" json:"popularity"` -} - -type PublishVideoRequest struct { - Title string `json:"title"` - Description string `json:"description"` - PlayURL string `json:"play_url"` - CoverURL string `json:"cover_url"` -} - -type DeleteVideoRequest struct { - ID uint `json:"id"` -} - -type ListByAuthorIDRequest struct { - AuthorID uint `json:"author_id"` -} - -type GetDetailRequest struct { - ID uint `json:"id"` -} - -type UpdateLikesCountRequest struct { - ID uint `json:"id"` - LikesCount int64 `json:"likes_count"` -} - -type OutboxMsg struct { - ID uint `gorm:"primaryKey"` - VideoID uint `gorm:"index"` - EventType string `gorm:"type:varchar(50)"` - CreateTime time.Time `gorm:"autoCreateTime"` - Status string `gorm:"type:varchar(50);index"` -} +package video + +import "time" + +type Video struct { + ID uint `gorm:"primaryKey" json:"id"` + AuthorID uint `gorm:"index;not null" json:"author_id"` + Username string `gorm:"type:varchar(255);not null" json:"username"` + Title string `gorm:"type:varchar(255);not null" json:"title"` + Description string `gorm:"type:varchar(255);" json:"description,omitempty"` + PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"` + CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"` + CreateTime time.Time `gorm:"autoCreateTime;index:idx_videos_create_time,sort:desc;index:idx_videos_popularity_time_id,priority:2,sort:desc" json:"create_time"` + LikesCount int64 `gorm:"column:likes_count;not null;default:0;index:idx_videos_likes_count_id,priority:1,sort:desc" json:"likes_count"` + Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"` +} +} + +type PublishVideoRequest struct { + Title string `json:"title"` + Description string `json:"description"` + PlayURL string `json:"play_url"` + CoverURL string `json:"cover_url"` +} + +type DeleteVideoRequest struct { + ID uint `json:"id"` +} + +type ListByAuthorIDRequest struct { + AuthorID uint `json:"author_id"` +} + +type GetDetailRequest struct { + ID uint `json:"id"` +} + +type UpdateLikesCountRequest struct { + ID uint `json:"id"` + LikesCount int64 `json:"likes_count"` +} + +type OutboxMsg struct { + ID uint `gorm:"primaryKey"` + VideoID uint `gorm:"index"` + EventType string `gorm:"type:varchar(50)"` + CreateTime time.Time `gorm:"autoCreateTime"` + Status string `gorm:"type:varchar(50);index"` +} diff --git a/backend/internal/video/video_repo.go b/backend/internal/video/video_repo.go index d8ee81a..2cc5597 100644 --- a/backend/internal/video/video_repo.go +++ b/backend/internal/video/video_repo.go @@ -1,104 +1,104 @@ -package video - -import ( - "context" - "errors" - - "gorm.io/gorm" -) - -type VideoRepository struct { - db *gorm.DB -} - -func NewVideoRepository(db *gorm.DB) *VideoRepository { - return &VideoRepository{db: db} -} - -func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error { - if err := vr.db.WithContext(ctx).Create(video).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) CreateMsg(ctx context.Context, Msg *OutboxMsg) error { - if err := vr.db.WithContext(ctx).Create(Msg).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error { - if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) { - var videos []Video - if err := vr.db.WithContext(ctx). - Where("author_id = ?", authorID). - Order("create_time desc"). - Offset(0). - Find(&videos).Error; err != nil { - return nil, err - } - return videos, nil -} - -func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) { - var video Video - if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil { - return (*Video)(nil), err - } - return &video, nil -} - -func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error { - if err := vr.db.WithContext(ctx).Model(&Video{}). - Where("id = ?", id). - Update("likes_count", likesCount).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) { - var video Video - if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, err - } - return true, nil -} - -func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error { - if err := vr.db.WithContext(ctx).Model(&Video{}). - Where("id = ?", id). - Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) ChangeLikesCount(ctx context.Context, id uint, change int64) error { - if err := vr.db.WithContext(ctx).Model(&Video{}). - Where("id = ?", id). - UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count + ?, 0)", change)).Error; err != nil { - return err - } - return nil -} - -func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change int64) error { - if err := vr.db.WithContext(ctx).Model(&Video{}). - Where("id = ?", id). - UpdateColumn("popularity", gorm.Expr("GREATEST(popularity + ?, 0)", change)).Error; err != nil { - return err - } - return nil -} +package video + +import ( + "context" + "errors" + + "gorm.io/gorm" +) + +type VideoRepository struct { + db *gorm.DB +} + +func NewVideoRepository(db *gorm.DB) *VideoRepository { + return &VideoRepository{db: db} +} + +func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error { + if err := vr.db.WithContext(ctx).Create(video).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) CreateMsg(ctx context.Context, Msg *OutboxMsg) error { + if err := vr.db.WithContext(ctx).Create(Msg).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error { + if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) { + var videos []Video + if err := vr.db.WithContext(ctx). + Where("author_id = ?", authorID). + Order("create_time desc"). + Limit(200). + Find(&videos).Error; err != nil { + return nil, err + } + return videos, nil +} + +func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) { + var video Video + if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil { + return (*Video)(nil), err + } + return &video, nil +} + +func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error { + if err := vr.db.WithContext(ctx).Model(&Video{}). + Where("id = ?", id). + Update("likes_count", likesCount).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) { + var video Video + if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return true, nil +} + +func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error { + if err := vr.db.WithContext(ctx).Model(&Video{}). + Where("id = ?", id). + Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) ChangeLikesCount(ctx context.Context, id uint, change int64) error { + if err := vr.db.WithContext(ctx).Model(&Video{}). + Where("id = ?", id). + UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count + ?, 0)", change)).Error; err != nil { + return err + } + return nil +} + +func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change int64) error { + if err := vr.db.WithContext(ctx).Model(&Video{}). + Where("id = ?", id). + UpdateColumn("popularity", gorm.Expr("GREATEST(popularity + ?, 0)", change)).Error; err != nil { + return err + } + return nil +} From 9d903ad8e1a519101c8851faf938869c28d5e7b5 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 15:54:55 +0800 Subject: [PATCH 04/23] =?UTF-8?q?feat(P1):=20MQ=20Worker=20=E6=AD=BB?= =?UTF-8?q?=E4=BF=A1=E9=98=9F=E5=88=97=20=E2=80=94=20=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E4=B8=8A=E9=99=903=E6=AC=A1=E5=90=8E=20Ack=20=E7=A7=BB?= =?UTF-8?q?=E5=85=A5=20DLX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/middleware/rabbitmq/dlx.go | 53 ++++ .../internal/middleware/rabbitmq/rabbitMQ.go | 239 +++++++-------- backend/internal/worker/commentworker.go | 250 ++++++++-------- backend/internal/worker/likeworker.go | 278 +++++++++--------- backend/internal/worker/popularityworker.go | 164 ++++++----- backend/internal/worker/socialworker.go | 207 ++++++------- 6 files changed, 637 insertions(+), 554 deletions(-) create mode 100644 backend/internal/middleware/rabbitmq/dlx.go diff --git a/backend/internal/middleware/rabbitmq/dlx.go b/backend/internal/middleware/rabbitmq/dlx.go new file mode 100644 index 0000000..82efe5b --- /dev/null +++ b/backend/internal/middleware/rabbitmq/dlx.go @@ -0,0 +1,53 @@ +package rabbitmq + +import ( + "log" + + amqp "github.com/rabbitmq/amqp091-go" +) + +const ( + DLXExchange = "dlx.events" + MaxRetryCount = 3 +) + +// DeclareDLX 声明死信交换机和对应的死信队列 +func DeclareDLX(ch *amqp.Channel, queueName string) error { + if ch == nil { + return nil + } + if err := ch.ExchangeDeclare( + DLXExchange, "topic", true, false, false, false, nil, + ); err != nil { + return err + } + dlxQueue := queueName + ".dlx" + _, err := ch.QueueDeclare( + dlxQueue, true, false, false, false, nil, + ) + if err != nil { + return err + } + if err := ch.QueueBind(dlxQueue, "#", DLXExchange, false, nil); err != nil { + return err + } + log.Printf("DLX ready: exchange=%s queue=%s", DLXExchange, dlxQueue) + return nil +} + +// GetRetryCount 从 AMQP x-death header 中提取当前消息已被重试的次数 +func GetRetryCount(d amqp.Delivery) int { + deaths, ok := d.Headers["x-death"].([]interface{}) + if !ok || len(deaths) == 0 { + return 0 + } + death, ok := deaths[0].(amqp.Table) + if !ok { + return 0 + } + count, ok := death["count"].(int64) + if !ok { + return 0 + } + return int(count) +} diff --git a/backend/internal/middleware/rabbitmq/rabbitMQ.go b/backend/internal/middleware/rabbitmq/rabbitMQ.go index b4a715e..e527ea1 100644 --- a/backend/internal/middleware/rabbitmq/rabbitMQ.go +++ b/backend/internal/middleware/rabbitmq/rabbitMQ.go @@ -1,116 +1,123 @@ -package rabbitmq - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "feedsystem_video_go/internal/config" - "strconv" - "time" - - amqp "github.com/rabbitmq/amqp091-go" -) - -type RabbitMQ struct { - Conn *amqp.Connection - Ch *amqp.Channel -} - -func NewRabbitMQ(cfg *config.RabbitMQConfig) (*RabbitMQ, error) { - if cfg == nil { - return nil, errors.New("rabbitmq config is nil") - } - url := "amqp://" + cfg.Username + ":" + cfg.Password + "@" + cfg.Host + ":" + strconv.Itoa(cfg.Port) + "/" - conn, err := amqp.Dial(url) - if err != nil { - return nil, err - } - ch, err := conn.Channel() - if err != nil { - return nil, err - } - return &RabbitMQ{Conn: conn, Ch: ch}, nil -} - -func (r *RabbitMQ) Close() error { - if r == nil || r.Ch == nil || r.Conn == nil { - return nil - } - if err := r.Ch.Close(); err != nil { - return err - } - if err := r.Conn.Close(); err != nil { - return err - } - 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") - } - if exchange == "" || queue == "" || bindingKey == "" { - return errors.New("exchange/queue/bindingKey is required") - } - - if err := r.Ch.ExchangeDeclare( - exchange, - "topic", - true, - false, - false, - false, - nil, - ); err != nil { - return err - } - - q, err := r.Ch.QueueDeclare( - queue, - true, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - return r.Ch.QueueBind( - q.Name, - bindingKey, - exchange, - false, - 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") - } - if exchange == "" || routingKey == "" { - return errors.New("exchange and routingKey are required") - } - b, err := json.Marshal(payload) - 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, - }) -} - -func newEventID(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} +package rabbitmq + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "feedsystem_video_go/internal/config" + "log" + "strconv" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type RabbitMQ struct { + Conn *amqp.Connection + Ch *amqp.Channel +} + +func NewRabbitMQ(cfg *config.RabbitMQConfig) (*RabbitMQ, error) { + if cfg == nil { + return nil, errors.New("rabbitmq config is nil") + } + url := "amqp://" + cfg.Username + ":" + cfg.Password + "@" + cfg.Host + ":" + strconv.Itoa(cfg.Port) + "/" + conn, err := amqp.Dial(url) + if err != nil { + return nil, err + } + ch, err := conn.Channel() + if err != nil { + return nil, err + } + return &RabbitMQ{Conn: conn, Ch: ch}, nil +} + +func (r *RabbitMQ) Close() error { + if r == nil || r.Ch == nil || r.Conn == nil { + return nil + } + if err := r.Ch.Close(); err != nil { + return err + } + if err := r.Conn.Close(); err != nil { + return err + } + 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") + } + if exchange == "" || queue == "" || bindingKey == "" { + return errors.New("exchange/queue/bindingKey is required") + } + + if err := r.Ch.ExchangeDeclare( + exchange, + "topic", + true, + false, + false, + false, + nil, + ); err != nil { + return err + } + + q, err := r.Ch.QueueDeclare( + queue, + true, + false, + false, + false, + amqp.Table{"x-dead-letter-exchange": DLXExchange}, + ) + if err != nil { + return err + } + + if err := r.Ch.QueueBind( + q.Name, + bindingKey, + exchange, + false, + nil, + ); err != nil { + return err + } + if err := DeclareDLX(r.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") + } + if exchange == "" || routingKey == "" { + return errors.New("exchange and routingKey are required") + } + b, err := json.Marshal(payload) + 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, + }) +} + +func newEventID(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/backend/internal/worker/commentworker.go b/backend/internal/worker/commentworker.go index 6b941b3..3fdde7f 100644 --- a/backend/internal/worker/commentworker.go +++ b/backend/internal/worker/commentworker.go @@ -1,122 +1,128 @@ -package worker - -import ( - "context" - "encoding/json" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - "feedsystem_video_go/internal/video" - "log" - "strings" - - amqp "github.com/rabbitmq/amqp091-go" -) - -type CommentWorker struct { - ch *amqp.Channel - comments *video.CommentRepository - videos *video.VideoRepository - queue string -} - -func NewCommentWorker(ch *amqp.Channel, comments *video.CommentRepository, videos *video.VideoRepository, queue string) *CommentWorker { - return &CommentWorker{ch: ch, comments: comments, videos: videos, queue: queue} -} - -func (w *CommentWorker) Run(ctx context.Context) error { - if w == nil || w.ch == nil || w.comments == nil || w.videos == nil { - return errors.New("comment worker is not initialized") - } - if w.queue == "" { - return errors.New("queue is required") - } - - deliveries, err := w.ch.Consume( - w.queue, - "", - false, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case d, ok := <-deliveries: - if !ok { - return errors.New("deliveries channel closed") - } - w.handleDelivery(ctx, d) - } - } -} - -func (w *CommentWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { - if err := w.process(ctx, d.Body); err != nil { - log.Printf("comment worker: failed to process message: %v", err) - _ = d.Nack(false, true) - return - } - _ = d.Ack(false) -} - -func (w *CommentWorker) process(ctx context.Context, body []byte) error { - var evt rabbitmq.CommentEvent - if err := json.Unmarshal(body, &evt); err != nil { - return nil - } - switch evt.Action { - case "publish": - return w.applyPublish(ctx, &evt) - case "delete": - return w.applyDelete(ctx, &evt) - default: - return nil - } -} - -func (w *CommentWorker) applyPublish(ctx context.Context, evt *rabbitmq.CommentEvent) error { - if evt == nil || evt.VideoID == 0 || evt.AuthorID == 0 || strings.TrimSpace(evt.Content) == "" { - return nil - } - - ok, err := w.videos.IsExist(ctx, evt.VideoID) - if err != nil { - return err - } - if !ok { - return nil - } - - c := &video.Comment{ - Username: strings.TrimSpace(evt.Username), - VideoID: evt.VideoID, - AuthorID: evt.AuthorID, - Content: strings.TrimSpace(evt.Content), - } - if err := w.comments.CreateComment(ctx, c); err != nil { - return err - } - return w.videos.ChangePopularity(ctx, evt.VideoID, 1) -} - -func (w *CommentWorker) applyDelete(ctx context.Context, evt *rabbitmq.CommentEvent) error { - if evt == nil || evt.CommentID == 0 { - return nil - } - c, err := w.comments.GetByID(ctx, evt.CommentID) - if err != nil { - return err - } - if c == nil { - return nil - } - return w.comments.DeleteComment(ctx, c) -} - +package worker + +import ( + "context" + "encoding/json" + "errors" + "feedsystem_video_go/internal/middleware/rabbitmq" + "feedsystem_video_go/internal/video" + "log" + "strings" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type CommentWorker struct { + ch *amqp.Channel + comments *video.CommentRepository + videos *video.VideoRepository + queue string +} + +func NewCommentWorker(ch *amqp.Channel, comments *video.CommentRepository, videos *video.VideoRepository, queue string) *CommentWorker { + return &CommentWorker{ch: ch, comments: comments, videos: videos, queue: queue} +} + +func (w *CommentWorker) Run(ctx context.Context) error { + if w == nil || w.ch == nil || w.comments == nil || w.videos == nil { + return errors.New("comment worker is not initialized") + } + if w.queue == "" { + return errors.New("queue is required") + } + + deliveries, err := w.ch.Consume( + w.queue, + "", + false, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case d, ok := <-deliveries: + if !ok { + return errors.New("deliveries channel closed") + } + w.handleDelivery(ctx, d) + } + } +} + +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) + return + } + log.Printf("comment worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) + _ = d.Nack(false, true) + return + } + _ = d.Ack(false) +} + +func (w *CommentWorker) process(ctx context.Context, body []byte) error { + var evt rabbitmq.CommentEvent + if err := json.Unmarshal(body, &evt); err != nil { + return nil + } + switch evt.Action { + case "publish": + return w.applyPublish(ctx, &evt) + case "delete": + return w.applyDelete(ctx, &evt) + default: + return nil + } +} + +func (w *CommentWorker) applyPublish(ctx context.Context, evt *rabbitmq.CommentEvent) error { + if evt == nil || evt.VideoID == 0 || evt.AuthorID == 0 || strings.TrimSpace(evt.Content) == "" { + return nil + } + + ok, err := w.videos.IsExist(ctx, evt.VideoID) + if err != nil { + return err + } + if !ok { + return nil + } + + c := &video.Comment{ + Username: strings.TrimSpace(evt.Username), + VideoID: evt.VideoID, + AuthorID: evt.AuthorID, + Content: strings.TrimSpace(evt.Content), + } + if err := w.comments.CreateComment(ctx, c); err != nil { + return err + } + return w.videos.ChangePopularity(ctx, evt.VideoID, 1) +} + +func (w *CommentWorker) applyDelete(ctx context.Context, evt *rabbitmq.CommentEvent) error { + if evt == nil || evt.CommentID == 0 { + return nil + } + c, err := w.comments.GetByID(ctx, evt.CommentID) + if err != nil { + return err + } + if c == nil { + return nil + } + return w.comments.DeleteComment(ctx, c) +} + diff --git a/backend/internal/worker/likeworker.go b/backend/internal/worker/likeworker.go index 25a9c6a..ae60369 100644 --- a/backend/internal/worker/likeworker.go +++ b/backend/internal/worker/likeworker.go @@ -1,136 +1,142 @@ -package worker - -import ( - "context" - "encoding/json" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - "feedsystem_video_go/internal/video" - "log" - amqp "github.com/rabbitmq/amqp091-go" - "time" -) - -type LikeWorker struct { - ch *amqp.Channel - likes *video.LikeRepository - videos *video.VideoRepository - queue string -} - -func NewLikeWorker(ch *amqp.Channel, likes *video.LikeRepository, videos *video.VideoRepository, queue string) *LikeWorker { - return &LikeWorker{ch: ch, likes: likes, videos: videos, queue: queue} -} - -func (w *LikeWorker) Run(ctx context.Context) error { - if w == nil || w.ch == nil || w.likes == nil || w.videos == nil { - return errors.New("like worker is not initialized") - } - if w.queue == "" { - return errors.New("queue is required") - } - - deliveries, err := w.ch.Consume( - w.queue, - "", - false, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case d, ok := <-deliveries: - if !ok { - return errors.New("deliveries channel closed") - } - w.handleDelivery(ctx, d) - } - } -} - -func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { - if err := w.process(ctx, d.Body); err != nil { - log.Printf("like worker: failed to process message: %v", err) - _ = d.Nack(false, true) - return - } - _ = d.Ack(false) -} - -func (w *LikeWorker) process(ctx context.Context, body []byte) error { - var evt rabbitmq.LikeEvent - if err := json.Unmarshal(body, &evt); err != nil { - // 解析事件失败,直接丢弃 - return nil - } - if evt.UserID == 0 || evt.VideoID == 0 { - return nil - } - - switch evt.Action { - case "like": - return w.applyLike(ctx, evt.UserID, evt.VideoID) - case "unlike": - return w.applyUnlike(ctx, evt.UserID, evt.VideoID) - default: - return nil - } -} - -func (w *LikeWorker) applyLike(ctx context.Context, userID, videoID uint) error { - ok, err := w.videos.IsExist(ctx, videoID) - if err != nil { - return err - } - if !ok { - return nil - } - - created, err := w.likes.LikeIgnoreDuplicate(ctx, &video.Like{ - VideoID: videoID, - AccountID: userID, - CreatedAt: time.Now(), - }) - if err != nil { - return err - } - if !created { - return nil - } - - if err := w.videos.ChangeLikesCount(ctx, videoID, 1); err != nil { - return err - } - return w.videos.ChangePopularity(ctx, videoID, 1) -} - -func (w *LikeWorker) applyUnlike(ctx context.Context, userID, videoID uint) error { - ok, err := w.videos.IsExist(ctx, videoID) - if err != nil { - return err - } - if !ok { - return nil - } - - deleted, err := w.likes.DeleteByVideoAndAccount(ctx, videoID, userID) - if err != nil { - return err - } - if !deleted { - return nil - } - - if err := w.videos.ChangeLikesCount(ctx, videoID, -1); err != nil { - return err - } - return w.videos.ChangePopularity(ctx, videoID, -1) -} +package worker + +import ( + "context" + "encoding/json" + "errors" + "feedsystem_video_go/internal/middleware/rabbitmq" + "feedsystem_video_go/internal/video" + "log" + amqp "github.com/rabbitmq/amqp091-go" + "time" +) + +type LikeWorker struct { + ch *amqp.Channel + likes *video.LikeRepository + videos *video.VideoRepository + queue string +} + +func NewLikeWorker(ch *amqp.Channel, likes *video.LikeRepository, videos *video.VideoRepository, queue string) *LikeWorker { + return &LikeWorker{ch: ch, likes: likes, videos: videos, queue: queue} +} + +func (w *LikeWorker) Run(ctx context.Context) error { + if w == nil || w.ch == nil || w.likes == nil || w.videos == nil { + return errors.New("like worker is not initialized") + } + if w.queue == "" { + return errors.New("queue is required") + } + + deliveries, err := w.ch.Consume( + w.queue, + "", + false, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case d, ok := <-deliveries: + if !ok { + return errors.New("deliveries channel closed") + } + w.handleDelivery(ctx, d) + } + } +} + +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) + return + } + log.Printf("like worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) + _ = d.Nack(false, true) + return + } + _ = d.Ack(false) +} + +func (w *LikeWorker) process(ctx context.Context, body []byte) error { + var evt rabbitmq.LikeEvent + if err := json.Unmarshal(body, &evt); err != nil { + // 解析事件失败,直接丢弃 + return nil + } + if evt.UserID == 0 || evt.VideoID == 0 { + return nil + } + + switch evt.Action { + case "like": + return w.applyLike(ctx, evt.UserID, evt.VideoID) + case "unlike": + return w.applyUnlike(ctx, evt.UserID, evt.VideoID) + default: + return nil + } +} + +func (w *LikeWorker) applyLike(ctx context.Context, userID, videoID uint) error { + ok, err := w.videos.IsExist(ctx, videoID) + if err != nil { + return err + } + if !ok { + return nil + } + + created, err := w.likes.LikeIgnoreDuplicate(ctx, &video.Like{ + VideoID: videoID, + AccountID: userID, + CreatedAt: time.Now(), + }) + if err != nil { + return err + } + if !created { + return nil + } + + if err := w.videos.ChangeLikesCount(ctx, videoID, 1); err != nil { + return err + } + return w.videos.ChangePopularity(ctx, videoID, 1) +} + +func (w *LikeWorker) applyUnlike(ctx context.Context, userID, videoID uint) error { + ok, err := w.videos.IsExist(ctx, videoID) + if err != nil { + return err + } + if !ok { + return nil + } + + deleted, err := w.likes.DeleteByVideoAndAccount(ctx, videoID, userID) + if err != nil { + return err + } + if !deleted { + return nil + } + + if err := w.videos.ChangeLikesCount(ctx, videoID, -1); err != nil { + return err + } + return w.videos.ChangePopularity(ctx, videoID, -1) +} diff --git a/backend/internal/worker/popularityworker.go b/backend/internal/worker/popularityworker.go index 8184425..8f1fbaf 100644 --- a/backend/internal/worker/popularityworker.go +++ b/backend/internal/worker/popularityworker.go @@ -1,79 +1,85 @@ -package worker - -import ( - "context" - "encoding/json" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - rediscache "feedsystem_video_go/internal/middleware/redis" - "feedsystem_video_go/internal/video" - "log" - - amqp "github.com/rabbitmq/amqp091-go" -) - -type PopularityWorker struct { - ch *amqp.Channel - cache *rediscache.Client - queue string -} - -func NewPopularityWorker(ch *amqp.Channel, cache *rediscache.Client, queue string) *PopularityWorker { - return &PopularityWorker{ch: ch, cache: cache, queue: queue} -} - -func (w *PopularityWorker) Run(ctx context.Context) error { - if w == nil || w.ch == nil || w.cache == nil { - return errors.New("popularity worker is not initialized") - } - if w.queue == "" { - return errors.New("queue is required") - } - - deliveries, err := w.ch.Consume( - w.queue, - "", - false, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case d, ok := <-deliveries: - if !ok { - return errors.New("deliveries channel closed") - } - w.handleDelivery(ctx, d) - } - } -} - -func (w *PopularityWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { - if err := w.process(ctx, d.Body); err != nil { - log.Printf("popularity worker: failed to process message: %v", err) - _ = d.Nack(false, true) - return - } - _ = d.Ack(false) -} - -func (w *PopularityWorker) process(ctx context.Context, body []byte) error { - var evt rabbitmq.PopularityEvent - if err := json.Unmarshal(body, &evt); err != nil { - return nil - } - if evt.VideoID == 0 || evt.Change == 0 { - return nil - } - video.UpdatePopularityCache(ctx, w.cache, evt.VideoID, evt.Change) - return nil -} - +package worker + +import ( + "context" + "encoding/json" + "errors" + "feedsystem_video_go/internal/middleware/rabbitmq" + rediscache "feedsystem_video_go/internal/middleware/redis" + "feedsystem_video_go/internal/video" + "log" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type PopularityWorker struct { + ch *amqp.Channel + cache *rediscache.Client + queue string +} + +func NewPopularityWorker(ch *amqp.Channel, cache *rediscache.Client, queue string) *PopularityWorker { + return &PopularityWorker{ch: ch, cache: cache, queue: queue} +} + +func (w *PopularityWorker) Run(ctx context.Context) error { + if w == nil || w.ch == nil || w.cache == nil { + return errors.New("popularity worker is not initialized") + } + if w.queue == "" { + return errors.New("queue is required") + } + + deliveries, err := w.ch.Consume( + w.queue, + "", + false, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case d, ok := <-deliveries: + if !ok { + return errors.New("deliveries channel closed") + } + w.handleDelivery(ctx, d) + } + } +} + +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) + return + } + log.Printf("popularity worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) + _ = d.Nack(false, true) + return + } + _ = d.Ack(false) +} + +func (w *PopularityWorker) process(ctx context.Context, body []byte) error { + var evt rabbitmq.PopularityEvent + if err := json.Unmarshal(body, &evt); err != nil { + return nil + } + if evt.VideoID == 0 || evt.Change == 0 { + return nil + } + video.UpdatePopularityCache(ctx, w.cache, evt.VideoID, evt.Change) + return nil +} + diff --git a/backend/internal/worker/socialworker.go b/backend/internal/worker/socialworker.go index 58c975a..2aff6ac 100644 --- a/backend/internal/worker/socialworker.go +++ b/backend/internal/worker/socialworker.go @@ -1,101 +1,106 @@ -package worker - -import ( - "context" - "encoding/json" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - "feedsystem_video_go/internal/social" - "log" - - "github.com/go-sql-driver/mysql" - amqp "github.com/rabbitmq/amqp091-go" -) - -type SocialWorker struct { - ch *amqp.Channel - repo *social.SocialRepository - queue string -} - -func NewSocialWorker(ch *amqp.Channel, repo *social.SocialRepository, queue string) *SocialWorker { - return &SocialWorker{ch: ch, repo: repo, queue: queue} -} - -func (w *SocialWorker) Run(ctx context.Context) error { - if w == nil || w.ch == nil || w.repo == nil { - return errors.New("social worker is not initialized") - } - if w.queue == "" { - return errors.New("queue is required") - } - - deliveries, err := w.ch.Consume( - w.queue, - "", - false, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case d, ok := <-deliveries: - if !ok { - return errors.New("deliveries channel closed") - } - w.handleDelivery(ctx, d) - } - } -} - -func (w *SocialWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { - if err := w.process(ctx, d.Body); err != nil { - log.Printf("social worker: failed to process message: %v", err) - // 重新入队,稍后重试 - _ = d.Nack(false, true) - return - } - _ = d.Ack(false) -} - -func (w *SocialWorker) process(ctx context.Context, body []byte) error { - var evt rabbitmq.SocialEvent - if err := json.Unmarshal(body, &evt); err != nil { - // 解析事件失败,直接丢弃 - return nil - } - if evt.FollowerID == 0 || evt.VloggerID == 0 { - return nil - } - - switch evt.Action { - case "follow": - err := w.repo.Follow(ctx, &social.Social{ - FollowerID: evt.FollowerID, - VloggerID: evt.VloggerID, - }) - if err == nil { - return nil - } - var mysqlErr *mysql.MySQLError - if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { - return nil - } - return err - case "unfollow": - return w.repo.Unfollow(ctx, &social.Social{ - FollowerID: evt.FollowerID, - VloggerID: evt.VloggerID, - }) - default: - return nil - } -} +package worker + +import ( + "context" + "encoding/json" + "errors" + "feedsystem_video_go/internal/middleware/rabbitmq" + "feedsystem_video_go/internal/social" + "log" + + "github.com/go-sql-driver/mysql" + amqp "github.com/rabbitmq/amqp091-go" +) + +type SocialWorker struct { + ch *amqp.Channel + repo *social.SocialRepository + queue string +} + +func NewSocialWorker(ch *amqp.Channel, repo *social.SocialRepository, queue string) *SocialWorker { + return &SocialWorker{ch: ch, repo: repo, queue: queue} +} + +func (w *SocialWorker) Run(ctx context.Context) error { + if w == nil || w.ch == nil || w.repo == nil { + return errors.New("social worker is not initialized") + } + if w.queue == "" { + return errors.New("queue is required") + } + + deliveries, err := w.ch.Consume( + w.queue, + "", + false, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case d, ok := <-deliveries: + if !ok { + return errors.New("deliveries channel closed") + } + w.handleDelivery(ctx, d) + } + } +} + +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) + return + } + log.Printf("social worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err) + _ = d.Nack(false, true) + return + } + _ = d.Ack(false) +} + +func (w *SocialWorker) process(ctx context.Context, body []byte) error { + var evt rabbitmq.SocialEvent + if err := json.Unmarshal(body, &evt); err != nil { + // 解析事件失败,直接丢弃 + return nil + } + if evt.FollowerID == 0 || evt.VloggerID == 0 { + return nil + } + + switch evt.Action { + case "follow": + err := w.repo.Follow(ctx, &social.Social{ + FollowerID: evt.FollowerID, + VloggerID: evt.VloggerID, + }) + if err == nil { + return nil + } + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + return nil + } + return err + case "unfollow": + return w.repo.Unfollow(ctx, &social.Social{ + FollowerID: evt.FollowerID, + VloggerID: evt.VloggerID, + }) + default: + return nil + } +} From 7d022620982d4fda5c1547c78f96224906b30fb6 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 15:57:15 +0800 Subject: [PATCH 05/23] =?UTF-8?q?fix(P2):=20rand.Read=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=20+=20JWT=E9=9A=8F=E6=9C=BA=E5=AF=86?= =?UTF-8?q?=E9=92=A5=20+=20=E5=AF=86=E7=A0=81=E7=8E=AF=E5=A2=83=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E5=8C=96=20+=20=E5=89=8D=E7=AB=AF=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 17 + backend/internal/auth/jwt.go | 139 +++---- backend/internal/video/video_handler.go | 494 ++++++++++++------------ docker-compose.yml | 212 +++++----- frontend/src/router/index.ts | 68 ++-- 5 files changed, 489 insertions(+), 441 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f1a8bcd --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# feedsystem_video_go 环境变量模板 +# 复制此文件为 .env 后修改实际值 +# .env 已被 .gitignore 忽略,不会提交到仓库 + +# MySQL +MYSQL_ROOT_PASSWORD=123456 +MYSQL_DATABASE=feedsystem + +# Redis +REDIS_PASSWORD=123456 + +# RabbitMQ +RABBITMQ_USER=admin +RABBITMQ_PASS=password123 + +# JWT (生产环境务必修改为随机强密钥) +JWT_SECRET=change-me-in-production diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index e7e4737..956a2e0 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -1,65 +1,74 @@ -// internal/auth/jwt.go -package auth - -import ( - "errors" - "os" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -func jwtSecret() []byte { - secret := os.Getenv("JWT_SECRET") - if secret == "" { - secret = "change-me-in-env" - } - return []byte(secret) -} - -type Claims struct { - AccountID uint `json:"account_id"` - Username string `json:"username"` - jwt.RegisteredClaims -} - -func GenerateToken(accountID uint, username string) (string, error) { - now := time.Now() - - claims := Claims{ - AccountID: accountID, - Username: username, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)), - IssuedAt: jwt.NewNumericDate(now), - NotBefore: jwt.NewNumericDate(now), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - return token.SignedString(jwtSecret()) -} - -func ParseToken(tokenString string) (*Claims, error) { - token, err := jwt.ParseWithClaims( - tokenString, - &Claims{}, - func(token *jwt.Token) (interface{}, error) { - if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() { - return nil, errors.New("unexpected signing method") - } - return jwtSecret(), nil - }, - ) - if err != nil { - return nil, err - } - - claims, ok := token.Claims.(*Claims) - if !ok || !token.Valid { - return nil, jwt.ErrTokenInvalidClaims - } - - return claims, nil -} +// internal/auth/jwt.go +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "log" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func jwtSecret() []byte { + secret := os.Getenv("JWT_SECRET") + if secret == "" { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + log.Printf("FATAL: cannot generate JWT secret: %v", err) + return []byte("fallback-unsafe-key-change-me") + } + secret = hex.EncodeToString(b) + log.Printf("WARNING: JWT_SECRET not set, generated random key. All tokens invalid on restart.") + } + return []byte(secret) +} + +type Claims struct { + AccountID uint `json:"account_id"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +func GenerateToken(accountID uint, username string) (string, error) { + now := time.Now() + + claims := Claims{ + AccountID: accountID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + return token.SignedString(jwtSecret()) +} + +func ParseToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims( + tokenString, + &Claims{}, + func(token *jwt.Token) (interface{}, error) { + if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() { + return nil, errors.New("unexpected signing method") + } + return jwtSecret(), nil + }, + ) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, jwt.ErrTokenInvalidClaims + } + + return claims, nil +} diff --git a/backend/internal/video/video_handler.go b/backend/internal/video/video_handler.go index 3fd7576..8319698 100644 --- a/backend/internal/video/video_handler.go +++ b/backend/internal/video/video_handler.go @@ -1,241 +1,253 @@ -package video - -import ( - "crypto/rand" - "encoding/hex" - "fmt" - "net/http" - "os" - "path" - "path/filepath" - "strings" - "time" - - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/middleware/jwt" - - "github.com/gin-gonic/gin" -) - -type VideoHandler struct { - service *VideoService - accountService *account.AccountService -} - -func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler { - return &VideoHandler{service: service, accountService: accountService} -} - -func (vh *VideoHandler) PublishVideo(c *gin.Context) { - var req PublishVideoRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - username, err := jwt.GetUsername(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - video := &Video{ - AuthorID: authorId, - Username: username, - Title: req.Title, - Description: req.Description, - PlayURL: req.PlayURL, - CoverURL: req.CoverURL, - CreateTime: time.Now(), - } - if err := vh.service.Publish(c.Request.Context(), video); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - c.JSON(200, video) -} - -func (vh *VideoHandler) UploadVideo(c *gin.Context) { - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - f, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) - return - } - - const maxSize = 200 << 20 - if f.Size <= 0 || f.Size > maxSize { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) - return - } - - ext := strings.ToLower(filepath.Ext(f.Filename)) - if ext != ".mp4" { - c.JSON(http.StatusBadRequest, gin.H{"error": "only .mp4 is allowed"}) - return - } - - date := time.Now().Format("20060102") - relDir := filepath.Join("videos", fmt.Sprintf("%d", authorId), date) - root := filepath.Join(".run", "uploads") - absDir := filepath.Join(root, relDir) - if err := os.MkdirAll(absDir, 0o755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - filename := randHex(16) + ext - absPath := filepath.Join(absDir, filename) - - if err := c.SaveUploadedFile(f, absPath); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - urlPath := path.Join("/static", "videos", fmt.Sprintf("%d", authorId), date, filename) - - c.JSON(http.StatusOK, gin.H{ - "url": buildAbsoluteURL(c, urlPath), - "play_url": buildAbsoluteURL(c, urlPath), - }) -} - -func (vh *VideoHandler) UploadCover(c *gin.Context) { - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - f, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) - return - } - - const maxSize = 10 << 20 - if f.Size <= 0 || f.Size > maxSize { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) - return - } - - ext := strings.ToLower(filepath.Ext(f.Filename)) - switch ext { - case ".jpg", ".jpeg", ".png", ".webp": - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp is allowed"}) - return - } - - date := time.Now().Format("20060102") - relDir := filepath.Join("covers", fmt.Sprintf("%d", authorId), date) - root := filepath.Join(".run", "uploads") - absDir := filepath.Join(root, relDir) - if err := os.MkdirAll(absDir, 0o755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - filename := randHex(16) + ext - absPath := filepath.Join(absDir, filename) - - if err := c.SaveUploadedFile(f, absPath); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - urlPath := path.Join("/static", "covers", fmt.Sprintf("%d", authorId), date, filename) - - c.JSON(http.StatusOK, gin.H{ - "url": buildAbsoluteURL(c, urlPath), - "cover_url": buildAbsoluteURL(c, urlPath), - }) -} - -func randHex(n int) string { - b := make([]byte, n) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - -func buildAbsoluteURL(c *gin.Context, p string) string { - scheme := "http" - if c.Request.TLS != nil { - scheme = "https" - } - if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" { - scheme = xf - } - return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p) -} - -func (vh *VideoHandler) DeleteVideo(c *gin.Context) { - var req DeleteVideoRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "video deleted"}) -} - -func (vh *VideoHandler) ListByAuthorID(c *gin.Context) { - var req ListByAuthorIDRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if videos == nil { - videos = []Video{} - } - c.JSON(200, videos) -} - -func (vh *VideoHandler) GetDetail(c *gin.Context) { - var req GetDetailRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - video, err := vh.service.GetDetail(c.Request.Context(), req.ID) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - c.JSON(200, video) -} - -func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) { - var req UpdateLikesCountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "likes count updated"}) -} +package video + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" + + "feedsystem_video_go/internal/account" + "feedsystem_video_go/internal/middleware/jwt" + + "github.com/gin-gonic/gin" +) + +type VideoHandler struct { + service *VideoService + accountService *account.AccountService +} + +func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler { + return &VideoHandler{service: service, accountService: accountService} +} + +func (vh *VideoHandler) PublishVideo(c *gin.Context) { + var req PublishVideoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + username, err := jwt.GetUsername(c) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + video := &Video{ + AuthorID: authorId, + Username: username, + Title: req.Title, + Description: req.Description, + PlayURL: req.PlayURL, + CoverURL: req.CoverURL, + CreateTime: time.Now(), + } + if err := vh.service.Publish(c.Request.Context(), video); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + c.JSON(200, video) +} + +func (vh *VideoHandler) UploadVideo(c *gin.Context) { + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + f, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) + return + } + + const maxSize = 200 << 20 + if f.Size <= 0 || f.Size > maxSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) + return + } + + ext := strings.ToLower(filepath.Ext(f.Filename)) + if ext != ".mp4" { + c.JSON(http.StatusBadRequest, gin.H{"error": "only .mp4 is allowed"}) + return + } + + date := time.Now().Format("20060102") + relDir := filepath.Join("videos", fmt.Sprintf("%d", authorId), date) + root := filepath.Join(".run", "uploads") + absDir := filepath.Join(root, relDir) + if err := os.MkdirAll(absDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + filename, err := randHex(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"}) + return + } + filename = filename + ext + absPath := filepath.Join(absDir, filename) + + if err := c.SaveUploadedFile(f, absPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + urlPath := path.Join("/static", "videos", fmt.Sprintf("%d", authorId), date, filename) + + c.JSON(http.StatusOK, gin.H{ + "url": buildAbsoluteURL(c, urlPath), + "play_url": buildAbsoluteURL(c, urlPath), + }) +} + +func (vh *VideoHandler) UploadCover(c *gin.Context) { + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + f, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) + return + } + + const maxSize = 10 << 20 + if f.Size <= 0 || f.Size > maxSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"}) + return + } + + ext := strings.ToLower(filepath.Ext(f.Filename)) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp": + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp is allowed"}) + return + } + + date := time.Now().Format("20060102") + relDir := filepath.Join("covers", fmt.Sprintf("%d", authorId), date) + root := filepath.Join(".run", "uploads") + absDir := filepath.Join(root, relDir) + if err := os.MkdirAll(absDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + filename, err := randHex(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"}) + return + } + filename = filename + ext + absPath := filepath.Join(absDir, filename) + + if err := c.SaveUploadedFile(f, absPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + urlPath := path.Join("/static", "covers", fmt.Sprintf("%d", authorId), date, filename) + + c.JSON(http.StatusOK, gin.H{ + "url": buildAbsoluteURL(c, urlPath), + "cover_url": buildAbsoluteURL(c, urlPath), + }) +} + +func randHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand.Read: %w", err) + } + return hex.EncodeToString(b), nil +} + +func buildAbsoluteURL(c *gin.Context, p string) string { + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" { + scheme = xf + } + return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p) +} + +func (vh *VideoHandler) DeleteVideo(c *gin.Context) { + var req DeleteVideoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "video deleted"}) +} + +func (vh *VideoHandler) ListByAuthorID(c *gin.Context) { + var req ListByAuthorIDRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if videos == nil { + videos = []Video{} + } + c.JSON(200, videos) +} + +func (vh *VideoHandler) GetDetail(c *gin.Context) { + var req GetDetailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + video, err := vh.service.GetDetail(c.Request.Context(), req.ID) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + c.JSON(200, video) +} + +func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) { + var req UpdateLikesCountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "likes count updated"}) +} diff --git a/docker-compose.yml b/docker-compose.yml index 9eb9464..3145017 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,106 +1,106 @@ -version: '3.8' - -services: - mysql: - image: mysql:8.0 - restart: always - environment: - MYSQL_ROOT_PASSWORD: "123456" - MYSQL_DATABASE: "feedsystem" - TZ: "Asia/Shanghai" - ports: - - "3307:3306" - 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 -p123456 --silent"] - interval: 5s - timeout: 5s - retries: 20 - - redis: - image: redis:7-alpine - restart: always - command: ["redis-server", "--appendonly", "yes", "--requirepass", "123456"] - ports: - - "6379:6379" - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "-a", "123456", "ping"] - interval: 5s - timeout: 3s - retries: 20 - - rabbitmq: - image: rabbitmq:3-management - restart: always - ports: - - "5672:5672" - - "15672:15672" - environment: - RABBITMQ_DEFAULT_USER: admin - RABBITMQ_DEFAULT_PASS: password123 - 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 - restart: always - ports: - - "8080:8080" - volumes: - - ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro - - backend_uploads:/app/.run/uploads - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - rabbitmq: - condition: service_healthy - - worker: - build: - context: . - dockerfile: backend/Dockerfile - target: worker - restart: always - volumes: - - ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - rabbitmq: - condition: service_healthy - - frontend: - build: - context: . - dockerfile: frontend/Dockerfile - restart: always - ports: - - "5173:80" - depends_on: - - backend - -volumes: - mysql_data: - redis_data: - rabbitmq_data: - backend_uploads: - +version: '3.8' + +services: + mysql: + image: mysql:8.0 + restart: always + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456} + MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem} + TZ: "Asia/Shanghai" + ports: + - "3307:3306" + 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 -p123456 --silent"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + restart: always + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-123456}"] + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "123456", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + rabbitmq: + image: rabbitmq:3-management + restart: always + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-password123} + 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 + restart: always + ports: + - "8080:8080" + volumes: + - ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro + - backend_uploads:/app/.run/uploads + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + + worker: + build: + context: . + dockerfile: backend/Dockerfile + target: worker + restart: always + volumes: + - ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + restart: always + ports: + - "5173:80" + depends_on: + - backend + +volumes: + mysql_data: + redis_data: + rabbitmq_data: + backend_uploads: + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6f53031..20905d5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,29 +1,39 @@ -import { createRouter, createWebHistory } from 'vue-router' - -import HomeView from '../views/HomeView.vue' -import HotView from '../views/HotView.vue' -import VideoView from '../views/VideoView.vue' -import VideoDetailView from '../views/VideoDetailView.vue' -import AccountView from '../views/AccountView.vue' -import ChangePasswordView from '../views/ChangePasswordView.vue' -import RegisterView from '../views/RegisterView.vue' -import SettingsView from '../views/SettingsView.vue' -import UserProfileView from '../views/UserProfileView.vue' - -const router = createRouter({ - history: createWebHistory(), - routes: [ - { path: '/', name: 'home', component: HomeView }, - { path: '/feed', redirect: '/' }, - { path: '/hot', name: 'hot', component: HotView }, - { path: '/video', name: 'video', component: VideoView }, - { path: '/video/:id', name: 'video-detail', component: VideoDetailView, props: true }, - { path: '/account', name: 'account', component: AccountView }, - { path: '/account/register', name: 'account-register', component: RegisterView }, - { path: '/account/change-password', name: 'account-change-password', component: ChangePasswordView }, - { path: '/settings', name: 'settings', component: SettingsView }, - { path: '/u/:id', name: 'user-profile', component: UserProfileView, props: true }, - ], -}) - -export default router +import { createRouter, createWebHistory } from 'vue-router' + +import HomeView from '../views/HomeView.vue' +import HotView from '../views/HotView.vue' +import VideoView from '../views/VideoView.vue' +import VideoDetailView from '../views/VideoDetailView.vue' +import AccountView from '../views/AccountView.vue' +import ChangePasswordView from '../views/ChangePasswordView.vue' +import RegisterView from '../views/RegisterView.vue' +import SettingsView from '../views/SettingsView.vue' +import UserProfileView from '../views/UserProfileView.vue' +import { useAuthStore } from '../stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'home', component: HomeView }, + { path: '/feed', redirect: '/' }, + { path: '/hot', name: 'hot', component: HotView }, + { path: '/video', name: 'video', component: VideoView, meta: { requiresAuth: true } }, + { path: '/video/:id', name: 'video-detail', component: VideoDetailView, props: true }, + { path: '/account', name: 'account', component: AccountView }, + { path: '/account/register', name: 'account-register', component: RegisterView }, + { path: '/account/change-password', name: 'account-change-password', component: ChangePasswordView }, + { path: '/settings', name: 'settings', component: SettingsView, meta: { requiresAuth: true } }, + { path: '/u/:id', name: 'user-profile', component: UserProfileView, props: true }, + ], +}) + +router.beforeEach((to, _from, next) => { + const auth = useAuthStore() + if (to.meta.requiresAuth && !auth.isLoggedIn) { + next({ path: '/account', query: { redirect: to.fullPath } }) + return + } + next() +}) + +export default router From 025be6dd78c7d9b96c3a95d44e87604cf180051e Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 16:03:29 +0800 Subject: [PATCH 06/23] =?UTF-8?q?refactor(P2):=20Handler=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=A0=81=E7=B2=BE=E7=A1=AE=E5=8C=96=20=E2=80=94=20ser?= =?UTF-8?q?vice=20=E5=B1=82=E5=BC=95=E5=85=A5=E5=93=A8=E5=85=B5=E9=94=99?= =?UTF-8?q?=E8=AF=AF=EF=BC=8Chandler=20=E5=B1=82=E7=94=A8=20ClassifyHTTPSt?= =?UTF-8?q?atus=20=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/account/handler.go | 286 +++++++------- backend/internal/feed/handler.go | 351 ++++++++--------- backend/internal/http/errors.go | 28 ++ backend/internal/social/handler.go | 251 ++++++------ backend/internal/video/comment_handler.go | 195 +++++----- backend/internal/video/comment_service.go | 233 ++++++------ backend/internal/video/like_handler.go | 227 +++++------ backend/internal/video/video_handler.go | 27 +- backend/internal/video/video_service.go | 441 +++++++++++----------- 9 files changed, 1038 insertions(+), 1001 deletions(-) create mode 100644 backend/internal/http/errors.go diff --git a/backend/internal/account/handler.go b/backend/internal/account/handler.go index 84df5b3..a72b87e 100644 --- a/backend/internal/account/handler.go +++ b/backend/internal/account/handler.go @@ -1,142 +1,144 @@ -package account - -import ( - "errors" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -type AccountHandler struct { - accountService *AccountService -} - -func NewAccountHandler(accountService *AccountService) *AccountHandler { - return &AccountHandler{accountService: accountService} -} -func (h *AccountHandler) CreateAccount(c *gin.Context) { - var req CreateAccountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if err := h.accountService.CreateAccount(c.Request.Context(), &Account{ - Username: req.Username, - Password: req.Password, - }); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "account created"}) -} - -func (h *AccountHandler) Rename(c *gin.Context) { - var req RenameRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - accountID, err := getAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername) - if err != nil { - if errors.Is(err, ErrNewUsernameRequired) { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if errors.Is(err, ErrUsernameTaken) { - c.JSON(409, gin.H{"error": err.Error()}) - return - } - if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(404, gin.H{"error": "account not found"}) - return - } - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"token": token}) -} - -func (h *AccountHandler) ChangePassword(c *gin.Context) { - var req ChangePasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil { - c.JSON(400, gin.H{"error": "unsuccessfully password changed"}) - return - } - c.JSON(200, gin.H{"message": "successfully password changed"}) -} - -func (h *AccountHandler) FindByID(c *gin.Context) { - var req FindByIDRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } else { - c.JSON(200, account) - } -} - -func (h *AccountHandler) FindByUsername(c *gin.Context) { - var req FindByUsernameRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } else { - c.JSON(200, account) - } -} - -func (h *AccountHandler) Login(c *gin.Context) { - var req LoginRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if token, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } else { - c.JSON(200, gin.H{"token": token}) - } -} - -func (h *AccountHandler) Logout(c *gin.Context) { - accountID, err := getAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "account logged out"}) -} - -func getAccountID(c *gin.Context) (uint, error) { - value, exists := c.Get("accountID") - if !exists { - return 0, errors.New("accountID not found") - } - id, ok := value.(uint) - if !ok { - return 0, errors.New("accountID has invalid type") - } - return id, nil -} +package account + +import ( + "errors" + + httputil "feedsystem_video_go/internal/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type AccountHandler struct { + accountService *AccountService +} + +func NewAccountHandler(accountService *AccountService) *AccountHandler { + return &AccountHandler{accountService: accountService} +} +func (h *AccountHandler) CreateAccount(c *gin.Context) { + var req CreateAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if err := h.accountService.CreateAccount(c.Request.Context(), &Account{ + Username: req.Username, + Password: req.Password, + }); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "account created"}) +} + +func (h *AccountHandler) Rename(c *gin.Context) { + var req RenameRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + accountID, err := getAccountID(c) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername) + if err != nil { + if errors.Is(err, ErrNewUsernameRequired) { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if errors.Is(err, ErrUsernameTaken) { + c.JSON(409, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(404, gin.H{"error": "account not found"}) + return + } + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"token": token}) +} + +func (h *AccountHandler) ChangePassword(c *gin.Context) { + var req ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil { + c.JSON(400, gin.H{"error": "unsuccessfully password changed"}) + return + } + c.JSON(200, gin.H{"message": "successfully password changed"}) +} + +func (h *AccountHandler) FindByID(c *gin.Context) { + var req FindByIDRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } else { + c.JSON(200, account) + } +} + +func (h *AccountHandler) FindByUsername(c *gin.Context) { + var req FindByUsernameRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } else { + c.JSON(200, account) + } +} + +func (h *AccountHandler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if token, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } else { + c.JSON(200, gin.H{"token": token}) + } +} + +func (h *AccountHandler) Logout(c *gin.Context) { + accountID, err := getAccountID(c) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "account logged out"}) +} + +func getAccountID(c *gin.Context) (uint, error) { + value, exists := c.Get("accountID") + if !exists { + return 0, errors.New("accountID not found") + } + id, ok := value.(uint) + if !ok { + return 0, errors.New("accountID has invalid type") + } + return id, nil +} diff --git a/backend/internal/feed/handler.go b/backend/internal/feed/handler.go index 48a427d..431bc2c 100644 --- a/backend/internal/feed/handler.go +++ b/backend/internal/feed/handler.go @@ -1,175 +1,176 @@ -package feed - -import ( - "feedsystem_video_go/internal/middleware/jwt" - "time" - - "github.com/gin-gonic/gin" -) - -type FeedHandler struct { - service *FeedService -} - -func NewFeedHandler(service *FeedService) *FeedHandler { - return &FeedHandler{service: service} -} - -func (f *FeedHandler) ListLatest(c *gin.Context) { - var req ListLatestRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.Limit <= 0 || req.Limit > 50 { - req.Limit = 10 - } - var latestTime time.Time - if req.LatestTime > 0 { - latestTime = time.UnixMilli(req.LatestTime) - } - viewerAccountID, err := jwt.GetAccountID(c) - if err != nil { - viewerAccountID = 0 - } - feedItems, err := f.service.ListLatest(c.Request.Context(), req.Limit, latestTime, viewerAccountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) - c.JSON(200, feedItems) -} - -func (f *FeedHandler) ListLikesCount(c *gin.Context) { - var req ListLikesCountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.Limit <= 0 || req.Limit > 50 { - req.Limit = 10 - } - - var cursor *LikesCountCursor - if req.LikesCountBefore != nil || req.IDBefore != nil { - if req.LikesCountBefore == nil || req.IDBefore == nil { - c.JSON(400, gin.H{"error": "likes_count_before and id_before must be provided together"}) - return - } - - likesCountBefore := *req.LikesCountBefore - idBefore := *req.IDBefore - - if likesCountBefore < 0 { - c.JSON(400, gin.H{"error": "invalid cursor: likes_count_before must be >= 0"}) - return - } - if idBefore == 0 { - if likesCountBefore != 0 { - c.JSON(400, gin.H{"error": "invalid cursor: id_before must be > 0"}) - return - } - } else { - cursor = &LikesCountCursor{ - LikesCount: likesCountBefore, - ID: idBefore, - } - } - } - viewerAccountID, err := jwt.GetAccountID(c) - if err != nil { - viewerAccountID = 0 - } - feedItems, err := f.service.ListLikesCount(c.Request.Context(), req.Limit, cursor, viewerAccountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) - c.JSON(200, feedItems) -} - -func (f *FeedHandler) ListByFollowing(c *gin.Context) { - var req ListByFollowingRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.Limit <= 0 || req.Limit > 50 { - req.Limit = 10 - } - viewerAccountID, err := jwt.GetAccountID(c) - if err != nil { - viewerAccountID = 0 - } - var latestTime time.Time - if req.LatestTime > 0 { - latestTime = time.Unix(req.LatestTime, 0) - } - feedItems, err := f.service.ListByFollowing(c.Request.Context(), req.Limit, latestTime, viewerAccountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) - c.JSON(200, feedItems) -} - -func (f *FeedHandler) ListByPopularity(c *gin.Context) { - var req ListByPopularityRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.Limit <= 0 || req.Limit > 50 { - req.Limit = 10 - } - viewerAccountID, err := jwt.GetAccountID(c) - if err != nil { - viewerAccountID = 0 - } - - var latestPopularity int64 - var latestBefore time.Time - var latestIDBefore uint - - if req.LatestPopularity < 0 { - c.JSON(400, gin.H{"error": "latest_popularity must be >= 0"}) - return - } - - anyCursor := !req.LatestBefore.IsZero() || req.LatestIDBefore != nil - if anyCursor { - if req.LatestBefore.IsZero() || req.LatestIDBefore == nil || *req.LatestIDBefore == 0 { - c.JSON(400, gin.H{"error": "latest_before and latest_id_before must be provided together"}) - return - } - latestPopularity = req.LatestPopularity - latestBefore = req.LatestBefore - latestIDBefore = *req.LatestIDBefore - } - resp, err := f.service.ListByPopularity( - c.Request.Context(), - req.Limit, - req.AsOf, - req.Offset, - viewerAccountID, - latestPopularity, - latestBefore, - latestIDBefore, - ) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - resp.VideoList = nonNilFeedVideoItems(resp.VideoList) - c.JSON(200, resp) -} - -func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem { - if items == nil { - return []FeedVideoItem{} - } - return items -} +package feed + +import ( + "feedsystem_video_go/internal/middleware/jwt" + httputil "feedsystem_video_go/internal/http" + "time" + + "github.com/gin-gonic/gin" +) + +type FeedHandler struct { + service *FeedService +} + +func NewFeedHandler(service *FeedService) *FeedHandler { + return &FeedHandler{service: service} +} + +func (f *FeedHandler) ListLatest(c *gin.Context) { + var req ListLatestRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 10 + } + var latestTime time.Time + if req.LatestTime > 0 { + latestTime = time.UnixMilli(req.LatestTime) + } + viewerAccountID, err := jwt.GetAccountID(c) + if err != nil { + viewerAccountID = 0 + } + feedItems, err := f.service.ListLatest(c.Request.Context(), req.Limit, latestTime, viewerAccountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) + c.JSON(200, feedItems) +} + +func (f *FeedHandler) ListLikesCount(c *gin.Context) { + var req ListLikesCountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 10 + } + + var cursor *LikesCountCursor + if req.LikesCountBefore != nil || req.IDBefore != nil { + if req.LikesCountBefore == nil || req.IDBefore == nil { + c.JSON(400, gin.H{"error": "likes_count_before and id_before must be provided together"}) + return + } + + likesCountBefore := *req.LikesCountBefore + idBefore := *req.IDBefore + + if likesCountBefore < 0 { + c.JSON(400, gin.H{"error": "invalid cursor: likes_count_before must be >= 0"}) + return + } + if idBefore == 0 { + if likesCountBefore != 0 { + c.JSON(400, gin.H{"error": "invalid cursor: id_before must be > 0"}) + return + } + } else { + cursor = &LikesCountCursor{ + LikesCount: likesCountBefore, + ID: idBefore, + } + } + } + viewerAccountID, err := jwt.GetAccountID(c) + if err != nil { + viewerAccountID = 0 + } + feedItems, err := f.service.ListLikesCount(c.Request.Context(), req.Limit, cursor, viewerAccountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) + c.JSON(200, feedItems) +} + +func (f *FeedHandler) ListByFollowing(c *gin.Context) { + var req ListByFollowingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 10 + } + viewerAccountID, err := jwt.GetAccountID(c) + if err != nil { + viewerAccountID = 0 + } + var latestTime time.Time + if req.LatestTime > 0 { + latestTime = time.Unix(req.LatestTime, 0) + } + feedItems, err := f.service.ListByFollowing(c.Request.Context(), req.Limit, latestTime, viewerAccountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList) + c.JSON(200, feedItems) +} + +func (f *FeedHandler) ListByPopularity(c *gin.Context) { + var req ListByPopularityRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.Limit <= 0 || req.Limit > 50 { + req.Limit = 10 + } + viewerAccountID, err := jwt.GetAccountID(c) + if err != nil { + viewerAccountID = 0 + } + + var latestPopularity int64 + var latestBefore time.Time + var latestIDBefore uint + + if req.LatestPopularity < 0 { + c.JSON(400, gin.H{"error": "latest_popularity must be >= 0"}) + return + } + + anyCursor := !req.LatestBefore.IsZero() || req.LatestIDBefore != nil + if anyCursor { + if req.LatestBefore.IsZero() || req.LatestIDBefore == nil || *req.LatestIDBefore == 0 { + c.JSON(400, gin.H{"error": "latest_before and latest_id_before must be provided together"}) + return + } + latestPopularity = req.LatestPopularity + latestBefore = req.LatestBefore + latestIDBefore = *req.LatestIDBefore + } + resp, err := f.service.ListByPopularity( + c.Request.Context(), + req.Limit, + req.AsOf, + req.Offset, + viewerAccountID, + latestPopularity, + latestBefore, + latestIDBefore, + ) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + resp.VideoList = nonNilFeedVideoItems(resp.VideoList) + c.JSON(200, resp) +} + +func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem { + if items == nil { + return []FeedVideoItem{} + } + return items +} diff --git a/backend/internal/http/errors.go b/backend/internal/http/errors.go new file mode 100644 index 0000000..a0e33e2 --- /dev/null +++ b/backend/internal/http/errors.go @@ -0,0 +1,28 @@ +package http + +import ( + "errors" + "net/http" + + "gorm.io/gorm" +) + +var ( + ErrUnauthorized = errors.New("unauthorized") + ErrValidation = errors.New("validation error") +) + +func ClassifyHTTPStatus(err error) int { + switch { + case err == nil: + return http.StatusOK + case errors.Is(err, ErrUnauthorized): + return http.StatusUnauthorized + case errors.Is(err, ErrValidation): + return http.StatusBadRequest + case errors.Is(err, gorm.ErrRecordNotFound): + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} diff --git a/backend/internal/social/handler.go b/backend/internal/social/handler.go index 22e20bf..7d95bb6 100644 --- a/backend/internal/social/handler.go +++ b/backend/internal/social/handler.go @@ -1,125 +1,126 @@ -package social - -import ( - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/middleware/jwt" - "net/http" - - "github.com/gin-gonic/gin" -) - -type SocialHandler struct { - service *SocialService -} - -func NewSocialHandler(service *SocialService) *SocialHandler { - return &SocialHandler{service: service} -} - -func (h *SocialHandler) Follow(c *gin.Context) { - var req FollowRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if req.VloggerID <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"}) - return - } - FollowerID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - social := &Social{ - FollowerID: FollowerID, - VloggerID: req.VloggerID, - } - if err := h.service.Follow(c.Request.Context(), social); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "followed"}) -} - -func (h *SocialHandler) Unfollow(c *gin.Context) { - var req UnfollowRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if req.VloggerID <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"}) - return - } - FollowerID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - social := &Social{ - FollowerID: FollowerID, - VloggerID: req.VloggerID, - } - if err := h.service.Unfollow(c.Request.Context(), social); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "unfollowed"}) -} - -func (h *SocialHandler) GetAllFollowers(c *gin.Context) { - var req GetAllFollowersRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - vloggerID := req.VloggerID - if vloggerID == 0 { - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - vloggerID = accountID - } - - followers, err := h.service.GetAllFollowers(c.Request.Context(), vloggerID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if followers == nil { - followers = []*account.Account{} - } - c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers}) -} - -func (h *SocialHandler) GetAllVloggers(c *gin.Context) { - var req GetAllVloggersRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - followerID := req.FollowerID - if followerID == 0 { - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - followerID = accountID - } - - vloggers, err := h.service.GetAllVloggers(c.Request.Context(), followerID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if vloggers == nil { - vloggers = []*account.Account{} - } - c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers}) -} +package social + +import ( + "feedsystem_video_go/internal/account" + httputil "feedsystem_video_go/internal/http" + "feedsystem_video_go/internal/middleware/jwt" + "net/http" + + "github.com/gin-gonic/gin" +) + +type SocialHandler struct { + service *SocialService +} + +func NewSocialHandler(service *SocialService) *SocialHandler { + return &SocialHandler{service: service} +} + +func (h *SocialHandler) Follow(c *gin.Context) { + var req FollowRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VloggerID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"}) + return + } + FollowerID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + social := &Social{ + FollowerID: FollowerID, + VloggerID: req.VloggerID, + } + if err := h.service.Follow(c.Request.Context(), social); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "followed"}) +} + +func (h *SocialHandler) Unfollow(c *gin.Context) { + var req UnfollowRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VloggerID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"}) + return + } + FollowerID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + social := &Social{ + FollowerID: FollowerID, + VloggerID: req.VloggerID, + } + if err := h.service.Unfollow(c.Request.Context(), social); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "unfollowed"}) +} + +func (h *SocialHandler) GetAllFollowers(c *gin.Context) { + var req GetAllFollowersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + vloggerID := req.VloggerID + if vloggerID == 0 { + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + vloggerID = accountID + } + + followers, err := h.service.GetAllFollowers(c.Request.Context(), vloggerID) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if followers == nil { + followers = []*account.Account{} + } + c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers}) +} + +func (h *SocialHandler) GetAllVloggers(c *gin.Context) { + var req GetAllVloggersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + followerID := req.FollowerID + if followerID == 0 { + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + followerID = accountID + } + + vloggers, err := h.service.GetAllVloggers(c.Request.Context(), followerID) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if vloggers == nil { + vloggers = []*account.Account{} + } + c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers}) +} diff --git a/backend/internal/video/comment_handler.go b/backend/internal/video/comment_handler.go index 0ed9a7b..d370b37 100644 --- a/backend/internal/video/comment_handler.go +++ b/backend/internal/video/comment_handler.go @@ -1,97 +1,98 @@ -package video - -import ( - "feedsystem_video_go/internal/account" - "feedsystem_video_go/internal/middleware/jwt" - - "github.com/gin-gonic/gin" -) - -type CommentHandler struct { - service *CommentService - accountService *account.AccountService -} - -func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler { - return &CommentHandler{service: service, accountService: accountService} -} -func (h *CommentHandler) PublishComment(c *gin.Context) { - var req PublishCommentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.Content == "" { - c.JSON(400, gin.H{"error": "content is required"}) - return - } - if req.VideoID <= 0 { - c.JSON(400, gin.H{"error": "video_id is required"}) - return - } - authorId, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - user, err := h.accountService.FindByID(c.Request.Context(), authorId) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - comment := &Comment{ - Username: user.Username, - VideoID: req.VideoID, - AuthorID: authorId, - Content: req.Content, - } - if err := h.service.Publish(c.Request.Context(), comment); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "comment published successfully"}) -} - -func (h *CommentHandler) DeleteComment(c *gin.Context) { - var req DeleteCommentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.CommentID <= 0 { - c.JSON(400, gin.H{"error": "comment_id is required"}) - return - } - if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - c.JSON(200, gin.H{"message": "comment deleted successfully"}) -} - -func (h *CommentHandler) GetAllComments(c *gin.Context) { - var req GetAllCommentsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.VideoID == 0 { - c.JSON(400, gin.H{"error": "video_id is required"}) - return - } - comments, err := h.service.GetAll(c.Request.Context(), req.VideoID) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if comments == nil { - comments = []Comment{} - } - c.JSON(200, comments) -} +package video + +import ( + "feedsystem_video_go/internal/account" + httputil "feedsystem_video_go/internal/http" + "feedsystem_video_go/internal/middleware/jwt" + + "github.com/gin-gonic/gin" +) + +type CommentHandler struct { + service *CommentService + accountService *account.AccountService +} + +func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler { + return &CommentHandler{service: service, accountService: accountService} +} +func (h *CommentHandler) PublishComment(c *gin.Context) { + var req PublishCommentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.Content == "" { + c.JSON(400, gin.H{"error": "content is required"}) + return + } + if req.VideoID <= 0 { + c.JSON(400, gin.H{"error": "video_id is required"}) + return + } + authorId, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + user, err := h.accountService.FindByID(c.Request.Context(), authorId) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + comment := &Comment{ + Username: user.Username, + VideoID: req.VideoID, + AuthorID: authorId, + Content: req.Content, + } + if err := h.service.Publish(c.Request.Context(), comment); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "comment published successfully"}) +} + +func (h *CommentHandler) DeleteComment(c *gin.Context) { + var req DeleteCommentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.CommentID <= 0 { + c.JSON(400, gin.H{"error": "comment_id is required"}) + return + } + if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + c.JSON(200, gin.H{"message": "comment deleted successfully"}) +} + +func (h *CommentHandler) GetAllComments(c *gin.Context) { + var req GetAllCommentsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VideoID == 0 { + c.JSON(400, gin.H{"error": "video_id is required"}) + return + } + comments, err := h.service.GetAll(c.Request.Context(), req.VideoID) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if comments == nil { + comments = []Comment{} + } + c.JSON(200, comments) +} diff --git a/backend/internal/video/comment_service.go b/backend/internal/video/comment_service.go index 875c2b0..2f502ba 100644 --- a/backend/internal/video/comment_service.go +++ b/backend/internal/video/comment_service.go @@ -1,116 +1,117 @@ -package video - -import ( - "context" - "errors" - "feedsystem_video_go/internal/middleware/rabbitmq" - rediscache "feedsystem_video_go/internal/middleware/redis" - "strings" - - "gorm.io/gorm" -) - -type CommentService struct { - repo *CommentRepository - VideoRepository *VideoRepository - cache *rediscache.Client - commentMQ *rabbitmq.CommentMQ - popularityMQ *rabbitmq.PopularityMQ -} - -func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository, cache *rediscache.Client, commentMQ *rabbitmq.CommentMQ, popularityMQ *rabbitmq.PopularityMQ) *CommentService { - return &CommentService{repo: repo, VideoRepository: videoRepo, cache: cache, commentMQ: commentMQ, popularityMQ: popularityMQ} -} - -func (s *CommentService) Publish(ctx context.Context, comment *Comment) error { - if comment == nil { - return errors.New("comment is nil") - } - comment.Username = strings.TrimSpace(comment.Username) - comment.Content = strings.TrimSpace(comment.Content) - if comment.VideoID == 0 || comment.AuthorID == 0 { - return errors.New("video_id and author_id are required") - } - if comment.Content == "" { - return errors.New("content is required") - } - - exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID) - if err != nil { - return err - } - if !exists { - return errors.New("video not found") - } - - mysqlEnqueued := false - redisEnqueued := false - if s.commentMQ != nil { - if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil { - mysqlEnqueued = true - } - } - if s.popularityMQ != nil { - if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil { - redisEnqueued = true - } - } - if mysqlEnqueued && redisEnqueued { - return nil - } - - // Fallback: direct MySQL write when comment MQ publish fails. - if !mysqlEnqueued { - if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("video not found") - } - return err - } - if err := tx.Create(comment).Error; err != nil { - return err - } - return tx.Model(&Video{}).Where("id = ?", comment.VideoID). - UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error - }); err != nil { - return err - } - } - - // Fallback: direct Redis update when popularity MQ publish fails. - if !redisEnqueued { - UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1) - } - return nil -} - -func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error { - comment, err := s.repo.GetByID(ctx, commentID) - if err != nil { - return err - } - if comment == nil { - return errors.New("comment not found") - } - if comment.AuthorID != accountID { - return errors.New("permission denied") - } - if s.commentMQ != nil { - if err := s.commentMQ.Delete(ctx, commentID); err == nil { - return nil - } - } - return s.repo.DeleteComment(ctx, comment) -} - -func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) { - exists, err := s.VideoRepository.IsExist(ctx, videoID) - if err != nil { - return nil, err - } - if !exists { - return nil, errors.New("video not found") - } - return s.repo.GetAllComments(ctx, videoID) -} +package video + +import ( + "context" + "errors" + "feedsystem_video_go/internal/middleware/rabbitmq" + rediscache "feedsystem_video_go/internal/middleware/redis" + httputil "feedsystem_video_go/internal/http" + "strings" + + "gorm.io/gorm" +) + +type CommentService struct { + repo *CommentRepository + VideoRepository *VideoRepository + cache *rediscache.Client + commentMQ *rabbitmq.CommentMQ + popularityMQ *rabbitmq.PopularityMQ +} + +func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository, cache *rediscache.Client, commentMQ *rabbitmq.CommentMQ, popularityMQ *rabbitmq.PopularityMQ) *CommentService { + return &CommentService{repo: repo, VideoRepository: videoRepo, cache: cache, commentMQ: commentMQ, popularityMQ: popularityMQ} +} + +func (s *CommentService) Publish(ctx context.Context, comment *Comment) error { + if comment == nil { + return errors.New("comment is nil") + } + comment.Username = strings.TrimSpace(comment.Username) + comment.Content = strings.TrimSpace(comment.Content) + if comment.VideoID == 0 || comment.AuthorID == 0 { + return errors.New("video_id and author_id are required") + } + if comment.Content == "" { + return errors.New("content is required") + } + + exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID) + if err != nil { + return err + } + if !exists { + return errors.New("video not found") + } + + mysqlEnqueued := false + redisEnqueued := false + if s.commentMQ != nil { + if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil { + mysqlEnqueued = true + } + } + if s.popularityMQ != nil { + if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil { + redisEnqueued = true + } + } + if mysqlEnqueued && redisEnqueued { + return nil + } + + // Fallback: direct MySQL write when comment MQ publish fails. + if !mysqlEnqueued { + if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("video not found") + } + return err + } + if err := tx.Create(comment).Error; err != nil { + return err + } + return tx.Model(&Video{}).Where("id = ?", comment.VideoID). + UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error + }); err != nil { + return err + } + } + + // Fallback: direct Redis update when popularity MQ publish fails. + if !redisEnqueued { + UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1) + } + return nil +} + +func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error { + comment, err := s.repo.GetByID(ctx, commentID) + if err != nil { + return err + } + if comment == nil { + return errors.New("comment not found") + } + if comment.AuthorID != accountID { + return httputil.ErrUnauthorized + } + if s.commentMQ != nil { + if err := s.commentMQ.Delete(ctx, commentID); err == nil { + return nil + } + } + return s.repo.DeleteComment(ctx, comment) +} + +func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) { + exists, err := s.VideoRepository.IsExist(ctx, videoID) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.New("video not found") + } + return s.repo.GetAllComments(ctx, videoID) +} diff --git a/backend/internal/video/like_handler.go b/backend/internal/video/like_handler.go index 66d595f..39aa2a9 100644 --- a/backend/internal/video/like_handler.go +++ b/backend/internal/video/like_handler.go @@ -1,113 +1,114 @@ -package video - -import ( - "feedsystem_video_go/internal/middleware/jwt" - - "github.com/gin-gonic/gin" -) - -type LikeHandler struct { - service *LikeService -} - -func NewLikeHandler(service *LikeService) *LikeHandler { - return &LikeHandler{service: service} -} - -func (lh *LikeHandler) Like(c *gin.Context) { - var req LikeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.VideoID <= 0 { - c.JSON(400, gin.H{"error": "video_id is required"}) - return - } - - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - like := &Like{ - VideoID: req.VideoID, - AccountID: accountID, - } - if err := lh.service.Like(c.Request.Context(), like); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "like success"}) -} - -func (lh *LikeHandler) Unlike(c *gin.Context) { - var req LikeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.VideoID <= 0 { - c.JSON(400, gin.H{"error": "video_id is required"}) - return - } - - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - like := &Like{ - VideoID: req.VideoID, - AccountID: accountID, - } - if err := lh.service.Unlike(c.Request.Context(), like); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"message": "unlike success"}) -} - -func (lh *LikeHandler) IsLiked(c *gin.Context) { - var req LikeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - if req.VideoID <= 0 { - c.JSON(400, gin.H{"error": "video_id is required"}) - return - } - - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"is_liked": isLiked}) -} - -func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) { - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - if videos == nil { - videos = []Video{} - } - c.JSON(200, videos) -} +package video + +import ( + "feedsystem_video_go/internal/middleware/jwt" + httputil "feedsystem_video_go/internal/http" + + "github.com/gin-gonic/gin" +) + +type LikeHandler struct { + service *LikeService +} + +func NewLikeHandler(service *LikeService) *LikeHandler { + return &LikeHandler{service: service} +} + +func (lh *LikeHandler) Like(c *gin.Context) { + var req LikeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VideoID <= 0 { + c.JSON(400, gin.H{"error": "video_id is required"}) + return + } + + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + like := &Like{ + VideoID: req.VideoID, + AccountID: accountID, + } + if err := lh.service.Like(c.Request.Context(), like); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "like success"}) +} + +func (lh *LikeHandler) Unlike(c *gin.Context) { + var req LikeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VideoID <= 0 { + c.JSON(400, gin.H{"error": "video_id is required"}) + return + } + + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + like := &Like{ + VideoID: req.VideoID, + AccountID: accountID, + } + if err := lh.service.Unlike(c.Request.Context(), like); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"message": "unlike success"}) +} + +func (lh *LikeHandler) IsLiked(c *gin.Context) { + var req LikeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + if req.VideoID <= 0 { + c.JSON(400, gin.H{"error": "video_id is required"}) + return + } + + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"is_liked": isLiked}) +} + +func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) { + accountID, err := jwt.GetAccountID(c) + if err != nil { + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) + return + } + + videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + if videos == nil { + videos = []Video{} + } + c.JSON(200, videos) +} diff --git a/backend/internal/video/video_handler.go b/backend/internal/video/video_handler.go index 8319698..988878d 100644 --- a/backend/internal/video/video_handler.go +++ b/backend/internal/video/video_handler.go @@ -12,6 +12,7 @@ import ( "time" "feedsystem_video_go/internal/account" + httputil "feedsystem_video_go/internal/http" "feedsystem_video_go/internal/middleware/jwt" "github.com/gin-gonic/gin" @@ -29,18 +30,18 @@ func NewVideoHandler(service *VideoService, accountService *account.AccountServi func (vh *VideoHandler) PublishVideo(c *gin.Context) { var req PublishVideoRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } authorId, err := jwt.GetAccountID(c) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } username, err := jwt.GetUsername(c) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } video := &Video{ @@ -53,7 +54,7 @@ func (vh *VideoHandler) PublishVideo(c *gin.Context) { CreateTime: time.Now(), } if err := vh.service.Publish(c.Request.Context(), video); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } c.JSON(200, video) @@ -193,16 +194,16 @@ func buildAbsoluteURL(c *gin.Context, p string) string { func (vh *VideoHandler) DeleteVideo(c *gin.Context) { var req DeleteVideoRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } authorId, err := jwt.GetAccountID(c) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "video deleted"}) @@ -211,12 +212,12 @@ func (vh *VideoHandler) DeleteVideo(c *gin.Context) { func (vh *VideoHandler) ListByAuthorID(c *gin.Context) { var req ListByAuthorIDRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } if videos == nil { @@ -228,12 +229,12 @@ func (vh *VideoHandler) ListByAuthorID(c *gin.Context) { func (vh *VideoHandler) GetDetail(c *gin.Context) { var req GetDetailRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } video, err := vh.service.GetDetail(c.Request.Context(), req.ID) if err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } c.JSON(200, video) @@ -242,11 +243,11 @@ func (vh *VideoHandler) GetDetail(c *gin.Context) { func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) { var req UpdateLikesCountRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) + c.JSON(httputil.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "likes count updated"}) diff --git a/backend/internal/video/video_service.go b/backend/internal/video/video_service.go index 92eba9a..d952066 100644 --- a/backend/internal/video/video_service.go +++ b/backend/internal/video/video_service.go @@ -1,220 +1,221 @@ -package video - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" - "time" - - "feedsystem_video_go/internal/middleware/rabbitmq" - rediscache "feedsystem_video_go/internal/middleware/redis" - - "gorm.io/gorm" -) - -type VideoService struct { - repo *VideoRepository - cache *rediscache.Client - cacheTTL time.Duration - popularityMQ *rabbitmq.PopularityMQ -} - -func NewVideoService(repo *VideoRepository, cache *rediscache.Client, popularityMQ *rabbitmq.PopularityMQ) *VideoService { - return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute, popularityMQ: popularityMQ} -} - -func (vs *VideoService) Publish(ctx context.Context, video *Video) error { - if video == nil { - return errors.New("video is nil") - } - video.Title = strings.TrimSpace(video.Title) - video.PlayURL = strings.TrimSpace(video.PlayURL) - video.CoverURL = strings.TrimSpace(video.CoverURL) - - if video.Title == "" { - return errors.New("title is required") - } - if video.PlayURL == "" { - return errors.New("play url is required") - } - if video.CoverURL == "" { - return errors.New("cover url is required") - } - - //事务保证视频写入库和消息写入本地消息表的一致性 - err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Create(video).Error; err != nil { - return err - } - - msg := OutboxMsg{ - VideoID: video.ID, - EventType: "video_published", - Status: "pending", - CreateTime: video.CreateTime, - } - - if err := tx.Create(&msg).Error; err != nil { - return err - } - return nil - - }) - return err - -} - -func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error { - video, err := vs.repo.GetByID(ctx, id) - if err != nil { - return err - } - if video == nil { - return errors.New("video not found") - } - if video.AuthorID != authorID { - return errors.New("unauthorized") - } - if err := vs.repo.DeleteVideo(ctx, id); err != nil { - return err - } - if vs.cache != nil { - cacheKey := fmt.Sprintf("video:detail:id=%d", id) - _ = vs.cache.Del(context.Background(), cacheKey) - } - return nil -} - -func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) { - videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID)) - if err != nil { - return nil, err - } - return videos, nil -} - -func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) { - cacheKey := fmt.Sprintf("video:detail:id=%d", id) - - getCached := func() (*Video, bool) { - opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - - b, err := vs.cache.GetBytes(opCtx, cacheKey) - if err != nil { - return nil, false - } - var cached Video - if err := json.Unmarshal(b, &cached); err != nil { - return nil, false - } - return &cached, true - } - - setCached := func(video *Video) { - b, err := json.Marshal(video) - if err != nil { - return - } - opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - _ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL) - } - - if vs.cache != nil { - if v, ok := getCached(); ok { - return v, nil - } - - opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - b, err := vs.cache.GetBytes(opCtx, cacheKey) - cancel() - if err == nil { - var cached Video - if err := json.Unmarshal(b, &cached); err == nil { - return &cached, nil - } - } else if rediscache.IsMiss(err) { - lockKey := "lock:" + cacheKey - - lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond) - token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second) - lockCancel() - - if lockErr == nil && locked { - defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }() - - if v, ok := getCached(); ok { - return v, nil - } - - video, err := vs.repo.GetByID(ctx, id) - if err != nil { - return nil, err - } - setCached(video) - return video, nil - } - - // 没拿到锁:等待别人回填缓存 - for i := 0; i < 5; i++ { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(20 * time.Millisecond): - } - if v, ok := getCached(); ok { - return v, nil - } - } - } - } - - video, err := vs.repo.GetByID(ctx, id) - if err != nil { - return nil, err - } - if vs.cache != nil { - setCached(video) - } - return video, nil -} - -func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error { - if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil { - return err - } - return nil -} - -func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error { - if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil { - return err - } - - if vs.popularityMQ != nil { - if err := vs.popularityMQ.Update(ctx, id, change); err == nil { - return nil - } - } - - if vs.cache != nil { - // 1) 详情缓存:直接失效(最简单靠谱) - _ = vs.cache.Del(context.Background(), fmt.Sprintf("video:detail:id=%d", id)) - - // 2) 热榜:写到“时间窗ZSET”,不要用 detail key - now := time.Now().UTC().Truncate(time.Minute) - windowKey := "hot:video:1m:" + now.Format("200601021504") - member := strconv.FormatUint(uint64(id), 10) - - opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel() - - _ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change)) - _ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour) - } - return nil -} +package video + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "feedsystem_video_go/internal/middleware/rabbitmq" + rediscache "feedsystem_video_go/internal/middleware/redis" + httputil "feedsystem_video_go/internal/http" + + "gorm.io/gorm" +) + +type VideoService struct { + repo *VideoRepository + cache *rediscache.Client + cacheTTL time.Duration + popularityMQ *rabbitmq.PopularityMQ +} + +func NewVideoService(repo *VideoRepository, cache *rediscache.Client, popularityMQ *rabbitmq.PopularityMQ) *VideoService { + return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute, popularityMQ: popularityMQ} +} + +func (vs *VideoService) Publish(ctx context.Context, video *Video) error { + if video == nil { + return errors.New("video is nil") + } + video.Title = strings.TrimSpace(video.Title) + video.PlayURL = strings.TrimSpace(video.PlayURL) + video.CoverURL = strings.TrimSpace(video.CoverURL) + + if video.Title == "" { + return errors.New("title is required") + } + if video.PlayURL == "" { + return errors.New("play url is required") + } + if video.CoverURL == "" { + return errors.New("cover url is required") + } + + //事务保证视频写入库和消息写入本地消息表的一致性 + err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(video).Error; err != nil { + return err + } + + msg := OutboxMsg{ + VideoID: video.ID, + EventType: "video_published", + Status: "pending", + CreateTime: video.CreateTime, + } + + if err := tx.Create(&msg).Error; err != nil { + return err + } + return nil + + }) + return err + +} + +func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error { + video, err := vs.repo.GetByID(ctx, id) + if err != nil { + return err + } + if video == nil { + return errors.New("video not found") + } + if video.AuthorID != authorID { + return httputil.ErrUnauthorized + } + if err := vs.repo.DeleteVideo(ctx, id); err != nil { + return err + } + if vs.cache != nil { + cacheKey := fmt.Sprintf("video:detail:id=%d", id) + _ = vs.cache.Del(context.Background(), cacheKey) + } + return nil +} + +func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) { + videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID)) + if err != nil { + return nil, err + } + return videos, nil +} + +func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) { + cacheKey := fmt.Sprintf("video:detail:id=%d", id) + + getCached := func() (*Video, bool) { + opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + + b, err := vs.cache.GetBytes(opCtx, cacheKey) + if err != nil { + return nil, false + } + var cached Video + if err := json.Unmarshal(b, &cached); err != nil { + return nil, false + } + return &cached, true + } + + setCached := func(video *Video) { + b, err := json.Marshal(video) + if err != nil { + return + } + opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + _ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL) + } + + if vs.cache != nil { + if v, ok := getCached(); ok { + return v, nil + } + + opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + b, err := vs.cache.GetBytes(opCtx, cacheKey) + cancel() + if err == nil { + var cached Video + if err := json.Unmarshal(b, &cached); err == nil { + return &cached, nil + } + } else if rediscache.IsMiss(err) { + lockKey := "lock:" + cacheKey + + lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond) + token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second) + lockCancel() + + if lockErr == nil && locked { + defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }() + + if v, ok := getCached(); ok { + return v, nil + } + + video, err := vs.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + setCached(video) + return video, nil + } + + // 没拿到锁:等待别人回填缓存 + for i := 0; i < 5; i++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(20 * time.Millisecond): + } + if v, ok := getCached(); ok { + return v, nil + } + } + } + } + + video, err := vs.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if vs.cache != nil { + setCached(video) + } + return video, nil +} + +func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error { + if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil { + return err + } + return nil +} + +func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error { + if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil { + return err + } + + if vs.popularityMQ != nil { + if err := vs.popularityMQ.Update(ctx, id, change); err == nil { + return nil + } + } + + if vs.cache != nil { + // 1) 详情缓存:直接失效(最简单靠谱) + _ = vs.cache.Del(context.Background(), fmt.Sprintf("video:detail:id=%d", id)) + + // 2) 热榜:写到“时间窗ZSET”,不要用 detail key + now := time.Now().UTC().Truncate(time.Minute) + windowKey := "hot:video:1m:" + now.Format("200601021504") + member := strconv.FormatUint(uint64(id), 10) + + opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + + _ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change)) + _ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour) + } + return nil +} From 3613cfe5a3adbb277f45e4a221f08012f90289eb Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 16:07:58 +0800 Subject: [PATCH 07/23] =?UTF-8?q?feat(P3):=20Docker=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=20+=20Worker=E4=BC=98=E9=9B=85=E9=87=8D?= =?UTF-8?q?=E5=90=AF=20+=20=E5=89=8D=E7=AB=AF=E9=94=99=E8=AF=AF=E7=9B=91?= =?UTF-8?q?=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/worker/main.go | 610 ++++++++++++++------------- docker-compose.yml | 15 + frontend/src/api/client.ts | 203 ++++----- frontend/src/main.ts | 26 +- frontend/src/utils/error-reporter.ts | 18 + 5 files changed, 468 insertions(+), 404 deletions(-) create mode 100644 frontend/src/utils/error-reporter.ts diff --git a/backend/cmd/worker/main.go b/backend/cmd/worker/main.go index f277bc0..de91005 100644 --- a/backend/cmd/worker/main.go +++ b/backend/cmd/worker/main.go @@ -1,295 +1,315 @@ -package main - -import ( - "context" - "feedsystem_video_go/internal/config" - "feedsystem_video_go/internal/db" - rediscache "feedsystem_video_go/internal/middleware/redis" - "feedsystem_video_go/internal/observability" - "feedsystem_video_go/internal/social" - "feedsystem_video_go/internal/video" - "feedsystem_video_go/internal/worker" - "log" - "os" - "os/signal" - "strconv" - "syscall" - "time" - - amqp "github.com/rabbitmq/amqp091-go" -) - -const ( - socialExchange = "social.events" - socialQueue = "social.events" - socialBindingKey = "social.*" - - likeExchange = "like.events" - likeQueue = "like.events" - likeBindingKey = "like.*" - - commentExchange = "comment.events" - commentQueue = "comment.events" - commentBindingKey = "comment.*" - - popularityExchange = "video.popularity.events" - popularityQueue = "video.popularity.events" - popularityBindingKey = "video.popularity.*" -) - -func main() { - // 加载配置 - configPath := os.Getenv("CONFIG_PATH") - if configPath == "" { - configPath = "configs/config.yaml" - } - log.Printf("Loading config from %s", configPath) - cfg, usedDefault, err := config.LoadLocalDev(configPath) - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - if usedDefault { - log.Printf("Config File %s not found, using default local config", configPath) - } else { - log.Printf("Config loaded from file: %s", configPath) - } - // 连接数据库 - sqlDB, err := db.NewDB(cfg.Database) - if err != nil { - log.Fatalf("Failed to connect database: %v", err) - } - defer db.CloseDB(sqlDB) - - // 连接 Redis(用于流行度更新) - cache, err := rediscache.NewFromEnv(&cfg.Redis) - if err != nil { - log.Printf("Redis config error (popularity worker disabled): %v", err) - cache = nil - } else { - pingCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - if err := cache.Ping(pingCtx); err != nil { - log.Printf("Redis not available (popularity worker disabled): %v", err) - _ = cache.Close() - cache = nil - } else { - defer cache.Close() - log.Printf("Redis connected (popularity worker enabled)") - } - } - // 连接 RabbitMQ - url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/" - conn, err := amqp.Dial(url) - if err != nil { - log.Fatalf("Failed to connect rabbitmq: %v", err) - } - defer conn.Close() - // 创建 RabbitMQ 通道 - ch, err := conn.Channel() - if err != nil { - log.Fatalf("Failed to open rabbitmq channel: %v", err) - } - defer ch.Close() - // 声明 Social 交换机和队列 - if err := declareSocialTopology(ch); err != nil { - log.Fatalf("Failed to declare social topology: %v", err) - } - if err := declareLikeTopology(ch); err != nil { - log.Fatalf("Failed to declare like topology: %v", err) - } - if err := declareCommentTopology(ch); err != nil { - log.Fatalf("Failed to declare comment topology: %v", err) - } - if cache != nil { - if err := declarePopularityTopology(ch); 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) - } - - repo := social.NewSocialRepository(sqlDB) - socialWorker := worker.NewSocialWorker(ch, repo, socialQueue) - 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() - - pprofServer, err := observability.NewPprofServer( - "Worker", - cfg.ObservabilityConfig.Pprof.Enabled, - cfg.ObservabilityConfig.Pprof.WorkerAddr, - ) - if err != nil { - log.Printf("Failed to start worker pprof server: %v", err) - } - if pprofServer != nil { - 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) }() - } - - err = <-errCh - if err != nil && err != context.Canceled { - log.Fatalf("Worker stopped: %v", err) - } - log.Printf("Worker stopped") -} - -func declareSocialTopology(ch *amqp.Channel) error { - if err := ch.ExchangeDeclare( - socialExchange, - "topic", - true, - false, - false, - false, - nil, - ); err != nil { - return err - } - - q, err := ch.QueueDeclare( - socialQueue, - true, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - if err := ch.QueueBind( - q.Name, - socialBindingKey, - socialExchange, - false, - nil, - ); err != nil { - return err - } - return nil -} - -func declarePopularityTopology(ch *amqp.Channel) error { - if err := ch.ExchangeDeclare( - popularityExchange, - "topic", - true, - false, - false, - false, - nil, - ); err != nil { - return err - } - - q, err := ch.QueueDeclare( - popularityQueue, - true, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - return ch.QueueBind( - q.Name, - popularityBindingKey, - popularityExchange, - false, - nil, - ) -} - -func declareLikeTopology(ch *amqp.Channel) error { - if err := ch.ExchangeDeclare( - likeExchange, - "topic", - true, - false, - false, - false, - nil, - ); err != nil { - return err - } - - q, err := ch.QueueDeclare( - likeQueue, - true, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - return ch.QueueBind( - q.Name, - likeBindingKey, - likeExchange, - false, - nil, - ) -} - -func declareCommentTopology(ch *amqp.Channel) error { - if err := ch.ExchangeDeclare( - commentExchange, - "topic", - true, - false, - false, - false, - nil, - ); err != nil { - return err - } - - q, err := ch.QueueDeclare( - commentQueue, - true, - false, - false, - false, - nil, - ) - if err != nil { - return err - } - - return ch.QueueBind( - q.Name, - commentBindingKey, - commentExchange, - false, - nil, - ) -} +package main + +import ( + "context" + "feedsystem_video_go/internal/config" + "feedsystem_video_go/internal/db" + rediscache "feedsystem_video_go/internal/middleware/redis" + "feedsystem_video_go/internal/observability" + "feedsystem_video_go/internal/social" + "feedsystem_video_go/internal/video" + "feedsystem_video_go/internal/worker" + "log" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + amqp "github.com/rabbitmq/amqp091-go" + "gorm.io/gorm" +) + +const ( + socialExchange = "social.events" + socialQueue = "social.events" + socialBindingKey = "social.*" + + likeExchange = "like.events" + likeQueue = "like.events" + likeBindingKey = "like.*" + + commentExchange = "comment.events" + commentQueue = "comment.events" + commentBindingKey = "comment.*" + + popularityExchange = "video.popularity.events" + popularityQueue = "video.popularity.events" + popularityBindingKey = "video.popularity.*" +) + +func connectWithRetry(name string, maxRetries int, fn func() error) { + for i := 0; i < maxRetries; i++ { + if err := fn(); err == nil { + return + } + wait := time.Duration(1< 30*time.Second { + wait = 30 * time.Second + } + log.Printf("%s 不可用,%v 后重试 (%d/%d)...", name, wait, i+1, maxRetries) + time.Sleep(wait) + } + log.Fatalf("%s: 超过最大重试次数", name) +} + +func main() { + // 加载配置 + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = "configs/config.yaml" + } + log.Printf("Loading config from %s", configPath) + cfg, usedDefault, err := config.LoadLocalDev(configPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + if usedDefault { + log.Printf("Config File %s not found, using default local config", configPath) + } else { + log.Printf("Config loaded from file: %s", configPath) + } + // 连接数据库(带重试) + var sqlDB *gorm.DB + connectWithRetry("MySQL", 10, func() error { + var err error + sqlDB, err = db.NewDB(cfg.Database) + return err + }) + defer db.CloseDB(sqlDB) + + // 连接 Redis(用于流行度更新) + cache, err := rediscache.NewFromEnv(&cfg.Redis) + if err != nil { + log.Printf("Redis config error (popularity worker disabled): %v", err) + cache = nil + } else { + pingCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + if err := cache.Ping(pingCtx); err != nil { + log.Printf("Redis not available (popularity worker disabled): %v", err) + _ = cache.Close() + cache = nil + } else { + defer cache.Close() + log.Printf("Redis connected (popularity worker enabled)") + } + } + // 连接 RabbitMQ(带重试) + url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/" + var conn *amqp.Connection + connectWithRetry("RabbitMQ", 10, func() error { + var err error + conn, err = amqp.Dial(url) + return err + }) + defer conn.Close() + // 创建 RabbitMQ 通道 + ch, err := conn.Channel() + if err != nil { + log.Fatalf("Failed to open rabbitmq channel: %v", err) + } + defer ch.Close() + // 声明 Social 交换机和队列 + if err := declareSocialTopology(ch); err != nil { + log.Fatalf("Failed to declare social topology: %v", err) + } + if err := declareLikeTopology(ch); err != nil { + log.Fatalf("Failed to declare like topology: %v", err) + } + if err := declareCommentTopology(ch); err != nil { + log.Fatalf("Failed to declare comment topology: %v", err) + } + if cache != nil { + if err := declarePopularityTopology(ch); 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) + } + + repo := social.NewSocialRepository(sqlDB) + socialWorker := worker.NewSocialWorker(ch, repo, socialQueue) + 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() + + pprofServer, err := observability.NewPprofServer( + "Worker", + cfg.ObservabilityConfig.Pprof.Enabled, + cfg.ObservabilityConfig.Pprof.WorkerAddr, + ) + if err != nil { + log.Printf("Failed to start worker pprof server: %v", err) + } + if pprofServer != nil { + 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) }() + } + + err = <-errCh + if err != nil && err != context.Canceled { + log.Fatalf("Worker stopped: %v", err) + } + log.Printf("Worker stopped") +} + +func declareSocialTopology(ch *amqp.Channel) error { + if err := ch.ExchangeDeclare( + socialExchange, + "topic", + true, + false, + false, + false, + nil, + ); err != nil { + return err + } + + q, err := ch.QueueDeclare( + socialQueue, + true, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + if err := ch.QueueBind( + q.Name, + socialBindingKey, + socialExchange, + false, + nil, + ); err != nil { + return err + } + return nil +} + +func declarePopularityTopology(ch *amqp.Channel) error { + if err := ch.ExchangeDeclare( + popularityExchange, + "topic", + true, + false, + false, + false, + nil, + ); err != nil { + return err + } + + q, err := ch.QueueDeclare( + popularityQueue, + true, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + return ch.QueueBind( + q.Name, + popularityBindingKey, + popularityExchange, + false, + nil, + ) +} + +func declareLikeTopology(ch *amqp.Channel) error { + if err := ch.ExchangeDeclare( + likeExchange, + "topic", + true, + false, + false, + false, + nil, + ); err != nil { + return err + } + + q, err := ch.QueueDeclare( + likeQueue, + true, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + return ch.QueueBind( + q.Name, + likeBindingKey, + likeExchange, + false, + nil, + ) +} + +func declareCommentTopology(ch *amqp.Channel) error { + if err := ch.ExchangeDeclare( + commentExchange, + "topic", + true, + false, + false, + false, + nil, + ); err != nil { + return err + } + + q, err := ch.QueueDeclare( + commentQueue, + true, + false, + false, + false, + nil, + ) + if err != nil { + return err + } + + return ch.QueueBind( + q.Name, + commentBindingKey, + commentExchange, + false, + nil, + ) +} diff --git a/docker-compose.yml b/docker-compose.yml index 3145017..4d1324e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,11 @@ services: condition: service_healthy rabbitmq: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- --post-data='{}' --header='Content-Type: application/json' http://localhost:8080/account/findByID || exit 1"] + interval: 10s + timeout: 5s + retries: 3 worker: build: @@ -87,6 +92,11 @@ services: condition: service_healthy rabbitmq: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "pgrep worker || exit 1"] + interval: 15s + timeout: 5s + retries: 3 frontend: build: @@ -97,6 +107,11 @@ services: - "5173:80" depends_on: - backend + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"] + interval: 10s + timeout: 5s + retries: 3 volumes: mysql_data: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1531b58..7f4bbd9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,99 +1,104 @@ -import { useAuthStore } from '../stores/auth' - -export class ApiError extends Error { - status: number - payload?: unknown - - constructor(message: string, status: number, payload?: unknown) { - super(message) - this.name = 'ApiError' - this.status = status - this.payload = payload - } -} - -type ApiErrorBody = { error?: string } - -const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api' - -export async function postJson(path: string, body: unknown, options?: { authRequired?: boolean }): Promise { - const auth = useAuthStore() - const token = auth.token - - if (options?.authRequired && !token) { - throw new ApiError('需要先登录(缺少 token)', 401) - } - - const headers: Record = { 'Content-Type': 'application/json' } - if (token) headers.Authorization = `Bearer ${token}` - - const res = await fetch(`${API_BASE}${path}`, { - method: 'POST', - headers, - body: JSON.stringify(body ?? {}), - }) - - const text = await res.text() - let data: unknown = null - if (text) { - try { - data = JSON.parse(text) - } catch { - data = text - } - } - - if (!res.ok) { - if (res.status === 401) { - auth.clearToken() - } - const msg = - data && typeof data === 'object' && (data as ApiErrorBody).error - ? String((data as ApiErrorBody).error) - : `请求失败 (${res.status})` - throw new ApiError(msg, res.status, data) - } - - return data as T -} - -export async function postForm(path: string, body: FormData, options?: { authRequired?: boolean }): Promise { - const auth = useAuthStore() - const token = auth.token - - if (options?.authRequired && !token) { - throw new ApiError('需要先登录(缺少 token)', 401) - } - - const headers: Record = {} - if (token) headers.Authorization = `Bearer ${token}` - - const res = await fetch(`${API_BASE}${path}`, { - method: 'POST', - headers, - body, - }) - - const text = await res.text() - let data: unknown = null - if (text) { - try { - data = JSON.parse(text) - } catch { - data = text - } - } - - if (!res.ok) { - if (res.status === 401) { - auth.clearToken() - } - const msg = - data && typeof data === 'object' && (data as ApiErrorBody).error - ? String((data as ApiErrorBody).error) - : `请求失败 (${res.status})` - throw new ApiError(msg, res.status, data) - } - - return data as T -} +import { useAuthStore } from '../stores/auth' +import { reportError } from '../utils/error-reporter' + +export class ApiError extends Error { + status: number + payload?: unknown + + constructor(message: string, status: number, payload?: unknown) { + super(message) + this.name = 'ApiError' + this.status = status + this.payload = payload + } +} + +type ApiErrorBody = { error?: string } + +const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api' + +export async function postJson(path: string, body: unknown, options?: { authRequired?: boolean }): Promise { + const auth = useAuthStore() + const token = auth.token + + if (options?.authRequired && !token) { + throw new ApiError('需要先登录(缺少 token)', 401) + } + + const headers: Record = { 'Content-Type': 'application/json' } + if (token) headers.Authorization = `Bearer ${token}` + + const res = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body ?? {}), + }) + + const text = await res.text() + let data: unknown = null + if (text) { + try { + data = JSON.parse(text) + } catch { + data = text + } + } + + if (!res.ok) { + if (res.status === 401) { + auth.clearToken() + } + const msg = + data && typeof data === 'object' && (data as ApiErrorBody).error + ? String((data as ApiErrorBody).error) + : `请求失败 (${res.status})` + const apiErr = new ApiError(msg, res.status, data) + reportError(apiErr, { path, status: res.status }) + throw apiErr + } + + return data as T +} + +export async function postForm(path: string, body: FormData, options?: { authRequired?: boolean }): Promise { + const auth = useAuthStore() + const token = auth.token + + if (options?.authRequired && !token) { + throw new ApiError('需要先登录(缺少 token)', 401) + } + + const headers: Record = {} + if (token) headers.Authorization = `Bearer ${token}` + + const res = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers, + body, + }) + + const text = await res.text() + let data: unknown = null + if (text) { + try { + data = JSON.parse(text) + } catch { + data = text + } + } + + if (!res.ok) { + if (res.status === 401) { + auth.clearToken() + } + const msg = + data && typeof data === 'object' && (data as ApiErrorBody).error + ? String((data as ApiErrorBody).error) + : `请求失败 (${res.status})` + const apiErr = new ApiError(msg, res.status, data) + reportError(apiErr, { path, status: res.status }) + throw apiErr + } + + return data as T +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6a92eff..d959065 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,10 +1,16 @@ -import { createApp } from 'vue' -import { createPinia } from 'pinia' -import './style.css' -import App from './App.vue' -import router from './router' - -const app = createApp(App) -app.use(createPinia()) -app.use(router) -app.mount('#app') +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import './style.css' +import App from './App.vue' +import router from './router' +import { reportError } from './utils/error-reporter' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) + +app.config.errorHandler = (err, _instance, info) => { + reportError(err instanceof Error ? err : new Error(String(err)), { info }) +} + +app.mount('#app') diff --git a/frontend/src/utils/error-reporter.ts b/frontend/src/utils/error-reporter.ts new file mode 100644 index 0000000..2ebc1e2 --- /dev/null +++ b/frontend/src/utils/error-reporter.ts @@ -0,0 +1,18 @@ +export function reportError(error: Error, context?: Record) { + if (import.meta.env.DEV) { + console.error('[ErrorReporter]', error.message, context) + return + } + fetch('/api/error-report', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: error.message, + stack: error.stack, + context, + timestamp: new Date().toISOString(), + }), + }).catch(() => { + /* 静默失败,避免错误上报自身导致循环 */ + }) +} From 4094a13846011e82689f4edbe72b2d17f8eebb63 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 16:08:43 +0800 Subject: [PATCH 08/23] =?UTF-8?q?perf(P3):=20Feed=20=E6=B5=81=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E6=BB=9A=E5=8A=A8=20=E2=80=94=20=E4=BB=85=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=BD=93=E5=89=8D=C2=B11=E6=9D=A1=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/views/HomeView.vue | 1842 ++++++++++++++++--------------- 1 file changed, 924 insertions(+), 918 deletions(-) diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 1f0360c..ccb3065 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,918 +1,924 @@ - - -