feat: harden loose-offline sync for user JWT, schema, and console ops

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>
This commit is contained in:
whm
2026-08-05 09:47:35 +08:00
parent 76cdcd760e
commit b04b180d30
59 changed files with 4762 additions and 308 deletions

View File

@@ -2,8 +2,12 @@ package handler
import (
"encoding/json"
"log"
"net/http"
"strings"
"time"
"aijianzhan/platform/internal/audit"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/svc"
@@ -12,19 +16,27 @@ import (
"github.com/zeromicro/go-zero/rest/pathvar"
)
// agent 同步只读白名单 + 推远程 A形态 B需 JWT 含「数据同步」权限(人类管理员或智能体均可)
// agent 同步:管理员/智能体(「数据同步」)租户级;普通登录用户按本人 Binding 范围
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))
channelID := pathvar.Vars(r)["id"]
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(channelID, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
if !authorizeUserSyncChannel(svcCtx, w, r, channelID) {
return
}
tables := uniqueStringSlice(ch.Local.Tables, ch.Remote.Tables)
hint := "通道 tables 仅历史兼容;用户自助以 Binding+JWT 为权限,整库表均可 push可不改白名单"
if len(tables) == 0 {
hint = "通道表白名单为空;以 Binding 为准,接受任意表 push可自动建表"
}
httpx.OkJson(w, map[string]any{
"channel_id": ch.ID,
"name": ch.Name,
@@ -33,17 +45,20 @@ func agentSyncWhitelistHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
"conflict_policy": ch.ConflictPolicy,
"tables": tables,
"pk_columns": ch.PKColumns,
"hint": "本机 agent 缓存此表白名单;仅白名单表走 local_dbsync",
"hint": hint,
})
}
}
func agentSyncPushHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
reqID := r.Header.Get("X-Request-Id")
if !requireDBSync(svcCtx, w) {
return
}
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
channelID := pathvar.Vars(r)["id"]
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(channelID, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
@@ -53,21 +68,46 @@ func agentSyncPushHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if !authorizeUserSyncPush(svcCtx, w, r, channelID, item) {
return
}
res, err := dbsync.PushToRemote(r.Context(), ch, svcCtx.DBSync.Store(), item)
dur := time.Since(started)
if err != nil {
logSyncReq(r, "push", channelID, item.Table, item.RowPK, "err", err.Error(), dur, reqID)
auditSync(svcCtx, r, "dbsync.push.error", map[string]any{
"channel_id": channelID, "table": item.Table, "row_pk": item.RowPK,
"online_db_id": item.OnlineDBID, "error": err.Error(), "ms": dur.Milliseconds(), "req_id": reqID,
})
if dbsync.IsRetryable(err) {
authx.WriteRetryableError(w, http.StatusServiceUnavailable, err.Error())
return
}
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
outcome := "applied"
if res != nil && res.Skipped {
outcome = "skipped"
}
logSyncReq(r, "push", channelID, item.Table, item.RowPK, outcome, "", dur, reqID)
auditSync(svcCtx, r, "dbsync.push", map[string]any{
"channel_id": channelID, "table": item.Table, "row_pk": item.RowPK,
"online_db_id": item.OnlineDBID, "outcome": outcome, "ms": dur.Milliseconds(), "req_id": reqID,
})
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) {
started := time.Now()
reqID := r.Header.Get("X-Request-Id")
if !requireDBSync(svcCtx, w) {
return
}
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
channelID := pathvar.Vars(r)["id"]
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(channelID, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
@@ -87,19 +127,282 @@ func agentSyncPushBatchHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
authx.WriteError(w, http.StatusBadRequest, "items limit 100")
return
}
for _, item := range body.Items {
if !authorizeUserSyncPush(svcCtx, w, r, channelID, item) {
return
}
}
results, err := dbsync.PushBatchToRemote(r.Context(), ch, svcCtx.DBSync.Store(), body.Items)
dur := time.Since(started)
if err != nil {
httpx.OkJson(w, map[string]any{
logSyncReq(r, "push_batch", channelID, "", "", "err", err.Error(), dur, reqID)
auditSync(svcCtx, r, "dbsync.push_batch.error", map[string]any{
"channel_id": channelID, "n": len(body.Items), "error": err.Error(),
"ms": dur.Milliseconds(), "req_id": reqID,
})
payload := map[string]any{
"success": false,
"error": err.Error(),
"results": results,
})
}
if dbsync.IsRetryable(err) {
payload["retryable"] = true
httpx.WriteJson(w, http.StatusServiceUnavailable, payload)
return
}
httpx.OkJson(w, payload)
return
}
logSyncReq(r, "push_batch", channelID, "", "", "ok", "", dur, reqID)
auditSync(svcCtx, r, "dbsync.push_batch", map[string]any{
"channel_id": channelID, "n": len(body.Items), "ms": dur.Milliseconds(), "req_id": reqID,
})
httpx.OkJson(w, map[string]any{"success": true, "results": results})
}
}
func logSyncReq(r *http.Request, op, channelID, table, rowPK, outcome, errMsg string, dur time.Duration, reqID string) {
uid := authx.UserID(r.Context())
if errMsg != "" {
log.Printf("dbsync %s channel=%s table=%s pk=%s user=%d outcome=%s err=%s dur=%s req=%s",
op, channelID, table, rowPK, uid, outcome, errMsg, dur, reqID)
return
}
log.Printf("dbsync %s channel=%s table=%s pk=%s user=%d outcome=%s dur=%s req=%s",
op, channelID, table, rowPK, uid, outcome, dur, reqID)
}
func auditSync(svcCtx *svc.ServiceContext, r *http.Request, action string, detail map[string]any) {
if svcCtx == nil || svcCtx.Audit == nil {
return
}
_ = svcCtx.Audit.Log(r.Context(), syncTenantID(r), authx.UserID(r.Context()), action, audit.DetailJSON(detail))
}
// authorizeUserSyncChannel 无「数据同步」时,须本人 Binding 覆盖该通道。
func authorizeUserSyncChannel(svcCtx *svc.ServiceContext, w http.ResponseWriter, r *http.Request, channelID string) bool {
if authx.SyncTenantWide(r.Context()) {
return true
}
uid := authx.UserID(r.Context())
tid := syncTenantID(r)
if !svcCtx.DBSync.Store().UserCanAccessChannel(tid, uid, channelID) {
authx.WriteError(w, http.StatusForbidden, "无权访问该同步通道:请先登记本人 Bindinglocal_database_id ↔ online_db_id + channel_id")
return false
}
return true
}
// authorizeUserSyncPush 无「数据同步」时,须本人 Binding 且 online_db_id 匹配。
func authorizeUserSyncPush(svcCtx *svc.ServiceContext, w http.ResponseWriter, r *http.Request, channelID string, item dbsync.PushItem) bool {
return authorizeUserOnlineDB(svcCtx, w, r, channelID, item.OnlineDBID, "用户自助 push 须带 online_db_id且须为本人 Binding")
}
// authorizeUserOnlineDB带 online_db_id 时一律按本人 Binding 校验(含有「数据同步」的人类管理员)。
// 仅「数据同步」且未带 online_db_id 时保持租户级管理路径(智能体/管理员兼容)。
func authorizeUserOnlineDB(svcCtx *svc.ServiceContext, w http.ResponseWriter, r *http.Request, channelID, onlineDBID, emptyMsg string) bool {
online := strings.TrimSpace(onlineDBID)
wide := authx.SyncTenantWide(r.Context())
if online == "" {
if wide {
return true
}
if emptyMsg == "" {
emptyMsg = "用户自助须带 online_db_id且须为本人 Binding"
}
authx.WriteError(w, http.StatusForbidden, emptyMsg)
return false
}
uid := authx.UserID(r.Context())
tid := syncTenantID(r)
if !svcCtx.DBSync.Store().UserOwnsOnlineDB(tid, uid, channelID, online) {
authx.WriteError(w, http.StatusForbidden, "无权访问该 online_db_id非本人 Binding")
return false
}
return true
}
func agentSyncPullHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return agentSyncPullWithDefaultMode(svcCtx, "")
}
func agentSyncBootstrapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return agentSyncPullWithDefaultMode(svcCtx, dbsync.PullModeBootstrap)
}
func agentSyncPullWithDefaultMode(svcCtx *svc.ServiceContext, forceMode string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
reqID := r.Header.Get("X-Request-Id")
if !requireDBSync(svcCtx, w) {
return
}
channelID := pathvar.Vars(r)["id"]
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(channelID, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
var req dbsync.PullRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if forceMode != "" {
req.Mode = forceMode
}
if !authorizeUserOnlineDB(svcCtx, w, r, channelID, req.OnlineDBID, "用户自助 pull/bootstrap 须带 online_db_id且须为本人 Binding") {
return
}
if !authorizeUserSyncChannel(svcCtx, w, r, channelID) {
return
}
res, err := dbsync.PullFromRemote(r.Context(), ch, svcCtx.DBSync.Store(), req)
dur := time.Since(started)
mode := req.Mode
if mode == "" {
mode = dbsync.PullModeBootstrap
}
if err != nil {
logSyncReq(r, "pull/"+mode, channelID, req.Table, "", "err", err.Error(), dur, reqID)
auditSync(svcCtx, r, "dbsync.pull.error", map[string]any{
"channel_id": channelID, "mode": mode, "table": req.Table,
"error": err.Error(), "ms": dur.Milliseconds(), "req_id": reqID,
})
if dbsync.IsRetryable(err) {
authx.WriteRetryableError(w, http.StatusServiceUnavailable, err.Error())
return
}
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
n := 0
if res != nil {
n = len(res.Items) + len(res.PKs)
}
logSyncReq(r, "pull/"+mode, channelID, req.Table, "", "ok", "", dur, reqID)
auditSync(svcCtx, r, "dbsync.pull", map[string]any{
"channel_id": channelID, "mode": mode, "table": req.Table,
"n": n, "ms": dur.Milliseconds(), "req_id": reqID,
})
httpx.OkJson(w, map[string]any{"success": true, "result": res})
}
}
func agentSyncSchemaEnsureHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
reqID := r.Header.Get("X-Request-Id")
if !requireDBSync(svcCtx, w) {
return
}
channelID := pathvar.Vars(r)["id"]
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(channelID, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
var req dbsync.SchemaEnsureRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
online := strings.TrimSpace(req.OnlineDBID)
if online == "" && len(req.Tables) > 0 {
online = strings.TrimSpace(req.Tables[0].OnlineDBID)
req.OnlineDBID = online
}
if !authorizeUserOnlineDB(svcCtx, w, r, channelID, online, "用户自助 schema/ensure 须带 online_db_id且须为本人 Binding") {
return
}
if !authorizeUserSyncChannel(svcCtx, w, r, channelID) {
return
}
res, err := dbsync.EnsureSchemasOnRemote(r.Context(), ch, req)
dur := time.Since(started)
if err != nil {
logSyncReq(r, "schema/ensure", channelID, "", "", "err", err.Error(), dur, reqID)
auditSync(svcCtx, r, "dbsync.schema_ensure.error", map[string]any{
"channel_id": channelID, "n": len(req.Tables), "error": err.Error(),
"ms": dur.Milliseconds(), "req_id": reqID,
})
if dbsync.IsRetryable(err) {
authx.WriteRetryableError(w, http.StatusServiceUnavailable, err.Error())
return
}
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
n := 0
created := 0
if res != nil {
n = len(res.Results)
for _, it := range res.Results {
if it.Created {
created++
}
}
}
logSyncReq(r, "schema/ensure", channelID, "", "", "ok", "", dur, reqID)
auditSync(svcCtx, r, "dbsync.schema_ensure", map[string]any{
"channel_id": channelID, "n": n, "created": created,
"ms": dur.Milliseconds(), "req_id": reqID,
})
httpx.OkJson(w, map[string]any{"success": true, "result": res})
}
}
func agentSyncSchemaDescribeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
reqID := r.Header.Get("X-Request-Id")
if !requireDBSync(svcCtx, w) {
return
}
channelID := pathvar.Vars(r)["id"]
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(channelID, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
var req dbsync.SchemaDescribeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if !authorizeUserOnlineDB(svcCtx, w, r, channelID, req.OnlineDBID, "用户自助 schema 须带 online_db_id且须为本人 Binding") {
return
}
if !authorizeUserSyncChannel(svcCtx, w, r, channelID) {
return
}
res, err := dbsync.DescribeSchemasFromRemote(r.Context(), ch, req)
dur := time.Since(started)
if err != nil {
logSyncReq(r, "schema", channelID, "", "", "err", err.Error(), dur, reqID)
auditSync(svcCtx, r, "dbsync.schema.error", map[string]any{
"channel_id": channelID, "error": err.Error(),
"ms": dur.Milliseconds(), "req_id": reqID,
})
if dbsync.IsRetryable(err) {
authx.WriteRetryableError(w, http.StatusServiceUnavailable, err.Error())
return
}
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
n := 0
if res != nil {
n = len(res.Tables)
}
logSyncReq(r, "schema", channelID, "", "", "ok", "", dur, reqID)
auditSync(svcCtx, r, "dbsync.schema", map[string]any{
"channel_id": channelID, "n": n, "ms": dur.Milliseconds(), "req_id": reqID,
})
httpx.OkJson(w, map[string]any{"success": true, "result": res})
}
}
func uniqueStringSlice(a, b []string) []string {
seen := map[string]struct{}{}
var out []string