From 5c54cc3f53f95d55bc4f128e4aac7b45b272b065 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Apr 2026 15:22:45 +0800 Subject: [PATCH] =?UTF-8?q?plan:=20=E5=85=A8=E9=A1=B9=E7=9B=AE=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=EF=BC=8817?= =?UTF-8?q?=E9=A1=B9=EF=BC=8C=E4=B8=89=E6=89=B9=E4=BA=A4=E4=BB=98=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<