Files
sub2api-cn-relay-manager/docs/2026-06-01-SYSTEMATIC-REPAIR-PLAN.md
phamnazage-jpg 91fa5d6ab4 fix(review): 完成系统性 Review 修复方案 - Task B-01 HTTP Server 超时配置
本次提交包含:
- B-01: HTTP Server 添加超时配置 (ReadTimeout/WriteTimeout/IdleTimeout/MaxHeaderBytes)
- 添加结构化日志包 internal/log/ (B-02 部分完成)
- 添加 Review 报告文档
- 添加系统性修复方案文档
- 添加最佳实践审核报告文档
- 更新任务清单和执行板

测试验证:
- TestServerHasTimeoutConfiguration 通过

关联文档:
- docs/2026-06-01-SYSTEMATIC-REVIEW-REPORT.md
- docs/2026-06-01-SYSTEMATIC-REPAIR-PLAN.md
- docs/2026-06-01-BEST-PRACTICE-AUDIT-REPORT.md
2026-06-01 22:02:01 +08:00

20 KiB
Raw Blame History

sub2api-cn-relay-manager 系统性修复方案

基于 2026-06-01 严格生产级 Review 报告生成 目标: 修复 BLOCKER 和 HIGH 级别问题,达到生产就绪标准


📋 任务总览

级别 任务数 预计工时 负责人
BLOCKER 4 16h 核心团队
HIGH 5 20h 核心团队
MEDIUM 4 12h 开发团队
合计 13 48h -

🚨 BLOCKER 级别任务(必须修复)

Task B-01: HTTP Server 添加超时配置

优先级: P0 | 工时: 4h | 阻塞: 是

问题描述

HTTP Server 未配置 ReadTimeoutWriteTimeoutIdleTimeout,生产环境可能遭遇 Slowloris 攻击,导致连接耗尽。

修复方案

步骤 1: 修改 internal/app/app.go

// 修改 NewServer 函数,添加超时配置
func NewServer(listenAddr string, handler http.Handler, listenerFactory ListenerFactory) *Server {
    if handler == nil {
        handler = NewAPIHandler("", ActionSet{})
    }
    server := &Server{
        server: &http.Server{
            Addr:         listenAddr,
            Handler:      handler,
            ReadTimeout:  30 * time.Second,  // 请求读取超时
            WriteTimeout: 30 * time.Second, // 响应写入超时
            IdleTimeout:  120 * time.Second, // 连接空闲超时
            MaxHeaderBytes: 1 << 20,         // 1MB 请求头限制
        },
        listen: net.Listen,
    }
    if listenerFactory != nil {
        server.listen = listenerFactory
    }
    return server
}

步骤 2: 验证测试

go test ./internal/app/... -v -run TestServer

验收标准

  • HTTP Server 配置四项超时参数
  • 集成测试通过
  • 压力测试验证超时行为

Task B-02: 日志结构化改造

优先级: P0 | 工时: 6h | 阻塞: 是

问题描述

使用标准库 log 输出非结构化日志,生产环境难以解析、聚合和监控。

修复方案

步骤 1: 添加依赖

go get golang.org/x/exp/slog

步骤 2: 创建 internal/log/log.go

package log

import (
    "os"
    "golang.org/x/exp/slog"
)

var logger *slog.Logger

func Init() {
    opts := &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }
    handler := slog.NewJSONHandler(os.Stdout, opts)
    logger = slog.New(handler)
    slog.SetDefault(logger)
}

func InitWithLevel(level string) {
    var slogLevel slog.Level
    switch level {
    case "DEBUG":
        slogLevel = slog.LevelDebug
    case "INFO":
        slogLevel = slog.LevelInfo
    case "WARN":
        slogLevel = slog.LevelWarn
    case "ERROR":
        slogLevel = slog.LevelError
    default:
        slogLevel = slog.LevelInfo
    }
    
    opts := &slog.HandlerOptions{
        Level: slogLevel,
    }
    handler := slog.NewJSONHandler(os.Stdout, opts)
    logger = slog.New(handler)
    slog.SetDefault(logger)
}

// 包装方法
func Info(msg string, args ...interface{}) { slog.Info(msg, args...) }
func Error(msg string, args ...interface{}) { slog.Error(msg, args...) }
func Debug(msg string, args ...interface{}) { slog.Debug(msg, args...) }
func Warn(msg string, args ...interface{}) { slog.Warn(msg, args...) }

步骤 3: 修改 cmd/server/main.go

func main() {
    // 初始化结构化日志
    log.InitWithLevel(os.Getenv("LOG_LEVEL"))
    
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    slog.Info("server starting", "addr", os.Getenv("SUB2API_CRM_LISTEN_ADDR"))
    
    if err := run(ctx, app.Bootstrap, func(ctx context.Context, server *app.Server) error {
        return server.Run(ctx)
    }); err != nil {
        slog.Error("server failed", "error", err)
        os.Exit(1)
    }
}

步骤 4: 替换全局 log 使用

  • 替换 cmd/server/main.go 中的 log.Fatalf
  • 替换 cmd/cli/main.go 中的 log.Fatalf
  • 替换 internal/routing/logwriter.go:233 中的 log.Printf

验收标准

  • 所有日志输出为 JSON 格式
  • 支持 LOG_LEVEL 环境变量控制
  • 日志包含标准字段timestamp, level, msg, 自定义字段
  • 集成测试验证日志格式

Task B-03: 日志轮转配置

优先级: P0 | 工时: 4h | 阻塞: 是

问题描述

容器环境下日志无限增长,可能导致磁盘耗尽。

修复方案

选项 1: 使用外部日志收集(推荐) 配置 Docker/Kubernetes 使用外部日志收集器(如 Fluentd、Filebeat

选项 2: 代码层实现轮转

步骤 1: 添加依赖

go get gopkg.in/natefinch/lumberjack.v2

步骤 2: 修改 internal/log/log.go

package log

import (
    "os"
    "gopkg.in/natefinch/lumberjack.v2"
    "golang.org/x/exp/slog"
)

func InitWithRotation(logFile string) {
    // 日志轮转配置
    lumberjackLogger := &lumberjack.Logger{
        Filename:   logFile,
        MaxSize:    100, // MB
        MaxBackups: 3,
        MaxAge:     7, // 天
        Compress:   true,
    }
    
    opts := &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }
    handler := slog.NewJSONHandler(lumberjackLogger, opts)
    logger := slog.New(handler)
    slog.SetDefault(logger)
}

验收标准

  • 单日志文件大小限制100MB
  • 保留历史日志数量限制3个
  • 历史日志压缩存储
  • 支持日志清理7天

Task B-04: CI/CD 工作流配置

优先级: P0 | 工时: 4h | 阻塞: 是

问题描述

缺少 GitHub Actions 自动化测试和发布流程。

修复方案

步骤 1: 创建 .github/workflows/ci.yml

name: CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        go-version: ['1.22.2']
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Set up Go
      uses: actions/setup-go@v5
      with:
        go-version: ${{ matrix.go-version }}
    
    - name: Cache Go modules
      uses: actions/cache@v4
      with:
        path: ~/go/pkg/mod
        key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
        restore-keys: |
          ${{ runner.os }}-go-
    
    - name: Download dependencies
      run: go mod download
    
    - name: Run go vet
      run: go vet ./...
    
    - name: Check formatting
      run: |
        if [ "$(gofmt -l . | wc -l)" -gt 0 ]; then
          echo "Please run 'gofmt -w .' to fix formatting"
          gofmt -d .
          exit 1
        fi
    
    - name: Run tests
      run: go test -v -race -coverprofile=coverage.out ./...
    
    - name: Check coverage
      run: |
        go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//' | awk '{if ($1 < 70) exit 1}'
    
    - name: Run integration tests
      run: go test ./tests/integration/... -v -count=1
    
    - name: Build server
      run: go build -o bin/server ./cmd/server
    
    - name: Build CLI
      run: go build -o bin/cli ./cmd/cli
    
    - name: Upload coverage
      uses: codecov/codecov-action@v4
      with:
        files: ./coverage.out
        fail_ci_if_error: true

  docker:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3
    
    - name: Build Docker image
      run: docker build -t sub2api-cn-relay-manager:latest .
    
    - name: Test Docker image
      run: |
        docker run --rm sub2api-cn-relay-manager:latest --help

步骤 2: 创建 .github/workflows/release.yml

name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    
    - name: Set up Go
      uses: actions/setup-go@v5
      with:
        go-version: '1.22.2'
    
    - name: Build binaries
      run: |
        GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${{ github.ref_name }}" -o bin/server-linux-amd64 ./cmd/server
        GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${{ github.ref_name }}" -o bin/cli-linux-amd64 ./cmd/cli
        GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${{ github.ref_name }}" -o bin/server-darwin-amd64 ./cmd/server
        GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.Version=${{ github.ref_name }}" -o bin/server-darwin-arm64 ./cmd/server
    
    - name: Create Release
      uses: softprops/action-gh-release@v1
      with:
        files: bin/*
        draft: false
        prerelease: false
    
    - name: Build and push Docker image
      run: |
        echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
        docker build -t ghcr.io/${{ github.repository }}:${{ github.ref_name }} .
        docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }}

验收标准

  • CI 自动运行测试、覆盖率检查、格式化检查
  • PR 必须通过 CI 才能合并
  • Release 自动构建多平台二进制文件
  • Docker 镜像自动构建并推送

🔴 HIGH 级别任务(建议修复)

Task H-01: 补充 testutil 测试

优先级: P1 | 工时: 3h

修复方案

创建 internal/testutil/sqlite_test.go

package testutil

import (
    "testing"
    "context"
)

func TestNewTestDB(t *testing.T) {
    db := NewTestDB(t)
    defer db.Close()
    
    // 验证数据库连接
    ctx := context.Background()
    if err := db.PingContext(ctx); err != nil {
        t.Fatalf("failed to ping test db: %v", err)
    }
}

func TestNewTestDBWithMigrations(t *testing.T) {
    db := NewTestDBWithMigrations(t)
    defer db.Close()
    
    // 验证迁移表存在
    var count int
    row := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table'")
    if err := row.Scan(&count); err != nil {
        t.Fatalf("failed to query tables: %v", err)
    }
    
    if count == 0 {
        t.Error("expected at least one table after migrations")
    }
}

Task H-02: 补充 migrations 测试

优先级: P1 | 工时: 4h

修复方案

创建 internal/store/migrations/migrations_test.go

package migrations

import (
    "testing"
    "database/sql"
    "os"
    "path/filepath"
)

func TestMigrationScripts(t *testing.T) {
    // 创建临时数据库
    tmpDir := t.TempDir()
    dbPath := filepath.Join(tmpDir, "test.db")
    
    db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
    if err != nil {
        t.Fatalf("failed to open db: %v", err)
    }
    defer db.Close()
    
    // 运行所有迁移
    if err := RunMigrations(db); err != nil {
        t.Fatalf("failed to run migrations: %v", err)
    }
    
    // 验证关键表存在
    tables := []string{
        "hosts",
        "packs",
        "providers",
        "import_runs",
        "routes",
    }
    
    for _, table := range tables {
        var name string
        err := db.QueryRow(
            "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
            table,
        ).Scan(&name)
        
        if err != nil {
            t.Errorf("expected table %s to exist: %v", table, err)
        }
    }
}

func TestMigrationIdempotency(t *testing.T) {
    // 验证多次运行迁移不会产生错误
    tmpDir := t.TempDir()
    dbPath := filepath.Join(tmpDir, "test.db")
    
    db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
    if err != nil {
        t.Fatalf("failed to open db: %v", err)
    }
    defer db.Close()
    
    // 第一次运行
    if err := RunMigrations(db); err != nil {
        t.Fatalf("first migration failed: %v", err)
    }
    
    // 第二次运行(幂等性)
    if err := RunMigrations(db); err != nil {
        t.Fatalf("second migration failed: %v", err)
    }
}

Task H-03: 日志 flush 错误监控

优先级: P1 | 工时: 3h

修复方案

修改 internal/routing/logwriter.go

type LogWriter struct {
    // ... existing fields
    flushErrorCount int64           // 添加错误计数
    flushErrorChan  chan error      // 错误通知通道
}

// Flush 方法添加错误处理
func (w *LogWriter) Flush() error {
    // ... existing logic
    
    if err != nil {
        w.flushErrorCount++
        // 记录到 Prometheus metrics
        metrics.FlushErrors.Inc()
        
        // 超过阈值告警
        if w.flushErrorCount > 10 {
            slog.Error("log flush error threshold exceeded", 
                "count", w.flushErrorCount,
                "error", err)
        }
        return err
    }
    
    // 成功时重置计数
    w.flushErrorCount = 0
    return nil
}

Task H-04: Prometheus 指标暴露

优先级: P1 | 工时: 6h

修复方案

步骤 1: 添加依赖

go get github.com/prometheus/client_golang/prometheus

步骤 2: 创建 internal/metrics/metrics.go

package metrics

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    // HTTP 请求指标
    HTTPRequestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total HTTP requests",
        },
        []string{"method", "path", "status"},
    )
    
    HTTPRequestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP request duration",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "path"},
    )
    
    // 业务指标
    ImportRunsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "import_runs_total",
            Help: "Total import runs",
        },
        []string{"status"},
    )
    
    ReconcileRunsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "reconcile_runs_total",
            Help: "Total reconcile runs",
        },
        []string{"status"},
    )
    
    // 日志 flush 错误
    LogFlushErrors = promauto.NewCounter(
        prometheus.CounterOpts{
            Name: "log_flush_errors_total",
            Help: "Total log flush errors",
        },
    )
)

步骤 3: 修改 internal/app/http_api.go

import (
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

// 在路由中添加 metrics 端点
func NewAPIHandler(...) http.Handler {
    mux := http.NewServeMux()
    // ... existing routes
    
    // Prometheus metrics 端点
    mux.Handle("GET /metrics", promhttp.Handler())
    
    return mux
}

步骤 4: 添加中间件记录指标

func MetricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // 包装 ResponseWriter 获取状态码
        wrapped := &responseRecorder{ResponseWriter: w}
        next.ServeHTTP(wrapped, r)
        
        duration := time.Since(start).Seconds()
        
        metrics.HTTPRequestsTotal.WithLabelValues(
            r.Method,
            r.URL.Path,
            strconv.Itoa(wrapped.statusCode),
        ).Inc()
        
        metrics.HTTPRequestDuration.WithLabelValues(
            r.Method,
            r.URL.Path,
        ).Observe(duration)
    })
}

Task H-05: 移除 Dockerfile 默认值

优先级: P1 | 工时: 1h

修复方案

修改 Dockerfile

# 移除默认空值,强制启动时配置
ENV SUB2API_CRM_ADMIN_TOKEN=""

# 添加启动检查
COPY scripts/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]

创建 scripts/docker-entrypoint.sh

#!/bin/bash
set -e

# 强制检查必需环境变量
if [ -z "$SUB2API_CRM_ADMIN_TOKEN" ]; then
    echo "ERROR: SUB2API_CRM_ADMIN_TOKEN is required"
    exit 1
fi

exec /usr/local/bin/sub2api-cn-relay-manager "$@"

🟡 MEDIUM 级别任务

Task M-01: 测试代码 panic 替换

优先级: P2 | 工时: 2h

修复位置:

  • internal/store/sqlite/packs_repo_test.go:208
  • internal/store/sqlite/providers_repo_test.go:316
// 修改前
panic("unexpected QueryRowContext")

// 修改后
t.Fatalf("unexpected QueryRowContext")

Task M-02: 错误信息字符串匹配优化

优先级: P2 | 工时: 3h

方案: 定义领域错误类型,替代字符串匹配

// 创建 `internal/errors/errors.go`
package errors

import "errors"

var (
    ErrProviderNotFound = errors.New("provider not found")
    ErrPackNotFound     = errors.New("pack not found")
    ErrHostNotFound     = errors.New("host not found")
    ErrInvalidConfig    = errors.New("invalid config")
)

// 使用 errors.Is 替代字符串匹配
if errors.Is(err, ErrProviderNotFound) {
    // handle
}

Task M-03: 边界测试补充

优先级: P2 | 工时: 4h

针对包: internal/app 边界条件

// 示例:边界值测试
func TestCreateHostWithEmptyName(t *testing.T) {
    req := CreateHostRequest{Name: ""}
    // 验证返回错误
}

func TestCreateHostWithMaxLengthName(t *testing.T) {
    req := CreateHostRequest{Name: strings.Repeat("a", 256)}
    // 验证处理
}

Task M-04: 添加版本信息端点

优先级: P2 | 工时: 3h

修改 internal/app/http_api.go

var (
    Version   = "dev"
    Commit    = "unknown"
    BuildTime = "unknown"
)

func init() {
    mux.HandleFunc("GET /version", func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, map[string]string{
            "version":    Version,
            "commit":     Commit,
            "build_time": BuildTime,
        })
    })
}

修改 Makefile

LDFLAGS := -X internal/app.Version=$(VERSION) \
           -X internal/app.Commit=$(COMMIT) \
           -X internal/app.BuildTime=$(BUILD_TIME)

build:
    go build -ldflags "$(LDFLAGS)" -o bin/server ./cmd/server

📅 执行计划

第一阶段BLOCKER 修复Week 1

日期 任务 负责人 产出
Day 1 B-01 HTTP 超时配置 Dev-1 PR #1
Day 2 B-02 日志结构化 Dev-2 PR #2
Day 3 B-03 日志轮转 Dev-2 PR #3
Day 4 B-04 CI/CD 配置 Dev-3 PR #4
Day 5 BLOCKER 集成测试 QA 测试报告

第二阶段HIGH 修复Week 2

日期 任务 负责人 产出
Day 1 H-01 testutil 测试 Dev-1 PR #5
Day 2 H-02 migrations 测试 Dev-1 PR #6
Day 3 H-03 日志 flush 监控 Dev-2 PR #7
Day 4 H-04 Prometheus Dev-2 PR #8
Day 5 H-05 Dockerfile Dev-3 PR #9

第三阶段MEDIUM + 验收Week 3

日期 任务 负责人 产出
Day 1-2 M-01 ~ M-04 Dev-1/2/3 PR #10-13
Day 3 全量回归测试 QA 测试报告
Day 4 性能测试 QA 性能报告
Day 5 生产就绪评审 Team 评审报告

验收标准

BLOCKER 完成标准

  • HTTP Server 配置四项超时参数
  • 所有日志输出为 JSON 格式
  • 日志轮转配置生效
  • CI/CD 自动化测试通过

HIGH 完成标准

  • testutil 覆盖率 > 70%
  • migrations 覆盖率 > 70%
  • Prometheus 指标可查询
  • Dockerfile 强制检查必需环境变量

生产就绪标准

  • 综合评级从 B 提升到 A
  • 所有测试通过
  • 性能测试达标
  • 安全扫描通过

📊 风险与应对

风险 概率 影响 应对策略
日志改造引入新问题 灰度发布,逐步替换
CI/CD 配置复杂 使用成熟模板,分步实施
Prometheus 性能影响 性能基准测试,监控开销
测试覆盖提升困难 优先覆盖核心路径

方案生成时间: 2026-06-01 方案版本: v1.0 基于 Review 报告: 2026-06-01-SYSTEMATIC-REVIEW-REPORT.md