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

View File

@@ -360,15 +360,39 @@ paths:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
responses: { "200": { description: OK } }
/api/v1/admin/sync/channels/{id}/inspect:
get:
operationId: inspectSyncChannel
summary: 列出通道线上/本机库表(验同步)
parameters:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
responses: { "200": { description: OK } }
/api/v1/admin/sync/channels/{id}/preview:
post:
operationId: previewSyncTable
summary: 预览单表行
parameters:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
responses: { "200": { description: OK } }
/api/v1/admin/sync/channels/{id}/drop-table:
post:
operationId: dropSyncTable
summary: 删除线上/本机业务表(仅当前 side不同步 DDL
parameters:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
responses: { "200": { description: OK } }
/api/v1/admin/sync/bindings:
get:
operationId: listSyncBindings
summary: 列出本机库↔线上库绑定
summary: 列出本机库↔线上库绑定(管理员全量;用户仅本人)
parameters: [{ $ref: "#/components/parameters/Authorization" }]
responses: { "200": { description: OK } }
post:
operationId: ensureSyncBinding
summary: 登记/更新绑定
summary: 登记/更新绑定(登录用户可自助;强制本人 user_id
parameters: [{ $ref: "#/components/parameters/Authorization" }]
responses: { "200": { description: OK } }
/api/v1/admin/sync/conflicts:
@@ -380,7 +404,7 @@ paths:
/api/v1/agent/sync/channels/{id}/whitelist:
get:
operationId: agentSyncWhitelist
summary: 本机 agent 拉取表白名单
summary: 拉取表白名单(「数据同步」或登录用户+本人 Binding
parameters:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
@@ -388,11 +412,42 @@ paths:
/api/v1/agent/sync/channels/{id}/push:
post:
operationId: agentSyncPush
summary: 本机 agent 推变更到线上 A
summary: 推变更到线上 A(用户自助须带本人 online_db_id
parameters:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
responses: { "200": { description: OK } }
responses:
"200":
description: OK含 applied / skipped 幂等)
content:
application/json:
examples:
applied:
value:
success: true
result:
ok: true
applied: true
skipped: false
applied_version: 1710000000000000000
message: applied
skipped:
value:
success: true
result:
ok: true
applied: false
skipped: true
applied_version: 1710000000000000000
message: already applied (same version)
"503":
description: remote 暂不可达,可重试
content:
application/json:
example:
code: 503
message: "open remote: ..."
retryable: true
/api/v1/agent/sync/channels/{id}/push/batch:
post:
operationId: agentSyncPushBatch
@@ -400,7 +455,67 @@ paths:
parameters:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
responses: { "200": { description: OK } }
responses:
"200": { description: OK }
"503": { description: remote 暂不可达retryable }
/api/v1/agent/sync/channels/{id}/pull:
post:
operationId: agentSyncPull
summary: 从线上 A 下行bootstrap / rows / pks用户自助须 online_db_id
parameters:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
responses:
"200":
description: OK
content:
application/json:
example:
success: true
result:
ok: true
mode: bootstrap
table: orders
pk_column: id
items:
- table: orders
op: upsert
row_pk: aaaa-bbbb
row: { id: aaaa-bbbb, title: x }
version: 1710000000000000000
next_after_pk: aaaa-bbbb
has_more: true
"503": { description: remote 暂不可达retryable }
/api/v1/agent/sync/channels/{id}/bootstrap:
post:
operationId: agentSyncBootstrap
summary: 全量灌库(等同 pull + mode=bootstrap
parameters:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
responses:
"200": { description: OK }
"503": { description: remote 暂不可达retryable }
/api/v1/agent/sync/channels/{id}/schema:
post:
operationId: agentSyncSchemaDescribe
summary: 拉取线上 A 表结构(含空表);本机按 columns CREATE IF NOT EXISTS
parameters:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
responses:
"200": { description: OK }
"503": { description: remote 暂不可达retryable }
/api/v1/agent/sync/channels/{id}/schema/ensure:
post:
operationId: agentSyncSchemaEnsure
summary: 本机空表结构推到线上 ACREATE IF NOT EXISTS无需 outbox 行)
parameters:
- { $ref: "#/components/parameters/Authorization" }
- { $ref: "#/components/parameters/Id" }
responses:
"200": { description: OK }
"503": { description: remote 暂不可达retryable }
/api/v1/platform/dbsync/lww-overrides:
get:
operationId: platformLwwOverrides

View File

@@ -34,6 +34,7 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) {
perm := authx.RequirePermission
tenant := authx.RequireTenant()
platformAdmin := authx.RequirePlatformAdmin()
syncPush := authx.RequireSyncPushAccess()
appGrant := requireAgentAppGrant(svcCtx)
// —— 公开 ——
@@ -133,13 +134,20 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) {
{Method: http.MethodPost, Path: "/api/v1/admin/sync/conflicts/:id/resolve", Handler: chain(syncResolveConflictHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/reconcile", Handler: chain(syncReconcileHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/ingest", Handler: chain(syncIngestHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodGet, Path: "/api/v1/admin/sync/bindings", Handler: chain(syncBindingsListHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/bindings", Handler: chain(syncBindingsEnsureHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodGet, Path: "/api/v1/admin/sync/channels/:id/inspect", Handler: chain(syncInspectHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/preview", Handler: chain(syncPreviewHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/channels/:id/drop-table", Handler: chain(syncDropTableHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodGet, Path: "/api/v1/admin/sync/bindings", Handler: chain(syncBindingsListHandler(svcCtx), rl, authMW, tenant, syncPush)},
{Method: http.MethodPost, Path: "/api/v1/admin/sync/bindings", Handler: chain(syncBindingsEnsureHandler(svcCtx), rl, authMW, tenant, syncPush)},
// —— 本机 sync agent形态 B白名单拉取 + 推线上 A需「数据同步」权限 ——
{Method: http.MethodGet, Path: "/api/v1/agent/sync/channels/:id/whitelist", Handler: chain(agentSyncWhitelistHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/push", Handler: chain(agentSyncPushHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/push/batch", Handler: chain(agentSyncPushBatchHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))},
// —— 本机 sync agent形态 B白名单 + push/pull「数据同步」租户级或登录用户按 Binding 自助 ——
{Method: http.MethodGet, Path: "/api/v1/agent/sync/channels/:id/whitelist", Handler: chain(agentSyncWhitelistHandler(svcCtx), rl, authMW, tenant, syncPush)},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/push", Handler: chain(agentSyncPushHandler(svcCtx), rl, authMW, tenant, syncPush)},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/push/batch", Handler: chain(agentSyncPushBatchHandler(svcCtx), rl, authMW, tenant, syncPush)},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/pull", Handler: chain(agentSyncPullHandler(svcCtx), rl, authMW, tenant, syncPush)},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/bootstrap", Handler: chain(agentSyncBootstrapHandler(svcCtx), rl, authMW, tenant, syncPush)},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/schema", Handler: chain(agentSyncSchemaDescribeHandler(svcCtx), rl, authMW, tenant, syncPush)},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/schema/ensure", Handler: chain(agentSyncSchemaEnsureHandler(svcCtx), rl, authMW, tenant, syncPush)},
// 存储POST 创建对象GET 读取(无 /upload 动词路径;旧路径保留别名防断裂)
{Method: http.MethodPost, Path: "/api/v1/storage", Handler: chain(uploadHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm上传文件))},

View File

@@ -3,8 +3,10 @@ package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"aijianzhan/platform/internal/audit"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/svc"
@@ -272,3 +274,128 @@ func syncIngestHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
httpx.OkJson(w, map[string]any{"ok": true, "ingested": n, "hint": "已写入本地并进入 outbox将同步到线上"})
}
}
// syncInspectHandler 控制台验同步:列出线上/本机库表 + 行数 + 列。
func syncInspectHandler(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
}
side := strings.TrimSpace(r.URL.Query().Get("side"))
if side == "" {
side = "remote"
}
includeMeta := r.URL.Query().Get("include_sync_meta") == "1" || r.URL.Query().Get("include_sync_meta") == "true"
var ep dbsync.Endpoint
switch side {
case "remote":
ep = ch.Remote
case "local":
ep = ch.Local
default:
authx.WriteError(w, http.StatusBadRequest, "side must be remote|local")
return
}
res, err := dbsync.InspectEndpoint(r.Context(), ep, side, includeMeta)
if err != nil && (res == nil || !res.OK) {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, res)
}
}
// syncPreviewHandler 预览单表内容(默认前 50 行)。
func syncPreviewHandler(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 {
Side string `json:"side"` // remote|local默认 remote
Table string `json:"table"`
Limit int `json:"limit"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
side := strings.TrimSpace(body.Side)
if side == "" {
side = "remote"
}
var ep dbsync.Endpoint
switch side {
case "remote":
ep = ch.Remote
case "local":
ep = ch.Local
default:
authx.WriteError(w, http.StatusBadRequest, "side must be remote|local")
return
}
res, err := dbsync.PreviewTable(r.Context(), ep, body.Table, body.Limit)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, res)
}
}
// syncDropTableHandler 控制台删业务表(仅当前 side 的 endpoint不同步 DDL 到另一侧)。
func syncDropTableHandler(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 {
Side string `json:"side"` // remote|local默认 remote
Table string `json:"table"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
side := strings.TrimSpace(body.Side)
if side == "" {
side = "remote"
}
var ep dbsync.Endpoint
switch side {
case "remote":
ep = ch.Remote
case "local":
ep = ch.Local
default:
authx.WriteError(w, http.StatusBadRequest, "side must be remote|local")
return
}
res, err := dbsync.DropTableOnEndpoint(r.Context(), ep, side, body.Table)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if svcCtx.Audit != nil {
_ = svcCtx.Audit.Log(r.Context(), syncTenantID(r), authx.UserID(r.Context()), "dbsync.drop_table", audit.DetailJSON(map[string]any{
"channel_id": ch.ID, "side": side, "table": body.Table, "dropped": res != nil && res.Dropped,
}))
}
httpx.OkJson(w, res)
}
}

View File

@@ -17,7 +17,16 @@ func syncBindingsListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return
}
localID := r.URL.Query().Get("local_database_id")
list, err := svcCtx.DBSync.Store().ListBindings(syncTenantID(r), localID)
tid := syncTenantID(r)
var (
list []dbsync.Binding
err error
)
if authx.SyncTenantWide(r.Context()) {
list, err = svcCtx.DBSync.Store().ListBindings(tid, localID)
} else {
list, err = svcCtx.DBSync.Store().ListBindingsFiltered(tid, authx.UserID(r.Context()), localID)
}
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
@@ -37,8 +46,14 @@ func syncBindingsEnsureHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return
}
body.TenantID = syncTenantID(r)
if body.UserID == 0 {
body.UserID = authx.UserID(r.Context())
uid := authx.UserID(r.Context())
if authx.SyncTenantWide(r.Context()) {
if body.UserID == 0 {
body.UserID = uid
}
} else {
// 用户自助:强制绑定到本人,禁止冒用他人 user_id
body.UserID = uid
}
saved, err := svcCtx.DBSync.Store().EnsureBinding(body)
if err != nil {