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)