Files
ai_site/platform/internal/handler/sync.go
whm 362dcee242 feat: heal dead sync channels and block deleting system default (Z12h)
Ensure default channel and rebind Agent/Binding on list, ticket, agents/me, and ensure-binding so SyncPage does not stay on deleted-channel errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 08:57:30 +08:00

419 lines
13 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
}
// Z12h-1公司默认同步通道禁止删除账号落点依赖它
if ch.IsSystemDefault {
authx.WriteError(w, http.StatusBadRequest, "公司默认同步通道不可删除;账号绑定落点依赖此通道。若异常请刷新页面自动修复")
return
}
svcCtx.DBSync.StopChannel(id)
if err := svcCtx.DBSync.Store().DeleteChannel(id); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
// 若误删后仍有 Binding 挂死heal 会挂回默认通道
if _, hErr := applogic.HealTenantSyncBind(r.Context(), svcCtx, tid); hErr != nil {
_ = hErr
}
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) {
// 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
})
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)
}
}