feat: add sync checkpoint restore for last successful sync

Capture rolling online DB snapshots after push/drain/reconcile and expose SyncPage 数据恢复 plus checkpoint/restore APIs; mark Z12h and restore done in coop docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-06 11:49:00 +08:00
parent 75622a728e
commit 680d2b8cde
11 changed files with 860 additions and 16 deletions

View File

@@ -90,6 +90,9 @@ func agentSyncPushHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
if res != nil && res.Skipped {
outcome = "skipped"
}
if res != nil && res.Applied {
dbsync.ScheduleCheckpoint(svcCtx.DBSync.Store(), ch, "push")
}
logSyncReq(r, "push", channelID, item.Table, item.RowPK, outcome, "", dur, reqID)
auditSync(svcCtx, r, "dbsync.push", map[string]any{
"channel_id": channelID, "table": item.Table, "row_pk": item.RowPK,
@@ -153,6 +156,15 @@ func agentSyncPushBatchHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
httpx.OkJson(w, payload)
return
}
applied := 0
for _, r0 := range results {
if r0.Applied {
applied++
}
}
if applied > 0 {
dbsync.ScheduleCheckpoint(svcCtx.DBSync.Store(), ch, "push_batch")
}
logSyncReq(r, "push_batch", channelID, "", "", "ok", "", dur, reqID)
auditSync(svcCtx, r, "dbsync.push_batch", map[string]any{
"channel_id": channelID, "n": len(body.Items), "ms": dur.Milliseconds(), "req_id": reqID,

View File

@@ -146,6 +146,8 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) {
{Method: http.MethodGet, Path: "/api/v1/admin/sync/conflicts", Handler: chain(syncConflictsHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/conflicts/:id/resolve", Handler: chain(syncResolveConflictHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/reconcile", Handler: chain(syncReconcileHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodGet, Path: "/api/v1/admin/sync/channels/:id/checkpoint", Handler: chain(syncCheckpointMetaHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/restore", Handler: chain(syncRestoreHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/ingest", Handler: chain(syncIngestHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodGet, Path: "/api/v1/admin/sync/channels/:id/inspect", Handler: chain(syncInspectHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/preview", Handler: chain(syncPreviewHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},

View File

@@ -262,6 +262,7 @@ func syncReconcileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
_ = svcCtx.DBSync.Store().PatchStats(ch.ID, func(c *dbsync.Channel) {
c.LastReconcileAt = &now
})
dbsync.ScheduleCheckpoint(svcCtx.DBSync.Store(), ch, "reconcile")
httpx.OkJson(w, res)
}
}
@@ -418,3 +419,62 @@ func syncDropTableHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
httpx.OkJson(w, res)
}
}
func syncCheckpointMetaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
id := pathvar.Vars(r)["id"]
if _, err := svcCtx.DBSync.Store().GetChannelForTenant(id, syncTenantID(r)); err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
meta, err := dbsync.LoadCheckpointMeta(svcCtx.DBSync.Store(), id)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, meta)
}
}
func syncRestoreHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
var body struct {
Which string `json:"which"`
Confirm bool `json:"confirm"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if !body.Confirm {
authx.WriteError(w, http.StatusBadRequest, "请确认恢复confirm=true")
return
}
which := strings.TrimSpace(body.Which)
if which == "" {
which = "latest"
}
res, err := dbsync.RestoreCheckpoint(r.Context(), svcCtx.DBSync.Store(), ch, which)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if svcCtx.Audit != nil {
_ = svcCtx.Audit.Log(r.Context(), syncTenantID(r), authx.UserID(r.Context()), "dbsync.restore", audit.DetailJSON(map[string]any{
"channel_id": ch.ID, "which": which, "synced_at": res.SyncedAt, "upserted": res.Upserted, "deleted": res.Deleted,
}))
}
httpx.OkJson(w, res)
}
}