package handler import ( "encoding/json" "net/http" "time" "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,将同步到线上"}) } }