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

@@ -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)
}
}