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>
This commit is contained in:
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"aijianzhan/platform/internal/authx"
|
||||
"aijianzhan/platform/internal/dbsync"
|
||||
@@ -20,6 +21,14 @@ func requireDBSync(svcCtx *svc.ServiceContext, w http.ResponseWriter) bool {
|
||||
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())
|
||||
@@ -74,6 +83,15 @@ func syncSaveHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
// 强制归属当前公司,禁止客户端伪造 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())
|
||||
@@ -189,37 +207,14 @@ func syncStopHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
|
||||
func syncConflictsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
only := r.URL.Query().Get("unresolved") != "0"
|
||||
list, err := svcCtx.DBSync.Store().ListConflictsByTenant(syncTenantID(r), only)
|
||||
if err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, map[string]any{"items": list})
|
||||
// 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) {
|
||||
if !requireDBSync(svcCtx, w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Resolution string `json:"resolution"` // apply_source | keep_target | discard
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Resolution == "" {
|
||||
body.Resolution = "discard"
|
||||
}
|
||||
id := pathvar.Vars(r)["id"]
|
||||
if err := svcCtx.DBSync.Store().ResolveConflictForTenant(id, syncTenantID(r), body.Resolution); err != nil {
|
||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OkJson(w, map[string]any{"ok": true, "resolution": body.Resolution})
|
||||
authx.WriteError(w, http.StatusForbidden, "冲突/LWW 覆盖日志仅平台超级管理员可操作")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,11 +228,19 @@ func syncReconcileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user