package handler import ( "encoding/json" "net/http" "strings" "time" "aijianzhan/platform/internal/audit" "aijianzhan/platform/internal/authx" "aijianzhan/platform/internal/dbsync" "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 } list, err := svcCtx.DBSync.Store().ListChannelsByTenant(syncTenantID(r)) 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"] if _, err := svcCtx.DBSync.Store().GetChannelForTenant(id, syncTenantID(r)); err != nil { authx.WriteError(w, http.StatusNotFound, err.Error()) return } svcCtx.DBSync.StopChannel(id) if err := svcCtx.DBSync.Store().DeleteChannel(id); err != nil { authx.WriteError(w, http.StatusBadRequest, err.Error()) return } httpx.OkJson(w, map[string]any{"ok": true}) } } 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) { // M3:LWW/冲突追溯仅平台超级管理员;公司 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 }) 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) } }