From 16e4f0faf8807b9df534f2fdfdcc9f7348d96eb6 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 3 Sep 2026 16:46:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A7=86=E9=A2=91=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8F=90=E5=89=8D=E6=B8=85=E7=A9=BA=EF=BC=8C?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E9=87=8D=E7=BD=AE=E6=AE=8B=E7=95=99=E7=9A=84?= =?UTF-8?q?=20running=20=E7=8A=B6=E6=80=81=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机使用时发现 55066525387 的子目录里有视频文件,界面上「目录」却不可点。 查明是取视频跑到一半程序被重启,videos 表里该商品的记录已被删除、 新记录还没写回,磁盘有文件而库里无记录。 缺陷一:prepareVideoFetch 在开始下载前先做了一次 ReplaceVideos(product.ID, nil, ...)。而 work.Finalize 里本来就有一次 ReplaceVideos(product.ID, records, now),ReplaceVideos 的语义就是按 product_id 全量替换。前面那次并不能少做任何事,唯一效果是把「文件已下载 但记录已被删」的窗口从零扩大到整个下载过程,任务停止、程序重启和崩溃 都会落进去。删掉它。 原注释说明作者考虑过这个风险,但只防住了登录失效一种情况。 行为变化是期望的:取视频在下载阶段之前失败时,旧的视频记录会保留而不是 被清空。那些文件确实还在磁盘上,「这次没找到视频」由 products.video_status 表达,不该靠删 videos 记录来表达。 缺陷二:进程退出后 download_status / upload_status 仍停留在 running, 但没有任何任务在跑,是个不会自愈的假状态。启动时在同一事务内把这两个字段 的 running 重置为 pending,只动 running,不碰 video_status 和 videos 表。 重置失败只记 Warn,不中断启动。 按负责人要求未对 cmsp.db 做任何手工订正。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq --- app.go | 11 ++++-- internal/store/product.go | 33 ++++++++++++++++ internal/store/product_test.go | 70 ++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/app.go b/app.go index aae835f..6ea7dd3 100644 --- a/app.go +++ b/app.go @@ -96,6 +96,11 @@ func (a *App) startup(ctx context.Context) { } a.db = db a.log.Success("数据库就绪:%s", dbPath) + if count, err := a.db.ResetRunningStatuses(); err != nil { + a.log.Warn("启动时重置残留的运行中状态失败:%v", err) + } else if count > 0 { + a.log.Info("启动时重置了 %d 个残留的运行中状态", count) + } // 未配置账号时不要刷新。同事第一次安装还没填账号就启动, // 同步刷新必然失败,一开机就弹「登录失败」,体验很差。 @@ -437,10 +442,6 @@ func (a *App) prepareVideoFetch(ctx context.Context, productID string, riskGuard return result, task.Work{}, fmt.Errorf("%w:%s", task.ErrLoginRequired, login.Message) } - // 登录通过后才清理旧记录,避免一次登录失效把已下载记录抹掉。 - if err := a.db.ReplaceVideos(product.ID, nil, time.Now().Format("2006-01-02 15:04:05")); err != nil { - return result, task.Work{}, err - } if err := a.db.UpdateProductStatus(product.ID, store.VideoPending, store.DownloadRunning, "", ""); err != nil { return result, task.Work{}, err } @@ -578,6 +579,8 @@ func (a *App) prepareVideoFetch(ctx context.Context, productID string, riskGuard firstDownloadError = downloadErr } } + // 视频记录只在全部下载结束后替换。ReplaceVideos 会按商品全量覆盖, + // 提前清空只会让任务中断、重启或崩溃时留下磁盘有文件而库中无记录的窗口。 if err := a.db.ReplaceVideos(product.ID, records, now); err != nil { return a.failVideoFetch(product.ID, err) } diff --git a/internal/store/product.go b/internal/store/product.go index c205a39..3ad9a3b 100644 --- a/internal/store/product.go +++ b/internal/store/product.go @@ -322,6 +322,39 @@ func (s *Store) UpdateProductStatus(id, videoStatus, downloadStatus, uploadStatu return nil } +// ResetRunningStatuses 把上次进程残留的运行中状态改回待处理。 +func (s *Store) ResetRunningStatuses() (int, error) { + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("开启重置运行中状态事务失败:%w", err) + } + defer tx.Rollback() + + downloadResult, err := tx.Exec(`UPDATE products SET download_status = ? WHERE download_status = ?`, + DownloadPending, DownloadRunning) + if err != nil { + return 0, fmt.Errorf("重置下载运行中状态失败:%w", err) + } + uploadResult, err := tx.Exec(`UPDATE products SET upload_status = ? WHERE upload_status = ?`, + UploadPending, UploadRunning) + if err != nil { + return 0, fmt.Errorf("重置上传运行中状态失败:%w", err) + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("提交重置运行中状态事务失败:%w", err) + } + + downloadCount, err := downloadResult.RowsAffected() + if err != nil { + return 0, fmt.Errorf("读取重置下载状态数量失败:%w", err) + } + uploadCount, err := uploadResult.RowsAffected() + if err != nil { + return 0, fmt.Errorf("读取重置上传状态数量失败:%w", err) + } + return int(downloadCount + uploadCount), nil +} + // CountResettableNoneProducts 返回当前被标记为「无同款视频」的商品数量。 func (s *Store) CountResettableNoneProducts() (int, error) { var count int diff --git a/internal/store/product_test.go b/internal/store/product_test.go index c3597e3..a85893d 100644 --- a/internal/store/product_test.go +++ b/internal/store/product_test.go @@ -6,6 +6,76 @@ import ( "testing" ) +func Test重置残留运行中状态只影响运行中字段(t *testing.T) { + s := newTestStore(t) + items := []Product{ + {ID: "下载运行中", ItemID: "1", VideoDiagnosis: VideoDiagnosisMissing}, + {ID: "上传运行中", ItemID: "2", VideoDiagnosis: VideoDiagnosisOK}, + {ID: "两个都运行中", ItemID: "3", VideoDiagnosis: VideoDiagnosisMissing}, + {ID: "已完成", ItemID: "4", VideoDiagnosis: VideoDiagnosisOK}, + {ID: "已失败", ItemID: "5", VideoDiagnosis: VideoDiagnosisMissing}, + {ID: "待处理", ItemID: "6", VideoDiagnosis: VideoDiagnosisOK}, + {ID: "none状态", ItemID: "7", VideoDiagnosis: VideoDiagnosisMissing}, + } + if err := s.UpsertProducts(items, "2026-09-03 10:00:00"); err != nil { + t.Fatalf("写入商品失败:%v", err) + } + statuses := map[string]struct{ download, upload string }{ + "下载运行中": {DownloadRunning, UploadDone}, + "上传运行中": {DownloadDone, UploadRunning}, + "两个都运行中": {DownloadRunning, UploadRunning}, + "已完成": {DownloadDone, UploadDone}, + "已失败": {DownloadFailed, UploadFailed}, + "待处理": {DownloadPending, UploadPending}, + "none状态": {"none", "none"}, + } + for id, status := range statuses { + if err := s.UpdateProductStatus(id, "", status.download, status.upload, ""); err != nil { + t.Fatalf("准备商品 %s 状态失败:%v", id, err) + } + } + if _, err := s.DB().Exec(`INSERT INTO videos (product_id, source_item, status) VALUES ('下载运行中', 'source', 'downloaded')`); err != nil { + t.Fatalf("准备视频记录失败:%v", err) + } + + changed, err := s.ResetRunningStatuses() + if err != nil { + t.Fatalf("重置残留运行中状态失败:%v", err) + } + if changed != 4 { + t.Fatalf("应重置 4 个状态字段,实际 %d", changed) + } + + want := map[string]struct { + download, upload, diagnosis string + }{ + "下载运行中": {DownloadPending, UploadDone, VideoDiagnosisMissing}, + "上传运行中": {DownloadDone, UploadPending, VideoDiagnosisOK}, + "两个都运行中": {DownloadPending, UploadPending, VideoDiagnosisMissing}, + "已完成": {DownloadDone, UploadDone, VideoDiagnosisOK}, + "已失败": {DownloadFailed, UploadFailed, VideoDiagnosisMissing}, + "待处理": {DownloadPending, UploadPending, VideoDiagnosisOK}, + "none状态": {"none", "none", VideoDiagnosisMissing}, + } + for id, expected := range want { + got, found, err := s.GetProduct(id) + if err != nil || !found { + t.Fatalf("读取商品 %s 失败:err=%v found=%v", id, err, found) + } + if got.DownloadStatus != expected.download || got.UploadStatus != expected.upload { + t.Fatalf("商品 %s 状态不正确:download=%q upload=%q", id, got.DownloadStatus, got.UploadStatus) + } + if got.VideoDiagnosis != expected.diagnosis || got.VideoStatus != VideoPending { + t.Fatalf("商品 %s 的视频状态被错误改动:diagnosis=%q videoStatus=%q", id, got.VideoDiagnosis, got.VideoStatus) + } + } + + var videos int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM videos WHERE product_id = '下载运行中'`).Scan(&videos); err != nil || videos != 1 { + t.Fatalf("重置不得删除 videos 记录:count=%d err=%v", videos, err) + } +} + func Test老库升级保留商品和本地状态(t *testing.T) { path := filepath.Join(t.TempDir(), "旧版.db") db, err := sql.Open("sqlite", path)