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
|
||||
|
||||
@@ -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: 本机空表结构推到线上 A(CREATE 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
|
||||
|
||||
@@ -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上传文件))},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user