feat(space): 共享工作区增量3b —— KB 知识库按 Space 共享(存储 re-key)

把 3a 的 Space 作用域推到 KB 层:KB/Doc/DocLink owner→space_id,作用域键
owner/name→space_id/name,同空间成员共享知识库(检索/入库/文库/双链/图谱)。

后端:
- store: KB/Doc/DocLink 加 space_id,唯一索引 (owner,*)→(space_id,*),owner 降级创建人;
  查询全改 space 作用域;SaveDoc/ListVault/GetDocByID/DeleteDocByID/ReplaceDocLinks/
  ResolveInboundLinks/ListLinks 改 space;tenantIDForSpace 补异步入库租户
- MigrateKBSpaces 启动迁移(space_id 回填 + 唯一索引换新,同 Agent 顺序坑规避)
- scopedKB owner/name→space_id/name;IngestJob 契约加 SpaceID;enqueueIngest/runIngest
  穿 space;MinIO 对象键改 space/kb/doc(避免跨空间同名撞键,老键不透明不迁)
- KB 写路由(create/ingest/ingest_file/note/delete)挂 RequireSpaceRole(member):viewer 只读
- 存储层重灌迁移端点 POST /admin/migrate-kb-storage(异步):为存量文档入队新 space
  作用域的重灌作业(复用 JetStream 入库 worker 池),先删旧键;避免同步重嵌撑爆 HTTP 超时

桌面端:
- KbView 收 spaceId(变则重拉库)+spaceReadOnly(viewer 禁建库/入库/文件/笔记);VaultPanel 同

验证(gateway+mcp-go+Milvus/Neo4j/embedding 全栈):
- PG 迁移: 20/21 KB + 50/54 doc 回填 space_id(4 未迁=pre-多租户 owner='wt' 空租户遗留,
  正确跳过),唯一索引 idx_kb_sn/idx_doc_skn 换新、旧索引删除
- KB 共享: member 见共享库 / viewer 建库·入库 403 / 切回个人空间隔离(看不到)
- 全向量链路: RagA 入库(真 dashscope embedding)→ RagB(空间member)检索命中 RagA 内容
- 存储重灌: 端点异步入队 49 作业(worker 池背压处理),重灌后老文档在新 space 键可检索

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-13 13:00:34 +08:00
parent addaa1b34f
commit a8c0bb42a7
9 changed files with 218 additions and 97 deletions
@@ -1,12 +1,14 @@
package handler
import (
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-shared/contract"
)
// SpacesList: GET /api/v1/me/spaces —— 当前活跃租户内我所属的工作区(供切换器)+ 当前活跃空间 id。
@@ -158,6 +160,47 @@ func (h *Handler) SpaceRemoveMember(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// MigrateKBStorage: POST /api/v1/admin/migrate-kb-storage —— 一次性把存量 KB 的三库(向量/全文/图谱)
// 从旧作用域键 owner/kb 重灌到新键 space_id/kb(增量3 存储层迁移)。RequireAdmin 保护。
// **异步**:为每篇文档在新 space 作用域下入队一条重灌作业(复用 JetStream 入库 worker 池:有界并发+
// 背压+崩溃重投),并先删旧键条目。立即返回入队数,进度经入库 worker 日志/SSE 看。避免同步重嵌几十篇撑爆 HTTP 超时。
func (h *Handler) MigrateKBStorage(c *gin.Context) {
docs, err := h.db.DocsForRestorage(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
enqueued, skipped := 0, 0
for _, d := range docs {
newScoped := d.SpaceID + "/" + d.KB
oldScoped := d.Owner + "/" + d.KB
if newScoped == oldScoped {
skipped++
continue
}
text := d.Content
if d.ObjectKey != "" && h.blob.Ready() {
if obj, oerr := h.blob.Get(c.Request.Context(), d.ObjectKey); oerr == nil {
text = obj
}
}
if strings.TrimSpace(text) == "" {
skipped++
continue
}
// 先删旧键条目(best-effort,幂等)。
_, _ = h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_delete"),
&contract.ToolCall{Tool: "kb_delete", Args: map[string]any{"kb": oldScoped, "file_id": d.ID}})
// 入队新 space 作用域的重灌作业(forceDoc=原名保持文档身份;worker 走完整入库管线)。
if _, e := h.enqueueIngest(c.Request.Context(), d.SpaceID, d.Owner, d.KB, newScoped, d.Name, "", nil, text); e != nil {
log.Printf("[migrate-kb] 入队重灌失败 doc=%s: %v", d.ID, e)
continue
}
enqueued++
}
c.JSON(http.StatusOK, gin.H{"total": len(docs), "enqueued": enqueued, "skipped": skipped})
}
// SpaceArchive: POST /api/v1/spaces/:id/archive —— 归档空间(须空间 admin;个人空间不可)。
func (h *Handler) SpaceArchive(c *gin.Context) {
sid := c.Param("id")