From 5617292760e8a418bcedd04d2f98a985ef4c493e Mon Sep 17 00:00:00 2001 From: chaoliu Date: Fri, 12 Jun 2026 14:57:47 +0800 Subject: [PATCH] fix(sync): return not_started for unsynced episodes --- docs/designs/cloud-sync-ui-implementation.md | 9 +- .../episode-qa-checks-mcap-integrity.zh.html | 6 +- internal/api/handlers/sync.go | 31 ++++++- internal/api/handlers/sync_test.go | 86 +++++++++++++++++++ 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/docs/designs/cloud-sync-ui-implementation.md b/docs/designs/cloud-sync-ui-implementation.md index 31877cd..701aa55 100644 --- a/docs/designs/cloud-sync-ui-implementation.md +++ b/docs/designs/cloud-sync-ui-implementation.md @@ -52,7 +52,7 @@ endpoints recommended for the episode-centered Cloud Sync Center redesign: | `POST` | `/api/v1/sync/episodes/:id` | Enqueue one episode for cloud sync by numeric episode ID | | `GET` | `/api/v1/sync/episodes` | Existing: list raw sync log entries for history/diagnosis | | `GET` | `/api/v1/sync/episodes/summary` | Recommended new endpoint: list latest sync state grouped by episode | -| `GET` | `/api/v1/sync/episodes/:id/status` | Get latest sync log for one episode | +| `GET` | `/api/v1/sync/episodes/:id/status` | Get current sync status for one episode | | `GET` | `/api/v1/sync/episodes/:id/logs` | Recommended new endpoint: list raw sync log history for one episode | | `GET` | `/api/v1/sync/config` | Get sanitized sync worker configuration | @@ -482,8 +482,11 @@ Update `synapse/src/views/admin/episodes/EpisodeDetail.vue`: Important status handling: -- `GET /sync/episodes/:id/status` may return 404 before the worker creates a - `sync_logs` row. Treat this as `queued` after a trigger, not as failure. +- `GET /sync/episodes/:id/status` returns `200 status=not_started` when the + episode exists but has no `sync_logs` row. This is a virtual status and is not + inserted into `sync_logs`. +- `GET /sync/episodes/:id/status` returns `404 episode not found` only when the + episode does not exist or has been soft-deleted. - `POST /sync/episodes/:id` returns `202 Accepted`, not completion. - `409 already synced` should refresh episode detail. - `409 already queued` should switch UI to `queued` or `syncing`. diff --git a/docs/designs/episode-qa-checks-mcap-integrity.zh.html b/docs/designs/episode-qa-checks-mcap-integrity.zh.html index 1698889..3356459 100644 --- a/docs/designs/episode-qa-checks-mcap-integrity.zh.html +++ b/docs/designs/episode-qa-checks-mcap-integrity.zh.html @@ -835,8 +835,9 @@

列表字段

交互规则

@@ -870,6 +871,7 @@

Synapse

  • 列表展示最近一次质检结果。
  • 质检历史抽屉能展示完整 qa_checks
  • 质检中心和 episode 详情页都通过 重新质检 调用 POST /api/v1/qa/episodes/:id/run
  • +
  • episode 详情页重新质检完成后展示结果弹窗;关闭后页面仅保留当前质量状态和质量说明。
  • diff --git a/internal/api/handlers/sync.go b/internal/api/handlers/sync.go index eadd585..12f9955 100644 --- a/internal/api/handlers/sync.go +++ b/internal/api/handlers/sync.go @@ -632,13 +632,13 @@ func (h *SyncHandler) ListEpisodeSyncLogs(c *gin.Context) { // GetSyncStatus returns the sync status for a specific episode. // // @Summary Get episode sync status -// @Description Returns the latest sync log entry for a specific episode +// @Description Returns the latest sync log entry for a specific episode, or status=not_started when the episode has no sync log // @Tags sync // @Produce json // @Param id path int true "Episode ID" // @Success 200 {object} SyncJobResponse // @Failure 400 {object} map[string]string -// @Failure 404 {object} map[string]string +// @Failure 404 {object} map[string]string "Episode not found" // @Router /sync/episodes/{id}/status [get] func (h *SyncHandler) GetSyncStatus(c *gin.Context) { idStr := c.Param("id") @@ -648,6 +648,25 @@ func (h *SyncHandler) GetSyncStatus(c *gin.Context) { return } + var episode struct { + ID int64 `db:"id"` + PublicID sql.NullString `db:"episode_id"` + } + err = h.db.Get(&episode, ` + SELECT id, episode_id + FROM episodes + WHERE id = ? AND deleted_at IS NULL + `, episodeID) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "episode not found"}) + return + } + if err != nil { + logger.Printf("[SYNC] Failed to query episode %d for sync status: %v", episodeID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get sync status"}) + return + } + var row syncLogRow err = h.db.Get(&row, ` SELECT @@ -672,7 +691,13 @@ func (h *SyncHandler) GetSyncStatus(c *gin.Context) { LIMIT 1 `, episodeID) if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "no sync record found for this episode"}) + c.JSON(http.StatusOK, SyncJobResponse{ + ID: 0, + EpisodeID: episode.ID, + EpisodePublicID: nullableString(episode.PublicID), + Status: "not_started", + AttemptCount: 0, + }) return } if err != nil { diff --git a/internal/api/handlers/sync_test.go b/internal/api/handlers/sync_test.go index a825937..477b436 100644 --- a/internal/api/handlers/sync_test.go +++ b/internal/api/handlers/sync_test.go @@ -128,6 +128,92 @@ func TestListEpisodeSyncSummariesGroupsByEpisode(t *testing.T) { } } +func TestGetSyncStatusReturnsNotStartedWhenEpisodeHasNoSyncLog(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSyncHandlerTestDB(t) + + if _, err := db.Exec(` + INSERT INTO episodes (id, episode_id, deleted_at) + VALUES (4181, 'episode-no-sync', NULL) + `); err != nil { + t.Fatalf("insert episode: %v", err) + } + + router := gin.New() + handler := NewSyncHandler(db, nil) + handler.RegisterRoutes(router.Group("/api/v1")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sync/episodes/4181/status", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var got SyncJobResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.ID != 0 { + t.Fatalf("id = %d, want 0 for virtual status", got.ID) + } + if got.EpisodeID != 4181 { + t.Fatalf("episode_id = %d, want 4181", got.EpisodeID) + } + if got.EpisodePublicID == nil || *got.EpisodePublicID != "episode-no-sync" { + t.Fatalf("episode_public_id = %v, want episode-no-sync", got.EpisodePublicID) + } + if got.Status != "not_started" { + t.Fatalf("status = %q, want not_started", got.Status) + } + if got.AttemptCount != 0 { + t.Fatalf("attempt_count = %d, want 0", got.AttemptCount) + } +} + +func TestGetSyncStatusReturnsNotFoundWhenEpisodeDoesNotExist(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSyncHandlerTestDB(t) + + if _, err := db.Exec(` + INSERT INTO episodes (id, episode_id, deleted_at) + VALUES (42, 'episode-deleted', ?) + `, time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)); err != nil { + t.Fatalf("insert deleted episode: %v", err) + } + + router := gin.New() + handler := NewSyncHandler(db, nil) + handler.RegisterRoutes(router.Group("/api/v1")) + + tests := []struct { + name string + path string + }{ + {name: "missing", path: "/api/v1/sync/episodes/404/status"}, + {name: "soft deleted", path: "/api/v1/sync/episodes/42/status"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got["error"] != "episode not found" { + t.Fatalf("error = %q, want episode not found", got["error"]) + } + }) + } +} + func setupSyncHandlerTestDB(t *testing.T) *sqlx.DB { t.Helper()