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:
whm
2026-07-31 17:54:14 +08:00
parent 632057c857
commit 76cdcd760e
39 changed files with 3302 additions and 199 deletions

View File

@@ -0,0 +1,119 @@
package handler
import (
"encoding/json"
"net/http"
"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"
)
// agent 同步只读白名单 + 推远程 A形态 B需 JWT 含「数据同步」权限(人类管理员或智能体均可)。
func agentSyncWhitelistHandler(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
}
tables := uniqueStringSlice(ch.Local.Tables, ch.Remote.Tables)
httpx.OkJson(w, map[string]any{
"channel_id": ch.ID,
"name": ch.Name,
"enabled": ch.Enabled,
"direction": ch.Direction,
"conflict_policy": ch.ConflictPolicy,
"tables": tables,
"pk_columns": ch.PKColumns,
"hint": "本机 agent 缓存此表白名单;仅白名单表走 local_dbsync",
})
}
}
func agentSyncPushHandler(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 item dbsync.PushItem
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
res, err := dbsync.PushToRemote(r.Context(), ch, svcCtx.DBSync.Store(), item)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"success": true, "result": res})
}
}
func agentSyncPushBatchHandler(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 {
Items []dbsync.PushItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if len(body.Items) == 0 {
authx.WriteError(w, http.StatusBadRequest, "items required")
return
}
if len(body.Items) > 100 {
authx.WriteError(w, http.StatusBadRequest, "items limit 100")
return
}
results, err := dbsync.PushBatchToRemote(r.Context(), ch, svcCtx.DBSync.Store(), body.Items)
if err != nil {
httpx.OkJson(w, map[string]any{
"success": false,
"error": err.Error(),
"results": results,
})
return
}
httpx.OkJson(w, map[string]any{"success": true, "results": results})
}
}
func uniqueStringSlice(a, b []string) []string {
seen := map[string]struct{}{}
var out []string
for _, xs := range [][]string{a, b} {
for _, s := range xs {
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
}
return out
}