Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见. Co-authored-by: Cursor <cursoragent@cursor.com>
402 lines
12 KiB
Go
402 lines
12 KiB
Go
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)
|
||
}
|
||
}
|