diff --git a/.gitignore b/.gitignore index aa1440b..4e39804 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .run/ backend/.run/ +data/ +.sisyphus/ +docs/ # # Binaries for programs and plugins @@ -31,4 +34,3 @@ go.work.sum # Editor/IDE .idea/ .vscode/ - diff --git a/.sisyphus/plans/optimization-plan.md b/.sisyphus/plans/optimization-plan.md deleted file mode 100644 index fe36818..0000000 --- a/.sisyphus/plans/optimization-plan.md +++ /dev/null @@ -1,1014 +0,0 @@ -# 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< {rabbitmq_metadata,rabbit@1ae715dbc287}, - machine => - {module,khepri_machine, - #{member => {rabbitmq_metadata,rabbit@1ae715dbc287}, - store_id => rabbitmq_metadata}}, - friendly_name => "RabbitMQ metadata store", - cluster_name => rabbitmq_metadata,uid => <<"RABBITDRASO8J6QGNT">>, - initial_members => [], - log_init_args => #{uid => <<"RABBITDRASO8J6QGNT">>}, - tick_timeout => 1000,broadcast_time => 100, - install_snap_rpc_timeout => 120000,await_condition_timeout => 30000}. \ No newline at end of file diff --git a/data/mnesia/rabbit@1ae715dbc287/coordination/rabbit@1ae715dbc287/meta.dets b/data/mnesia/rabbit@1ae715dbc287/coordination/rabbit@1ae715dbc287/meta.dets deleted file mode 100644 index d846f51..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/coordination/rabbit@1ae715dbc287/meta.dets and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/coordination/rabbit@1ae715dbc287/names.dets b/data/mnesia/rabbit@1ae715dbc287/coordination/rabbit@1ae715dbc287/names.dets deleted file mode 100644 index 27d30cb..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/coordination/rabbit@1ae715dbc287/names.dets and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/.config b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/.config deleted file mode 100644 index 0c886cd..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/.config +++ /dev/null @@ -1,2 +0,0 @@ -%% This file is auto-generated! Edit at your own risk! -{segment_entry_count, 2048}. \ No newline at end of file diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/.vhost b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/.vhost deleted file mode 100644 index 35ec3b9..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/.vhost +++ /dev/null @@ -1 +0,0 @@ -/ \ No newline at end of file diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/0.rdq b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/0.rdq deleted file mode 100644 index e69de29..0000000 diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/clean.dot b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/clean.dot deleted file mode 100644 index 21062c2..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/clean.dot +++ /dev/null @@ -1,2 +0,0 @@ -{client_refs,[<<64,206,177,233,7,189,195,164,71,45,63,18,117,108,68,115>>]}. -{index_module,rabbit_msg_store_ets_index}. diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/file_summary.ets b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/file_summary.ets deleted file mode 100644 index d9f4b07..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/file_summary.ets and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/msg_store_index.ets b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/msg_store_index.ets deleted file mode 100644 index 32d7632..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent/msg_store_index.ets and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/0.rdq b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/0.rdq deleted file mode 100644 index e69de29..0000000 diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/clean.dot b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/clean.dot deleted file mode 100644 index 537b343..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/clean.dot +++ /dev/null @@ -1,2 +0,0 @@ -{client_refs,[]}. -{index_module,rabbit_msg_store_ets_index}. diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/file_summary.ets b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/file_summary.ets deleted file mode 100644 index 79e4eae..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/file_summary.ets and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/msg_store_index.ets b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/msg_store_index.ets deleted file mode 100644 index cd659e2..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient/msg_store_index.ets and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/queues/DIH3SR5T3GFJ86CWM34SYIQ8P/.queue_name b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/queues/DIH3SR5T3GFJ86CWM34SYIQ8P/.queue_name deleted file mode 100644 index a530e60..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/queues/DIH3SR5T3GFJ86CWM34SYIQ8P/.queue_name +++ /dev/null @@ -1,2 +0,0 @@ -VHOST: / -QUEUE: task_queue diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/queues/DIH3SR5T3GFJ86CWM34SYIQ8P/journal.jif b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/queues/DIH3SR5T3GFJ86CWM34SYIQ8P/journal.jif deleted file mode 100644 index e69de29..0000000 diff --git a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/recovery.dets b/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/recovery.dets deleted file mode 100644 index a6c46fd..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/recovery.dets and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/node-type.txt b/data/mnesia/rabbit@1ae715dbc287/node-type.txt deleted file mode 100644 index 342cbf2..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/node-type.txt +++ /dev/null @@ -1 +0,0 @@ -disc. diff --git a/data/mnesia/rabbit@1ae715dbc287/nodes_running_at_shutdown b/data/mnesia/rabbit@1ae715dbc287/nodes_running_at_shutdown deleted file mode 100644 index 217b036..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/nodes_running_at_shutdown +++ /dev/null @@ -1 +0,0 @@ -[rabbit@1ae715dbc287]. diff --git a/data/mnesia/rabbit@1ae715dbc287/quorum/rabbit@1ae715dbc287/00000003.wal b/data/mnesia/rabbit@1ae715dbc287/quorum/rabbit@1ae715dbc287/00000003.wal deleted file mode 100644 index 698b19c..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/quorum/rabbit@1ae715dbc287/00000003.wal +++ /dev/null @@ -1 +0,0 @@ -RAWA \ No newline at end of file diff --git a/data/mnesia/rabbit@1ae715dbc287/quorum/rabbit@1ae715dbc287/meta.dets b/data/mnesia/rabbit@1ae715dbc287/quorum/rabbit@1ae715dbc287/meta.dets deleted file mode 100644 index c1ae47e..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/quorum/rabbit@1ae715dbc287/meta.dets and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/quorum/rabbit@1ae715dbc287/names.dets b/data/mnesia/rabbit@1ae715dbc287/quorum/rabbit@1ae715dbc287/names.dets deleted file mode 100644 index c1ae47e..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/quorum/rabbit@1ae715dbc287/names.dets and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/rabbit_durable_exchange.DCD b/data/mnesia/rabbit@1ae715dbc287/rabbit_durable_exchange.DCD deleted file mode 100644 index a50dd07..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/rabbit_durable_exchange.DCD and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/rabbit_durable_queue.DCD b/data/mnesia/rabbit@1ae715dbc287/rabbit_durable_queue.DCD deleted file mode 100644 index 213522f..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/rabbit_durable_queue.DCD and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/rabbit_durable_route.DCD b/data/mnesia/rabbit@1ae715dbc287/rabbit_durable_route.DCD deleted file mode 100644 index f8dd237..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/rabbit_durable_route.DCD +++ /dev/null @@ -1 +0,0 @@ -cXM \ No newline at end of file diff --git a/data/mnesia/rabbit@1ae715dbc287/rabbit_runtime_parameters.DCD b/data/mnesia/rabbit@1ae715dbc287/rabbit_runtime_parameters.DCD deleted file mode 100644 index b2b6da4..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/rabbit_runtime_parameters.DCD and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/rabbit_serial b/data/mnesia/rabbit@1ae715dbc287/rabbit_serial deleted file mode 100644 index 358e77b..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/rabbit_serial +++ /dev/null @@ -1 +0,0 @@ -3. diff --git a/data/mnesia/rabbit@1ae715dbc287/rabbit_topic_permission.DCD b/data/mnesia/rabbit@1ae715dbc287/rabbit_topic_permission.DCD deleted file mode 100644 index f8dd237..0000000 --- a/data/mnesia/rabbit@1ae715dbc287/rabbit_topic_permission.DCD +++ /dev/null @@ -1 +0,0 @@ -cXM \ No newline at end of file diff --git a/data/mnesia/rabbit@1ae715dbc287/rabbit_user.DCD b/data/mnesia/rabbit@1ae715dbc287/rabbit_user.DCD deleted file mode 100644 index 94cf309..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/rabbit_user.DCD and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/rabbit_user_permission.DCD b/data/mnesia/rabbit@1ae715dbc287/rabbit_user_permission.DCD deleted file mode 100644 index 4959afd..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/rabbit_user_permission.DCD and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/rabbit_vhost.DCD b/data/mnesia/rabbit@1ae715dbc287/rabbit_vhost.DCD deleted file mode 100644 index 1cb0ec1..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/rabbit_vhost.DCD and /dev/null differ diff --git a/data/mnesia/rabbit@1ae715dbc287/schema.DAT b/data/mnesia/rabbit@1ae715dbc287/schema.DAT deleted file mode 100644 index 1078c91..0000000 Binary files a/data/mnesia/rabbit@1ae715dbc287/schema.DAT and /dev/null differ diff --git a/docs/plans/2025-04-25-features-design.md b/docs/plans/2025-04-25-features-design.md deleted file mode 100644 index 78d9eb8..0000000 --- a/docs/plans/2025-04-25-features-design.md +++ /dev/null @@ -1,90 +0,0 @@ -# 用户体系 + 社交深化 设计文档 - -> **日期**: 2025-04-25 -> **状态**: 待实施 -> **方案**: 依赖驱动分批(3 阶段) - -## 概述 - -在现有短视频 Feed 系统基础上,扩展用户 profile 体系(头像、简介、Refresh Token、主页统计)和社交互动能力(通知、私信、话题、@提及)。 - ---- - -## P1 — 用户基石(4 项) - -### 1. 头像上传 + 个人简介 - -**Account 模型扩展**: -```go -AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"` -Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"` -``` - -**新增接口**: -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/account/uploadAvatar` | multipart 上传头像,校验类型/大小,存 `.run/uploads/avatars/{id}/` | -| POST | `/account/updateProfile` | JSON `{ avatar_url?, bio? }` 更新当前用户 | - -**前端**: UserAvatar 组件支持 `src`,AccountView 加头像上传 + bio 编辑,Feed 卡片显示头像。 - -### 2. 登录态优化(Refresh Token) - -**双 Token**: -- Access Token: 15min 过期 -- Refresh Token: 7天过期,落库 + Redis 缓存 - -**新增接口**: `POST /account/refresh` — 接收 refresh token 返回新 access token - -**前端**: auth store 存双 token,client.ts 401 自动刷新,登录页"记住我"。 - ---- - -## P2 — Feed 可见 + 通知(3 项) - -### 3. 粉丝数 / 关注数展示 - -**后端**: 社交接口返回中加 `follower_count` / `vlogger_count`,通过聚合查询计数。 - -**前端**: UserProfileView 和 Feed 卡片显示粉丝数。 - -### 4. 用户主页增强 - -**后端**: `POST /account/getProfile` — 返回用户信息 + 视频列表 + 获赞总数。 - -**前端**: UserProfileView 加视频列表网格 + 统计卡片。 - -### 5. 实时消息通知 - -**架构**: 复用 MQ 事件(like.events / comment.events / social.events)→ NotificationWorker 消费 → 写 `Notification` 表 + WebSocket 推送。 - -**新增表**: `Notification` — id, recipient_id, sender_id, type, target_id, is_read, created_at。 - -**新增接口**: -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/notification/list` | 返回当前用户未读通知列表 | -| POST | `/notification/markRead` | 标记单条/全部已读 | -| GET | `/ws/notifications` | WebSocket 升级,实时推送 | - -**前端**: AppShell 右上角通知铃铛 + 未读红点。 - ---- - -## P3 — 互动深化(3 项) - -### 6. 私信 / 即时通讯 - -**新增表**: `Message` — id, from_id, to_id, content, is_read, created_at - -**后端**: WebSocket 双向通道,`POST /message/send` + `POST /message/list` - -### 7. #话题标签 - -**新增表**: `Tag` — id, name (unique);`VideoTag` — video_id, tag_id - -**改动**: 视频发布时从 title/description 中提取 `#xxx`,写入 `VideoTag` 关联表;`POST /feed/listByTag` 按话题浏览。 - -### 8. @提及 - -**改动**: 评论发布时解析 `@username`,创建 Notification 并推送。 diff --git a/docs/plans/2025-04-25-optimization-design.md b/docs/plans/2025-04-25-optimization-design.md deleted file mode 100644 index 0bb7e10..0000000 --- a/docs/plans/2025-04-25-optimization-design.md +++ /dev/null @@ -1,149 +0,0 @@ -# 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` + 冒烟测试。 diff --git a/docs/plans/2025-04-25-p1-implementation.md b/docs/plans/2025-04-25-p1-implementation.md deleted file mode 100644 index 2774179..0000000 --- a/docs/plans/2025-04-25-p1-implementation.md +++ /dev/null @@ -1,411 +0,0 @@ -# P1 用户基石 实施计划 - -> **For Claude:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. - -**Goal:** 扩展 Account 模型(头像+简介)、实现双 Token 登录态优化(Access + Refresh Token) - -**Architecture:** Account 模型加 avatar_url/bio/refresh_token 字段;复用 UploadCover 的文件上传逻辑做头像;JWT 双 Token 机制 — Access 15min / Refresh 7天 - -**Tech Stack:** Go + Gin + GORM + JWT + Vue 3 + Pinia - ---- - -### Task 1: Account 模型扩展 - -**Files:** -- Modify: `backend/internal/account/entity.go:3-8` - -**Step 1: 修改 Account struct** - -```go -type Account struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"unique" json:"username"` - Password string `json:"-"` - Token string `json:"-"` - RefreshToken string `json:"-"` - AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"` - Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"` -} -``` - -**Step 2: 编译验证** - -Run: `go build ./...` -Expected: 编译通过(AutoMigrate 自动加列) - -**Step 3: Commit** - -```bash -git add backend/internal/account/entity.go -git commit -m "feat: Account 模型加 avatar_url/bio/refresh_token 字段" -``` - ---- - -### Task 2: 头像上传 Handler - -**Files:** -- Modify: `backend/internal/account/handler.go` — 新增 UploadAvatar 方法 -- Modify: `backend/internal/http/router.go` — 注册路由 - -**Step 1: 添加 UploadAvatar handler** - -参考 `video/video_handler.go` 的 `UploadCover`,在 `account/handler.go` 中新增: - -```go -func (ah *AccountHandler) UploadAvatar(c *gin.Context) { - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, 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 allowed"}) - return - } - dir := filepath.Join(".run", "uploads", "avatars", strconv.FormatUint(uint64(accountID), 10)) - if err := os.MkdirAll(dir, 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": err.Error()}) - return - } - filename = filename + ext - absPath := filepath.Join(dir, filename) - if err := c.SaveUploadedFile(f, absPath); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - urlPath := path.Join("/static", "avatars", strconv.FormatUint(uint64(accountID), 10), filename) - avatarURL := buildAbsoluteURL(c, urlPath) - - // 更新数据库 - if err := ah.accountService.UpdateAvatar(c.Request.Context(), accountID, avatarURL); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"avatar_url": avatarURL}) -} -``` - -需要新增 import: `"os"`, `"path"`, `"path/filepath"`, `"crypto/rand"`, `"encoding/hex"`, `"strconv"`, `"strings"`, `"net/http"` — 但 account/handler.go 已有部分,按需补。 - -同时需要从 `video_handler.go` 复制 `randHex` 和 `buildAbsoluteURL` 函数(或提取到公共 util)。 - -**Step 2: 在 router.go 注册路由** - -```go -protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar) -``` - -**Step 3: 添加 AccountService.UpdateAvatar 方法** - -```go -func (as *AccountService) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error { - return as.accountRepo.UpdateAvatar(ctx, accountID, avatarURL) -} -``` - -**Step 4: 添加 AccountRepository.UpdateAvatar 方法** - -```go -func (ar *AccountRepository) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error { - return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", accountID).Update("avatar_url", avatarURL).Error -} -``` - -**Step 5: 编译验证** - -Run: `go build ./...` -Expected: 通过 - -**Step 6: Commit** - -```bash -git add backend/internal/account/handler.go backend/internal/account/service.go backend/internal/account/repo.go backend/internal/http/router.go -git commit -m "feat: 头像上传接口 /account/uploadAvatar" -``` - ---- - -### Task 3: 更新个人简介接口 - -**Files:** -- Modify: `backend/internal/account/handler.go` — 新增 UpdateProfile -- Modify: `backend/internal/http/router.go` — 注册路由 - -**Step 1: 新增 request struct + handler** - -在 `entity.go` 加: -```go -type UpdateProfileRequest struct { - AvatarURL string `json:"avatar_url"` - Bio string `json:"bio"` -} -``` - -Handler: -```go -func (ah *AccountHandler) UpdateProfile(c *gin.Context) { - accountID, err := jwt.GetAccountID(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - var req UpdateProfileRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := ah.accountService.UpdateProfile(c.Request.Context(), accountID, &req); err != nil { - c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "profile updated"}) -} -``` - -**Step 2: Service + Repo 层** - -```go -func (as *AccountService) UpdateProfile(ctx context.Context, accountID uint, req *UpdateProfileRequest) error { - updates := map[string]interface{}{} - if req.Bio != "" { - updates["bio"] = strings.TrimSpace(req.Bio) - } - if req.AvatarURL != "" { - updates["avatar_url"] = strings.TrimSpace(req.AvatarURL) - } - if len(updates) == 0 { - return errors.New("nothing to update") - } - return as.accountRepo.UpdateFields(ctx, accountID, updates) -} -``` - -**Step 3: 注册路由** - -```go -protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile) -``` - -**Step 4: 编译 + 提交** - -Run: `go build ./...` -Expected: 通过 - -```bash -git add backend/internal/account/ && git add backend/internal/http/router.go -git commit -m "feat: 个人简介更新接口 /account/updateProfile" -``` - ---- - -### Task 4: Refresh Token 机制 - -**Files:** -- Modify: `backend/internal/auth/jwt.go` — 新增 GenerateRefreshToken + ValidateRefreshToken -- Modify: `backend/internal/account/handler.go` — 新增 Refresh handler -- Modify: `backend/internal/account/service.go` — Login 返回双 token -- Modify: `backend/internal/http/router.go` — 注册 refresh 路由 - -**Step 1: auth/jwt.go 增加 Refresh Token** - -```go -const ( - AccessTokenTTL = 15 * time.Minute - RefreshTokenTTL = 7 * 24 * time.Hour -) - -func GenerateAccessToken(accountID uint, username string) (string, error) { - // 原 GenerateToken 逻辑,TTL 改为 15min -} - -func GenerateRefreshToken(accountID uint) (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} -``` - -**Step 2: Login 返回双 token** - -修改 `account/service.go` 的 `Login` 方法,返回值从 `(string, error)` 改为 `(accessToken, refreshToken string, err error)`,并更新 `entity.go` 中的 `LoginResponse`。 - -```go -type LoginResponse struct { - Token string `json:"token"` // access token - RefreshToken string `json:"refresh_token"` // refresh token - AccountID uint `json:"account_id"` - Username string `json:"username"` -} -``` - -Login 时生成两个 token,access token 落库 `account.token`,refresh token 落库 `account.refresh_token`,两者都缓存到 Redis。 - -**Step 3: Refresh handler** - -新增 `POST /account/refresh`: - -```go -type RefreshRequest struct { - RefreshToken string `json:"refresh_token"` -} - -func (ah *AccountHandler) Refresh(c *gin.Context) { - var req RefreshRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - newAccessToken, err := ah.accountService.RefreshAccessToken(c.Request.Context(), req.RefreshToken) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) - return - } - c.JSON(http.StatusOK, gin.H{"token": newAccessToken}) -} -``` - -`AccountService.RefreshAccessToken`:查 Redis `account:{id}:refresh` → 匹配 → 生成新 access token → 更新 token 字段。 - -**Step 4: 登出/改密时同时清空 refresh_token** - -在 `Logout` 和 `ChangePassword` 中增加 `Del("account:{id}:refresh")`。 - -**Step 5: 编译 + 提交** - -```bash -git add backend/internal/auth/jwt.go backend/internal/account/ -git commit -m "feat: Refresh Token 机制 — Access 15min + Refresh 7天" -``` - ---- - -### Task 5: 前端 auth store + client.ts 适配双 Token - -**Files:** -- Modify: `frontend/src/stores/auth.ts` -- Modify: `frontend/src/api/client.ts` -- Modify: `frontend/src/api/account.ts` - -**Step 1: auth store 存储双 token** - -```typescript -const ACCESS_KEY = 'access_token' -const REFRESH_KEY = 'refresh_token' - -// 新增字段 -const refreshToken = ref(readToken(REFRESH_KEY)) - -function setTokens(access: string, refresh: string) { - token.value = access; refreshToken.value = refresh - writeToken(ACCESS_KEY, access); writeToken(REFRESH_KEY, refresh) -} - -function clearTokens() { - token.value = null; refreshToken.value = null - removeToken(ACCESS_KEY); removeToken(REFRESH_KEY) -} -``` - -**Step 2: client.ts 401 自动刷新** - -```typescript -async function tryRefresh(): Promise { - const auth = useAuthStore() - if (!auth.refreshToken) return null - try { - const res = await postJson<{ token: string }>('/account/refresh', { refresh_token: auth.refreshToken }) - auth.setToken(res.token) - return res.token - } catch { - auth.clearTokens() - return null - } -} -``` - -在 `postJson` 和 `postForm` 的 `!res.ok` 分支中,401 时先尝试刷新,成功则重试原请求。 - -**Step 3: 编译验证** - -Run: `npm run build` -Expected: 通过 - -**Step 4: Commit** - -```bash -git add frontend/src/stores/auth.ts frontend/src/api/client.ts frontend/src/api/account.ts -git commit -m "feat: 前端双 Token 适配 — 401 自动刷新 + Refresh Token 存储" -``` - ---- - -### Task 6: 前端用户 Profile UI - -**Files:** -- Modify: `frontend/src/views/AccountView.vue` -- Modify: `frontend/src/components/UserAvatar.vue` -- Modify: `frontend/src/views/HomeView.vue` — Feed 卡片中 UserAvatar 传递头像 URL -- Modify: `frontend/src/api/account.ts` — 新增 API 调用 - -**Step 1: UserAvatar 支持 src** - -```vue - - -``` - -**Step 2: AccountView 加头像上传 + bio 编辑** - -在登录后的 AccountView 中增加:头像上传按钮(调用 `/account/uploadAvatar`)、bio 编辑输入框(调用 `/account/updateProfile`)。 - -**Step 3: 编译验证** - -Run: `npm run build` -Expected: 通过 - -**Step 4: Commit** - -```bash -git add frontend/src/components/UserAvatar.vue frontend/src/views/AccountView.vue frontend/src/views/HomeView.vue frontend/src/api/account.ts -git commit -m "feat: 前端用户 Profile UI — 头像上传 + bio 编辑 + 登录记住我" -``` - ---- - -## 验证清单 - -完成所有 Task 后: - -```bash -cd backend && go build ./... && go vet ./... && go test ./... -cd frontend && npm run build -``` - -Expected: 全部通过 diff --git a/frontend/index.html b/frontend/index.html index f0c77aa..fe569d4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,6 @@ - ShortVideo diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg deleted file mode 100644 index 770e9d3..0000000 --- a/frontend/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue deleted file mode 100644 index a3281aa..0000000 --- a/frontend/src/components/HelloWorld.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - -