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

@@ -74,6 +74,8 @@ var Catalog = []Entry{
{Method: "POST", Path: "/api/v1/admin/sync/channels/{id}/start", OperationID: "startSyncChannel", Summary: "启动近实时同步", Group: "admin"},
{Method: "POST", Path: "/api/v1/admin/sync/channels/{id}/stop", OperationID: "stopSyncChannel", Summary: "停止同步", Group: "admin"},
{Method: "POST", Path: "/api/v1/admin/sync/channels/{id}/reconcile", OperationID: "reconcileSyncChannel", Summary: "主键对账(同步修复,有限流)", Group: "admin"},
{Method: "GET", Path: "/api/v1/admin/sync/channels/{id}/checkpoint", OperationID: "getSyncCheckpoint", Summary: "线上库同步快照摘要latest/previous", Group: "admin"},
{Method: "POST", Path: "/api/v1/admin/sync/channels/{id}/restore", OperationID: "restoreSyncChannel", Summary: "按上次同步快照恢复线上库", Group: "admin"},
{Method: "POST", Path: "/api/v1/admin/sync/channels/{id}/ingest", OperationID: "ingestSyncRows", Summary: "外部行写入通道 local", Group: "admin"},
{Method: "GET", Path: "/api/v1/admin/sync/bindings", OperationID: "listSyncBindings", Summary: "列出本机库↔线上库绑定(用户仅本人)", Group: "admin"},
{Method: "POST", Path: "/api/v1/admin/sync/bindings", OperationID: "ensureSyncBinding", Summary: "登记/更新绑定(用户可自助)", Group: "admin"},

View File

@@ -0,0 +1,453 @@
package dbsync
import (
"compress/gzip"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
checkpointDebounce = 30 * time.Second
checkpointMaxRows = 500_000 // 单次快照行数上限,避免撑爆磁盘
checkpointFileLatest = "latest.json.gz"
checkpointFilePrev = "previous.json.gz"
checkpointFileMeta = "meta.json"
)
// CheckpointSlotMeta 一代快照摘要(不读全量)。
type CheckpointSlotMeta struct {
SyncedAt time.Time `json:"synced_at"`
Source string `json:"source"`
TableCount int `json:"table_count"`
RowCount int `json:"row_count"`
}
// CheckpointMeta 通道快照目录摘要。
type CheckpointMeta struct {
ChannelID string `json:"channel_id"`
Latest *CheckpointSlotMeta `json:"latest,omitempty"`
Previous *CheckpointSlotMeta `json:"previous,omitempty"`
}
// CheckpointTable 单表快照。
type CheckpointTable struct {
PKColumn string `json:"pk_column"`
Rows map[string]map[string]any `json:"rows"` // pk -> row
}
// CheckpointPayload 全量快照内容。
type CheckpointPayload struct {
ChannelID string `json:"channel_id"`
SyncedAt time.Time `json:"synced_at"`
Source string `json:"source"`
Tables map[string]CheckpointTable `json:"tables"`
}
// RestoreResult 恢复结果。
type RestoreResult struct {
OK bool `json:"ok"`
Which string `json:"which"`
SyncedAt time.Time `json:"synced_at"`
Source string `json:"source,omitempty"`
Tables int `json:"tables"`
Upserted int `json:"upserted"`
Deleted int `json:"deleted"`
RestoredAt time.Time `json:"restored_at"`
}
var (
cpSchedMu sync.Mutex
cpPending = map[string]*time.Timer{}
)
func (s *FileStore) Dir() string {
if s == nil {
return "./data/dbsync"
}
return s.dir
}
func checkpointDir(store *FileStore, channelID string) string {
return filepath.Join(store.Dir(), "checkpoints", strings.TrimSpace(channelID))
}
// ScheduleCheckpoint 防抖后写入线上库快照;失败仅打日志。
func ScheduleCheckpoint(store *FileStore, ch *Channel, source string) {
if store == nil || ch == nil || strings.TrimSpace(ch.ID) == "" {
return
}
id := strings.TrimSpace(ch.ID)
chCopy := *ch
src := strings.TrimSpace(source)
if src == "" {
src = "sync"
}
cpSchedMu.Lock()
defer cpSchedMu.Unlock()
if t, ok := cpPending[id]; ok {
t.Stop()
}
cpPending[id] = time.AfterFunc(checkpointDebounce, func() {
cpSchedMu.Lock()
delete(cpPending, id)
cpSchedMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := SaveCheckpoint(ctx, store, &chCopy, src); err != nil {
log.Printf("dbsync checkpoint channel=%s source=%s: %v", id, src, err)
}
})
}
// SaveCheckpoint 导出通道 Remote 业务表,轮转 previous ← latest。
func SaveCheckpoint(ctx context.Context, store *FileStore, ch *Channel, source string) error {
if store == nil || ch == nil {
return fmt.Errorf("store/channel required")
}
dir := checkpointDir(store, ch.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
payload, err := dumpRemoteCheckpoint(ctx, ch, source)
if err != nil {
return err
}
latestPath := filepath.Join(dir, checkpointFileLatest)
prevPath := filepath.Join(dir, checkpointFilePrev)
// 轮转:现有 latest → previous
if _, err := os.Stat(latestPath); err == nil {
_ = os.Remove(prevPath)
if err := os.Rename(latestPath, prevPath); err != nil {
// Windows 上目标存在时 Rename 可能失败;已删 prev 再试
_ = os.Remove(prevPath)
if err2 := os.Rename(latestPath, prevPath); err2 != nil {
return fmt.Errorf("rotate checkpoint: %w", err2)
}
}
}
tmp := latestPath + ".tmp"
if err := writeCheckpointGzip(tmp, payload); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, latestPath); err != nil {
_ = os.Remove(latestPath)
if err2 := os.Rename(tmp, latestPath); err2 != nil {
_ = os.Remove(tmp)
return err2
}
}
meta := CheckpointMeta{ChannelID: ch.ID}
meta.Latest = slotMetaFromPayload(payload)
if prev, err := loadCheckpointPayload(prevPath); err == nil && prev != nil {
meta.Previous = slotMetaFromPayload(prev)
}
return writeCheckpointMeta(dir, meta)
}
func slotMetaFromPayload(p *CheckpointPayload) *CheckpointSlotMeta {
if p == nil {
return nil
}
rows := 0
for _, t := range p.Tables {
rows += len(t.Rows)
}
return &CheckpointSlotMeta{
SyncedAt: p.SyncedAt,
Source: p.Source,
TableCount: len(p.Tables),
RowCount: rows,
}
}
func dumpRemoteCheckpoint(ctx context.Context, ch *Channel, source string) (*CheckpointPayload, error) {
db, err := AcquireRemote(ch.Remote.Driver, ch.Remote.DSN)
if err != nil {
return nil, wrapOpenRemote(err)
}
names, err := listTablesForInspect(ctx, db, ch.Remote.Driver, false)
if err != nil {
return nil, err
}
out := &CheckpointPayload{
ChannelID: ch.ID,
SyncedAt: time.Now().UTC(),
Source: source,
Tables: make(map[string]CheckpointTable, len(names)),
}
totalRows := 0
for _, table := range names {
pkCol := "id"
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
pkCol = strings.TrimSpace(ch.PKColumns[table])
}
cols, err := listColumns(ctx, db, ch.Remote.Driver, table)
if err != nil {
return nil, fmt.Errorf("columns %s: %w", table, err)
}
rows, err := fetchAllRows(ctx, db, ch.Remote.Driver, table, cols)
if err != nil {
return nil, fmt.Errorf("dump %s: %w", table, err)
}
m := make(map[string]map[string]any, len(rows))
for _, row := range rows {
pk := fmt.Sprint(row[pkCol])
if pk == "" || pk == "<nil>" {
continue
}
m[pk] = row
}
totalRows += len(m)
if totalRows > checkpointMaxRows {
return nil, fmt.Errorf("checkpoint too large: >%d rows", checkpointMaxRows)
}
out.Tables[table] = CheckpointTable{PKColumn: pkCol, Rows: m}
}
return out, nil
}
func fetchAllRows(ctx context.Context, db *sql.DB, driver Driver, table string, cols []string) ([]map[string]any, error) {
if len(cols) == 0 {
return nil, nil
}
q := fmt.Sprintf(`SELECT * FROM %s`, quoteIdent(driver, table))
rows, err := db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
colNames, err := rows.Columns()
if err != nil {
return nil, err
}
var out []map[string]any
for rows.Next() {
raw := make([]any, len(colNames))
ptrs := make([]any, len(colNames))
for i := range raw {
ptrs[i] = &raw[i]
}
if err := rows.Scan(ptrs...); err != nil {
return nil, err
}
m := make(map[string]any, len(colNames))
for i, c := range colNames {
m[c] = normalizeValue(raw[i])
}
out = append(out, m)
}
return out, rows.Err()
}
func writeCheckpointGzip(path string, payload *CheckpointPayload) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
zw := gzip.NewWriter(f)
enc := json.NewEncoder(zw)
if err := enc.Encode(payload); err != nil {
_ = zw.Close()
return err
}
if err := zw.Close(); err != nil {
return err
}
return f.Close()
}
func loadCheckpointPayload(path string) (*CheckpointPayload, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
zr, err := gzip.NewReader(f)
if err != nil {
return nil, err
}
defer zr.Close()
var p CheckpointPayload
if err := json.NewDecoder(zr).Decode(&p); err != nil && err != io.EOF {
return nil, err
}
return &p, nil
}
func writeCheckpointMeta(dir string, meta CheckpointMeta) error {
b, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, checkpointFileMeta), b, 0o644)
}
// LoadCheckpointMeta 读取摘要;无快照时返回空 meta不报错
func LoadCheckpointMeta(store *FileStore, channelID string) (*CheckpointMeta, error) {
if store == nil || strings.TrimSpace(channelID) == "" {
return &CheckpointMeta{}, nil
}
dir := checkpointDir(store, channelID)
b, err := os.ReadFile(filepath.Join(dir, checkpointFileMeta))
if err != nil {
if os.IsNotExist(err) {
// 尝试从文件推断
meta := &CheckpointMeta{ChannelID: channelID}
if p, e := loadCheckpointPayload(filepath.Join(dir, checkpointFileLatest)); e == nil {
meta.Latest = slotMetaFromPayload(p)
}
if p, e := loadCheckpointPayload(filepath.Join(dir, checkpointFilePrev)); e == nil {
meta.Previous = slotMetaFromPayload(p)
}
return meta, nil
}
return nil, err
}
var meta CheckpointMeta
if err := json.Unmarshal(b, &meta); err != nil {
return nil, err
}
meta.ChannelID = channelID
return &meta, nil
}
// LoadCheckpoint 加载 latest 或 previous。
func LoadCheckpoint(store *FileStore, channelID, which string) (*CheckpointPayload, error) {
which = strings.TrimSpace(which)
if which == "" {
which = "latest"
}
if which != "latest" && which != "previous" {
return nil, fmt.Errorf("which 须为 latest 或 previous")
}
name := checkpointFileLatest
if which == "previous" {
name = checkpointFilePrev
}
path := filepath.Join(checkpointDir(store, channelID), name)
p, err := loadCheckpointPayload(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("无可用快照(%s", which)
}
return nil, err
}
return p, nil
}
// RestoreCheckpoint 将线上库恢复为指定快照upsert + 删除快照外 PK
func RestoreCheckpoint(ctx context.Context, store *FileStore, ch *Channel, which string) (*RestoreResult, error) {
if store == nil || ch == nil {
return nil, fmt.Errorf("store/channel required")
}
payload, err := LoadCheckpoint(store, ch.ID, which)
if err != nil {
return nil, err
}
db, err := AcquireRemote(ch.Remote.Driver, ch.Remote.DSN)
if err != nil {
return nil, wrapOpenRemote(err)
}
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
return nil, err
}
verBase := time.Now().UnixNano()
upserted, deleted := 0, 0
for table, ct := range payload.Tables {
pkCol := strings.TrimSpace(ct.PKColumn)
if pkCol == "" {
pkCol = "id"
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
pkCol = strings.TrimSpace(ch.PKColumns[table])
}
}
// 先 upsert 快照行
i := 0
for pk, row := range ct.Rows {
if len(row) == 0 {
continue
}
if err := EnsureTableFromRow(ctx, db, ch.Remote.Driver, table, pkCol, row); err != nil {
return nil, fmt.Errorf("ensure %s: %w", table, err)
}
b, err := json.Marshal(row)
if err != nil {
return nil, err
}
ver := verBase + int64(i)
i++
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, "upsert", string(b), ver); err != nil {
return nil, fmt.Errorf("upsert %s pk=%s: %w", table, pk, err)
}
upserted++
}
// 删快照中不存在的行(含空表:清空线上多余行)
livePKs, err := listTablePKs(ctx, db, ch.Remote.Driver, table, pkCol)
if err != nil {
// 表可能尚不存在且快照也空
if len(ct.Rows) == 0 {
continue
}
return nil, fmt.Errorf("list pk %s: %w", table, err)
}
for _, pk := range livePKs {
if _, ok := ct.Rows[pk]; ok {
continue
}
delPayload, _ := json.Marshal(map[string]any{pkCol: pk})
ver := verBase + int64(i)
i++
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, "delete", string(delPayload), ver); err != nil {
return nil, fmt.Errorf("delete %s pk=%s: %w", table, pk, err)
}
deleted++
}
}
// 快照里没有、线上多出来的业务表:不自动 DROP避免误伤仅对快照内表做行级 prune
which = strings.TrimSpace(which)
if which == "" {
which = "latest"
}
return &RestoreResult{
OK: true,
Which: which,
SyncedAt: payload.SyncedAt,
Source: payload.Source,
Tables: len(payload.Tables),
Upserted: upserted,
Deleted: deleted,
RestoredAt: time.Now().UTC(),
}, nil
}
func listTablePKs(ctx context.Context, db *sql.DB, driver Driver, table, pkCol string) ([]string, error) {
q := fmt.Sprintf(`SELECT %s FROM %s`, quoteIdent(driver, pkCol), quoteIdent(driver, table))
rows, err := db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var v any
if err := rows.Scan(&v); err != nil {
return nil, err
}
s := fmt.Sprint(normalizeValue(v))
if s != "" && s != "<nil>" {
out = append(out, s)
}
}
return out, rows.Err()
}

View File

@@ -0,0 +1,89 @@
package dbsync
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestCheckpointSaveRestoreRoundTrip(t *testing.T) {
dir := t.TempDir()
store, err := NewFileStore(dir)
if err != nil {
t.Fatal(err)
}
remoteDSN := "file:" + filepath.ToSlash(filepath.Join(dir, "online.db")) + "?_pragma=busy_timeout(5000)"
ch := &Channel{
ID: "ch-cp-1",
TenantID: 1,
Name: "test",
Remote: Endpoint{
Driver: DriverSQLite,
DSN: remoteDSN,
},
PKColumns: map[string]string{"demo": "id"},
}
ctx := context.Background()
db, err := Open(DriverSQLite, remoteDSN)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = db.Close()
InvalidateRemote(DriverSQLite, remoteDSN)
})
if _, err := db.ExecContext(ctx, `CREATE TABLE demo (id TEXT PRIMARY KEY, name TEXT)`); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO demo(id,name) VALUES('a','one'),('b','two')`); err != nil {
t.Fatal(err)
}
if err := EnsureMeta(ctx, db, DriverSQLite); err != nil {
t.Fatal(err)
}
if err := SaveCheckpoint(ctx, store, ch, "test"); err != nil {
t.Fatal(err)
}
meta, err := LoadCheckpointMeta(store, ch.ID)
if err != nil || meta.Latest == nil || meta.Latest.RowCount != 2 {
t.Fatalf("meta=%+v err=%v", meta, err)
}
// mutate then restore
if _, err := db.ExecContext(ctx, `DELETE FROM demo WHERE id='a'`); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO demo(id,name) VALUES('c','three')`); err != nil {
t.Fatal(err)
}
res, err := RestoreCheckpoint(ctx, store, ch, "latest")
if err != nil {
t.Fatal(err)
}
if !res.OK || res.Upserted < 2 {
t.Fatalf("restore=%+v", res)
}
var n int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM demo`).Scan(&n); err != nil || n != 2 {
t.Fatalf("count=%d err=%v", n, err)
}
var name string
if err := db.QueryRowContext(ctx, `SELECT name FROM demo WHERE id='a'`).Scan(&name); err != nil || name != "one" {
t.Fatalf("row a name=%q err=%v", name, err)
}
// second save rotates
time.Sleep(10 * time.Millisecond)
if _, err := db.ExecContext(ctx, `UPDATE demo SET name='one2' WHERE id='a'`); err != nil {
t.Fatal(err)
}
if err := SaveCheckpoint(ctx, store, ch, "test2"); err != nil {
t.Fatal(err)
}
meta2, _ := LoadCheckpointMeta(store, ch.ID)
if meta2.Previous == nil || meta2.Previous.RowCount != 2 {
t.Fatalf("expected previous after rotate: %+v", meta2)
}
}

View File

@@ -120,6 +120,7 @@ func (m *Manager) loop(ctx context.Context, id string) {
now := time.Now().UTC()
c.LastReconcileAt = &now
})
ScheduleCheckpoint(m.store, ch, "reconcile")
}
}
}
@@ -176,6 +177,10 @@ func (m *Manager) tick(ctx context.Context, ch *Channel) error {
c.LastError = ""
}
})
// 成功且本批有变更 → 防抖打线上库快照(数据恢复)
if err == nil && n > 0 {
ScheduleCheckpoint(m.store, ch, "drain")
}
return err
}

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)
}
}