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:
@@ -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, "无权访问该同步通道:请先登记本人 Binding(local_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
|
||||
|
||||
Reference in New Issue
Block a user