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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user