Skip to content

[Bug] CDN 批量上传单文件失败被静默吞错,部署声称成功但线上图片可能大面积 404 #44

@Tespera

Description

@Tespera

Gridea Pro 版本

main 分支 HEAD (b84db26)

操作系统

macOS (Apple Silicon)

系统版本

Darwin 25.4.0(问题与平台无关)

优先级

P1(数据一致性 / 用户信任)

Bug 描述

backend/internal/service/cdn_upload_service.go:364-380UploadMediaForDeploy 里:

for _, file := range filesToUpload {
    f := file
    g.Go(func() error {
        sem <- struct{}{}
        defer func() { <-sem }()

        if err := s.uploadToGitHub(gCtx, setting, f.localPath, f.remotePath); err != nil {
            logger(fmt.Sprintf(\"上传 %s 失败: %v\", f.remotePath, err))
            return nil // 单个文件失败不中断整个上传
        }

        mu.Lock()
        uploadCount++
        mu.Unlock()
        return nil
    })
}

if err := g.Wait(); err != nil {
    return fmt.Errorf(\"上传媒体文件失败: %w\", err)
}

logger(fmt.Sprintf(\"CDN 上传完成共上传 %d 个文件\", uploadCount))

问题细节

  1. return nil——失败不中断。设计意图可以理解(不让一张图挂掉整个部署),但实现有多处缺陷:
  2. 上层 DeployService.DeployToRemotedeploy_service.go:92-98)对 CDN 错误全部降级为 warning
    if err := s.cdnUploadService.UploadMediaForDeploy(...); err != nil {
        s.log(ctx, fmt.Sprintf(\"CDN upload warning: %v\", err))
    }
    即便返回了错误也不阻止部署继续。
  3. 结合起来:假设 100 张图有 50 张上传失败,最终用户看到的 toast 是"同步成功"——但实际上线上站点打开后,50 张图全是 404(jsdelivr 或自定义 CDN URL 都取不到)。

复现步骤

  1. 配置 CDN 为 GitHub + jsdelivr,写入一个错误 token(或权限只读)。
  2. post-images/ 里放几十张图。
  3. 点发布。
  4. 结果:toast 提示"同步成功",但访问站点时图片全 404;打开控制台能看到背后刷的失败日志,但 UI 上毫无指示。

期望行为

  • 失败文件被显式汇总并返回给调用方
  • 失败比例或数量超过阈值(例如 > 10% 或 > 5 个)时中止部署,弹窗询问用户"忽略失败继续 / 重试失败项 / 取消本次部署"。
  • 即便少量失败也在最终 UI 上显示"上传 95/100 成功,5 个失败(可展开查看)"。
  • 失败的图片路径列表保留在 store 中,可复制用于排查。

实际行为

失败静默,用户不知情;部署声称成功但线上图片大面积 404。

截图 / 日志

无。

补充信息 — 最佳解决方案

1. 重构返回值

type UploadResult struct {
    Success  int
    Failed   []UploadFailure
    Total    int
}

type UploadFailure struct {
    Path  string
    Error string
    // 可选:HTTP 状态码等元信息
}

func (s *CdnUploadService) UploadMediaForDeploy(ctx context.Context, appDir string, eventSink func(DeployEvent)) (UploadResult, error)

errgroup 改为收集 []UploadFailure(带锁),不因单文件失败终止整组:

var (
    failuresMu sync.Mutex
    failures   []UploadFailure
)

for _, file := range filesToUpload {
    f := file
    g.Go(func() error {
        if err := ctx.Err(); err != nil { return err }
        if err := s.uploadToGitHub(gCtx, setting, f.localPath, f.remotePath); err != nil {
            failuresMu.Lock()
            failures = append(failures, UploadFailure{Path: f.remotePath, Error: err.Error()})
            failuresMu.Unlock()
            return nil
        }
        atomic.AddInt32(&successCount, 1)
        return nil
    })
}

2. 阈值策略 & 用户确认

DeployService.DeployToRemote 里拿到 UploadResult 后:

if len(result.Failed) > 0 {
    ratio := float64(len(result.Failed)) / float64(result.Total)
    if ratio > s.cdnFailureThreshold() || len(result.Failed) > s.cdnFailureAbsoluteCap() {
        // emit 事件 \"需要用户决策\"
        choice := s.askUser(ctx, result)
        switch choice {
        case \"retry\":
            // 只对失败集合重新上传一次
        case \"abort\":
            return fmt.Errorf(\"CDN 上传失败过多: %d/%d\", len(result.Failed), result.Total)
        case \"ignore\":
            // 继续部署,但在最终摘要里显眼标出
        }
    }
}

阈值默认 threshold=0.1absoluteCap=5,写入设置可调。

3. 前端最终摘要

部署结束的 toast / modal 要展示:

  • 成功数 / 总数。
  • 失败列表(可展开),每行显示路径 + 简化错误原因。
  • 提供"复制失败列表"、"仅重试失败项"两个按钮。

4. 单测

  • 构造一个 uploadToGitHub 注入 stub,模拟 50% 失败率,验证:
    • UploadResult.Failed 长度正确;
    • 阈值策略能触发 askUser;
    • 仅重试失败项时不会重复上传成功项。

影响文件:

  • backend/internal/service/cdn_upload_service.go
  • backend/internal/service/deploy_service.go(阈值处理、askUser 协议)
  • backend/internal/domain/errors.go(新增错误键)
  • 前端:部署摘要 modal / drawer。

检查清单

  • 我已搜索过现有 Issue,确认此 Bug 尚未被报告过。
  • 我正在使用最新版本的 Gridea Pro。

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1中优先级 / 强烈建议处理bugSomething isn't workingtriage待分诊

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions