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 @@
列表字段
交互规则
- 点击
重新质检 后按钮进入提交中状态。
- - 返回
passed=true 时刷新列表和详情,展示最新状态。
- - 返回
passed=false 时展示 details,并刷新 qa_status 与 quality_flag。
+ - 返回
passed=true 时刷新列表和详情,展示最新状态,并在 episode 详情页弹出本次质检结果。
+ - 返回
passed=false 时在结果弹窗展示 details,并刷新 qa_status 与 quality_flag。
+ - episode 详情页的质量检查卡片只常驻展示当前状态和
quality_flag;本次重新质检结果不再作为“最近结果”行常驻,避免与质量说明重复。
- 返回
409 时提示该 episode 正在质检中,不重复提交。
- 质检历史使用抽屉或轻量面板展示
GET /api/v1/qa/episodes/:id/checks 的完整记录。
@@ -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()