Files
VLoop/.sisyphus/plans/optimization-plan.md

1015 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# feedsystem_video_go 全项目优化实施计划
> **For Claude:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task.
**Goal:** 修复 17 项代码审查发现的问题涵盖安全性、数据库性能、MQ 可靠性、代码质量、前端架构,按三批渐进交付。
**Architecture:** 风险驱动分批 — P1 修复直接威胁稳定性的 BugP2 加固安全面和规范化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: 敏感配置迁移至 .envdocker-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<TabKey>('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<number, HTMLVideoElement>()
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<Record<string, boolean>>({})
const followBusy = reactive<Record<string, boolean>>({})
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
<script setup lang="ts">
import { useVideoFeed } from '../composables/useVideoFeed'
import { useVideoPlayer } from '../composables/useVideoPlayer'
import { useLikeFollow } from '../composables/useLikeFollow'
import CommentDrawer from '../components/CommentDrawer.vue'
const { tab, recommend, hot, following, loadRecommend, loadHot, loadFollowing } = useVideoFeed()
const { muted, activeIndex, videoMap, setVideoRef, playActive, toggleMute, togglePlayPause } = useVideoPlayer()
const { likeBusy, followBusy, toggleLike, toggleFollow, share } = useLikeFollow()
// ... watch/onMounted 逻辑用 composable 返回的方法
</script>
```
**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
<section v-for="(item, idx) in filteredItems" ...>
```
改为只渲染 `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
<section
v-for="(item, idx) in filteredItems"
v-show="idx >= visibleRange.start && idx <= visibleRange.end"
...
>
```
**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<string, unknown>) {
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<<i), 30)) * time.Second
log.Printf("%s 不可用,%v 后重试 (%d/%d)...", name, wait, i+1, maxRetries)
time.Sleep(wait)
}
log.Fatalf("%s: 超过最大重试次数", name)
}
```
替换原有的直接 fatal 调用:
```go
// 替换前:
db, err := dbconnect.NewDB(cfg)
if err != nil { log.Fatal(err) }
// 替换后:
var db *gorm.DB
connectWithRetry("MySQL", func() error {
var err error
db, err = dbconnect.NewDB(cfg)
return err
}, 10)
```
**Step 2: 编译验证**
```bash
cd backend && go build ./cmd/worker
```
**Step 3: Commit**
```bash
git add backend/cmd/worker/main.go
git commit -m "fix: Worker 启动时 MySQL/Redis/MQ 不可用改为指数退避重试而非直接 fatal"
```
---
## 验证清单
每完成一批,执行以下验证:
| 批次 | 验证命令 | 预期 |
|------|---------|------|
| P1 | `cd backend && go build ./... && go vet ./...` | 编译通过,无 vet 告警 |
| P2 | `cd backend && go build ./... && go vet ./... && cd ../frontend && npm run build` | 全量构建通过 |
| P3 | `cd backend && go test ./... && cd ../frontend && npm run build && npx vue-tsc --noEmit` | 测试通过 + 类型检查通过 |