Files
ai_site/platform/internal/handler/sync.go
whm 76cdcd760e feat: ship loose-offline dbsync (validate, agent push, LWW audit)
Add UUID/FK channel checks, agent whitelist/push APIs, bindings, super-admin LWW audit with rollback, reconcile rate limits, and sync docs. Default customers stay opt-in; company conflict UI is removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 17:54:14 +08:00

275 lines
8.3 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"
"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) {
// 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将同步到线上"})
}
}