Files
ai_site/platform/internal/handler/sync.go
whm 680d2b8cde 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>
2026-08-06 11:49:00 +08:00

481 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"aijianzhan/platform/internal/audit"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/logic/applogic"
"aijianzhan/platform/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
"github.com/zeromicro/go-zero/rest/pathvar"
)
func requireDBSync(svcCtx *svc.ServiceContext, w http.ResponseWriter) bool {
if svcCtx.DBSync == nil {
authx.WriteError(w, http.StatusServiceUnavailable, "dbsync not enabled")
return false
}
return true
}
func reconcileMinInterval(svcCtx *svc.ServiceContext) time.Duration {
sec := 300
if svcCtx != nil && svcCtx.Config.DBSync.ReconcileMinSec > 0 {
sec = svcCtx.Config.DBSync.ReconcileMinSec
}
return time.Duration(sec) * time.Second
}
// sync 仅公司顶级权限(管理员 /「数据同步」);智能体与编辑不可配。
func syncTenantID(r *http.Request) int64 {
return authx.TenantID(r.Context())
}
func syncListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
tid := syncTenantID(r)
if _, err := applogic.HealTenantSyncBind(r.Context(), svcCtx, tid); err != nil {
// 列表仍继续,避免自愈失败挡运维
_ = err
}
list, err := svcCtx.DBSync.Store().ListChannelsByTenant(tid)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"items": list})
}
}
func syncGetHandler(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
}
httpx.OkJson(w, ch)
}
}
func syncSaveHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
var ch dbsync.Channel
if err := json.NewDecoder(r.Body).Decode(&ch); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if id := pathvar.Vars(r)["id"]; id != "" {
ch.ID = id
existing, err := svcCtx.DBSync.Store().GetChannelForTenant(id, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
_ = existing
}
// 强制归属当前公司,禁止客户端伪造 tenant_id
ch.TenantID = syncTenantID(r)
if err := dbsync.ValidateChannelConfig(&ch); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
// 对可达端做 UUID 主键 + 外键闭包校验remote 通常为线上库)
if err := dbsync.ValidateChannelAgainstDB(r.Context(), &ch); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
saved, err := svcCtx.DBSync.Store().SaveChannel(ch)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, saved)
}
}
func syncDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
id := pathvar.Vars(r)["id"]
tid := syncTenantID(r)
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(id, tid)
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
wasDefault := ch.IsSystemDefault
svcCtx.DBSync.StopChannel(id)
if err := svcCtx.DBSync.Store().DeleteChannel(id); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
// Z12h-1默认同步可删删后 Ensure+heal账号落点自动挂回新默认通道
out := map[string]any{"ok": true, "deleted_id": id, "was_system_default": wasDefault}
if healed, hErr := applogic.HealTenantSyncBind(r.Context(), svcCtx, tid); hErr != nil {
out["heal_error"] = hErr.Error()
} else if healed != nil {
out["channel_id"] = healed.ChannelID
out["recreated_default"] = wasDefault || healed.ChannelCreated
out["bindings_fixed"] = healed.BindingsFixed
out["agents_fixed"] = healed.AgentsFixed
}
httpx.OkJson(w, out)
}
}
func syncTestHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
var body struct {
Local *dbsync.Endpoint `json:"local"`
Remote *dbsync.Endpoint `json:"remote"`
Side string `json:"side"` // local|remote|both
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
out := map[string]any{}
side := body.Side
if side == "" {
side = "both"
}
if (side == "local" || side == "both") && body.Local != nil {
out["local"] = dbsync.TestEndpoint(r.Context(), *body.Local)
}
if (side == "remote" || side == "both") && body.Remote != nil {
out["remote"] = dbsync.TestEndpoint(r.Context(), *body.Remote)
}
httpx.OkJson(w, out)
}
}
func syncPrepareHandler(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
}
if err := dbsync.PrepareChannel(r.Context(), ch); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "message": "outbox + triggers ready"})
}
}
func syncStartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
id := pathvar.Vars(r)["id"]
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(id, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
if err := dbsync.PrepareChannel(r.Context(), ch); err != nil {
authx.WriteError(w, http.StatusBadRequest, "prepare: "+err.Error())
return
}
if err := svcCtx.DBSync.StartChannel(id); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "running": true})
}
}
func syncStopHandler(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
}
svcCtx.DBSync.StopChannel(id)
httpx.OkJson(w, map[string]any{"ok": true, "running": false})
}
}
func syncConflictsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// M3LWW/冲突追溯仅平台超级管理员;公司 top 403
authx.WriteError(w, http.StatusForbidden, "冲突/LWW 覆盖日志仅平台超级管理员可查")
}
}
func syncResolveConflictHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authx.WriteError(w, http.StatusForbidden, "冲突/LWW 覆盖日志仅平台超级管理员可操作")
}
}
func syncReconcileHandler(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
}
if ok, wait := dbsync.CanReconcile(ch, reconcileMinInterval(svcCtx)); !ok {
authx.WriteError(w, http.StatusTooManyRequests, dbsync.ReconcileTooSoonError(wait).Error())
return
}
res, err := dbsync.ReconcileChannel(r.Context(), ch)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
now := time.Now().UTC()
_ = svcCtx.DBSync.Store().PatchStats(ch.ID, func(c *dbsync.Channel) {
c.LastReconcileAt = &now
})
dbsync.ScheduleCheckpoint(svcCtx.DBSync.Store(), ch, "reconcile")
httpx.OkJson(w, res)
}
}
func syncIngestHandler(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 {
Table string `json:"table"`
Source string `json:"source"` // 如 c / excel / api
Rows []map[string]any `json:"rows"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
n, err := dbsync.IngestRows(r.Context(), ch, body.Table, body.Rows, body.Source)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "ingested": n, "hint": "已写入本地并进入 outbox将同步到线上"})
}
}
// syncInspectHandler 控制台验同步:列出线上/本机库表 + 行数 + 列。
func syncInspectHandler(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
}
side := strings.TrimSpace(r.URL.Query().Get("side"))
if side == "" {
side = "remote"
}
includeMeta := r.URL.Query().Get("include_sync_meta") == "1" || r.URL.Query().Get("include_sync_meta") == "true"
var ep dbsync.Endpoint
switch side {
case "remote":
ep = ch.Remote
case "local":
ep = ch.Local
default:
authx.WriteError(w, http.StatusBadRequest, "side must be remote|local")
return
}
res, err := dbsync.InspectEndpoint(r.Context(), ep, side, includeMeta)
if err != nil && (res == nil || !res.OK) {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, res)
}
}
// syncPreviewHandler 预览单表内容(默认前 50 行)。
func syncPreviewHandler(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 {
Side string `json:"side"` // remote|local默认 remote
Table string `json:"table"`
Limit int `json:"limit"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
side := strings.TrimSpace(body.Side)
if side == "" {
side = "remote"
}
var ep dbsync.Endpoint
switch side {
case "remote":
ep = ch.Remote
case "local":
ep = ch.Local
default:
authx.WriteError(w, http.StatusBadRequest, "side must be remote|local")
return
}
res, err := dbsync.PreviewTable(r.Context(), ep, body.Table, body.Limit)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, res)
}
}
// syncDropTableHandler 控制台删业务表(仅当前 side 的 endpoint不同步 DDL 到另一侧)。
func syncDropTableHandler(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 {
Side string `json:"side"` // remote|local默认 remote
Table string `json:"table"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
side := strings.TrimSpace(body.Side)
if side == "" {
side = "remote"
}
var ep dbsync.Endpoint
switch side {
case "remote":
ep = ch.Remote
case "local":
ep = ch.Local
default:
authx.WriteError(w, http.StatusBadRequest, "side must be remote|local")
return
}
res, err := dbsync.DropTableOnEndpoint(r.Context(), ep, side, body.Table)
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.drop_table", audit.DetailJSON(map[string]any{
"channel_id": ch.ID, "side": side, "table": body.Table, "dropped": res != nil && res.Dropped,
}))
}
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)
}
}