Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/designs/cloud-sync-ui-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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`.
Expand Down
6 changes: 4 additions & 2 deletions docs/designs/episode-qa-checks-mcap-integrity.zh.html
Original file line number Diff line number Diff line change
Expand Up @@ -835,8 +835,9 @@ <h3>列表字段</h3>
<h3>交互规则</h3>
<ul>
<li>点击 <code>重新质检</code> 后按钮进入提交中状态。</li>
<li>返回 <code>passed=true</code> 时刷新列表和详情,展示最新状态。</li>
<li>返回 <code>passed=false</code> 时展示 <code>details</code>,并刷新 <code>qa_status</code> 与 <code>quality_flag</code>。</li>
<li>返回 <code>passed=true</code> 时刷新列表和详情,展示最新状态,并在 episode 详情页弹出本次质检结果。</li>
<li>返回 <code>passed=false</code> 时在结果弹窗展示 <code>details</code>,并刷新 <code>qa_status</code> 与 <code>quality_flag</code>。</li>
<li>episode 详情页的质量检查卡片只常驻展示当前状态和 <code>quality_flag</code>;本次重新质检结果不再作为“最近结果”行常驻,避免与质量说明重复。</li>
<li>返回 <code>409</code> 时提示该 episode 正在质检中,不重复提交。</li>
<li>质检历史使用抽屉或轻量面板展示 <code>GET /api/v1/qa/episodes/:id/checks</code> 的完整记录。</li>
</ul>
Expand Down Expand Up @@ -870,6 +871,7 @@ <h3>Synapse</h3>
<li>列表展示最近一次质检结果。</li>
<li>质检历史抽屉能展示完整 <code>qa_checks</code>。</li>
<li>质检中心和 episode 详情页都通过 <code>重新质检</code> 调用 <code>POST /api/v1/qa/episodes/:id/run</code>。</li>
<li>episode 详情页重新质检完成后展示结果弹窗;关闭后页面仅保留当前质量状态和质量说明。</li>
</ul>
</div>
</div>
Expand Down
31 changes: 28 additions & 3 deletions internal/api/handlers/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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 {
Expand Down
86 changes: 86 additions & 0 deletions internal/api/handlers/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading