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
+29 -29
View File
@@ -33,15 +33,15 @@ func rawKB(kb string) string {
return kb
}
// scopedKB 把知识库名锁进当前用户作用域:"owner/name"
// owner 来自身份(X-User-ID),客户端只发库名、发不了 owner,故无法越权查到他人的库。
// scopedKB 把知识库名锁进当前活跃工作区作用域:"space_id/name"(增量3 共享工作区)
// space_id 由 SpaceContext 中间件按身份+活跃空间注入,客户端发不了,故无法越权查他空间的库。
func scopedKB(c *gin.Context, kb string) string {
return userID(c) + "/" + rawKB(kb)
return spaceID(c) + "/" + rawKB(kb)
}
// KbList: GET /api/v1/kb/list —— 当前用户的知识库列表(按 owner 隔离)。
// KbList: GET /api/v1/kb/list —— 当前活跃工作区的知识库列表(同空间成员共享)。
func (h *Handler) KbList(c *gin.Context) {
rows, err := h.db.ListKB(c.Request.Context(), userID(c))
rows, err := h.db.ListKB(c.Request.Context(), spaceID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
@@ -63,7 +63,7 @@ func (h *Handler) KbCreate(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
return
}
if err := h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.Name), body.Kind); err != nil {
if err := h.db.EnsureKB(c.Request.Context(), spaceID(c), userID(c), rawKB(body.Name), body.Kind); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
@@ -80,8 +80,8 @@ func (h *Handler) KbIngest(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
return
}
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.KB), "general")
job, err := h.enqueueIngest(c.Request.Context(), userID(c), rawKB(body.KB), scopedKB(c, body.KB), "", "", nil, body.Text)
_ = h.db.EnsureKB(c.Request.Context(), spaceID(c), userID(c), rawKB(body.KB), "general")
job, err := h.enqueueIngest(c.Request.Context(), spaceID(c), userID(c), rawKB(body.KB), scopedKB(c, body.KB), "", "", nil, body.Text)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
@@ -102,9 +102,9 @@ func (h *Handler) KbSaveNote(c *gin.Context) {
return
}
owner := userID(c)
_ = h.db.EnsureKB(c.Request.Context(), owner, rawKB(body.KB), "general")
_ = h.db.EnsureKB(c.Request.Context(), spaceID(c), owner, rawKB(body.KB), "general")
// 落库 + 重建索引由入库工作队列统一处理(forceDoc=name 保持笔记身份)。
job, err := h.enqueueIngest(c.Request.Context(), owner, rawKB(body.KB), scopedKB(c, body.KB), body.Name, "", nil, body.Content)
job, err := h.enqueueIngest(c.Request.Context(), spaceID(c), owner, rawKB(body.KB), scopedKB(c, body.KB), body.Name, "", nil, body.Content)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
@@ -130,21 +130,21 @@ var wikiRe = regexp.MustCompile(`\[\[([^\]|]+)(\|[^\]]*)?\]\]`)
// KbVault: GET /api/v1/kb/vault?kb= —— 某知识库的全部原始文档(名+内容),供 Obsidian 式文库浏览。
func (h *Handler) KbVault(c *gin.Context) {
rows, err := h.db.ListVault(c.Request.Context(), userID(c), rawKB(c.Query("kb")))
rows, err := h.db.ListVault(c.Request.Context(), spaceID(c), rawKB(c.Query("kb")))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
docs := make([]gin.H, 0, len(rows))
for _, r := range rows {
docs = append(docs, gin.H{"id": r.ID, "name": r.Name, "ext": r.Ext, "size": r.Size, "preview": r.Preview})
docs = append(docs, gin.H{"id": r.ID, "name": r.Name, "ext": r.Ext, "size": r.Size, "preview": r.Preview, "owner": r.Owner})
}
c.JSON(http.StatusOK, gin.H{"docs": docs})
}
// KbDoc: GET /api/v1/kb/doc?id= —— 按文件 ID 取单篇全文(按需加载,不在列表里拉全量)。
func (h *Handler) KbDoc(c *gin.Context) {
d, err := h.db.GetDocByID(c.Request.Context(), userID(c), c.Query("id"))
d, err := h.db.GetDocByID(c.Request.Context(), spaceID(c), c.Query("id"))
if err != nil || d == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文档不存在"})
return
@@ -161,13 +161,13 @@ func (h *Handler) KbDoc(c *gin.Context) {
// KbDeleteDoc: DELETE /api/v1/kb/doc?id= —— 级联删一份文档:
// 三库(向量/全文/图谱,经 mcp-go kb_delete 按 file_id) + MinIO 原文 + PG 元数据/双链。owner 作用域防越权。
func (h *Handler) KbDeleteDoc(c *gin.Context) {
owner := userID(c)
d, err := h.db.GetDocByID(c.Request.Context(), owner, c.Query("id"))
sid := spaceID(c)
d, err := h.db.GetDocByID(c.Request.Context(), sid, c.Query("id"))
if err != nil || d == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文档不存在"})
return
}
scoped := d.Owner + "/" + d.KB
scoped := d.SpaceID + "/" + d.KB
// 「类事务」删除:先删依赖存储(三库/MinIO),PG 记录**最后删**。任一存储删失败 → 不删 PG、
// 保留记录报错让用户重试(三库/MinIO 删均幂等);杜绝「PG 删了却在三库/MinIO 留下再也删不掉的孤儿」。
// ① 三库按 file_id 级联删。失败 → 保留 PG 可重试。
@@ -192,7 +192,7 @@ func (h *Handler) KbDeleteDoc(c *gin.Context) {
}
}
// ③ PG 元数据 + 双链(最后删;到此依赖存储已清,PG 删成即整体一致)。
if e := h.db.DeleteDocByID(c.Request.Context(), owner, d.KB, d.ID); e != nil {
if e := h.db.DeleteDocByID(c.Request.Context(), sid, d.KB, d.ID); e != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": e.Error()})
return
}
@@ -201,7 +201,7 @@ func (h *Handler) KbDeleteDoc(c *gin.Context) {
// KbLinks: GET /api/v1/kb/links?kb= —— 某库已解析的 [[双链]](FromID→ToID),供反链/笔记关系图按 ID 渲染。
func (h *Handler) KbLinks(c *gin.Context) {
rows, err := h.db.ListLinks(c.Request.Context(), userID(c), rawKB(c.Query("kb")))
rows, err := h.db.ListLinks(c.Request.Context(), spaceID(c), rawKB(c.Query("kb")))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
@@ -248,8 +248,8 @@ func (h *Handler) KbIngestFile(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(kb), "general")
job, err := h.enqueueIngest(c.Request.Context(), userID(c), rawKB(kb), scopedKB(c, kb), "", fh.Filename, data, "")
_ = h.db.EnsureKB(c.Request.Context(), spaceID(c), userID(c), rawKB(kb), "general")
job, err := h.enqueueIngest(c.Request.Context(), spaceID(c), userID(c), rawKB(kb), scopedKB(c, kb), "", fh.Filename, data, "")
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
@@ -267,7 +267,7 @@ func StartIngestWorkers(ctx context.Context, db *store.Postgres, cache *store.Re
// enqueueIngest 把一次入库请求暂存到对象存储(claim-check),再发布作业到 JetStream 工作队列,
// 立即返回 job_id。暂存让作业消息恒小(不受 max_payload 限制)、崩溃重投只搬引用。
// MinIO 是准生产硬依赖(正文也一律落 MinIO);未就绪即拒绝入库,不静默退化。
func (h *Handler) enqueueIngest(ctx context.Context, owner, kbName, scoped, forceDoc, filename string, data []byte, rawText string) (string, error) {
func (h *Handler) enqueueIngest(ctx context.Context, spaceID, owner, kbName, scoped, forceDoc, filename string, data []byte, rawText string) (string, error) {
if !h.blob.Ready() {
return "", errors.New("对象存储未就绪,暂时无法入库")
}
@@ -282,7 +282,7 @@ func (h *Handler) enqueueIngest(ctx context.Context, owner, kbName, scoped, forc
return "", fmt.Errorf("暂存失败: %w", err)
}
jobMsg := &contract.IngestJob{
JobID: job, Owner: owner, KBName: kbName, Scoped: scoped,
JobID: job, Owner: owner, SpaceID: spaceID, KBName: kbName, Scoped: scoped,
ForceDoc: forceDoc, Filename: filename, StageKey: stageKey, IsText: isText,
}
if err := h.bus.PublishIngestJob(ctx, jobMsg); err != nil {
@@ -309,7 +309,7 @@ func (h *Handler) processIngestJob(ctx context.Context, job *contract.IngestJob,
} else {
data = []byte(payload)
}
retryable, rerr := h.runIngest(ctx, job.JobID, job.Owner, job.KBName, job.Scoped, job.ForceDoc, job.Filename, data, rawText)
retryable, rerr := h.runIngest(ctx, job.JobID, job.SpaceID, job.Owner, job.KBName, job.Scoped, job.ForceDoc, job.Filename, data, rawText)
if rerr != nil && retryable && !lastAttempt {
return rerr // 瞬时失败 → 保留暂存,延迟重投
}
@@ -318,11 +318,11 @@ func (h *Handler) processIngestJob(ctx context.Context, job *contract.IngestJob,
}
// runIngest 跑入库流水线,逐阶段把进度发到 sundynix.streams.<job>,由入库 worker 调用。
// owner+kbName 用于"文库"原文留存;scoped 是 owner/kb 作向量/全文/图谱分区键。
// spaceID 是作用域(共享工作区),owner 记创建人;kbName 用于"文库"原文留存;scoped 是 space_id/kb 作三库分区键。
// forceDoc 非空时强制以它为文档名(笔记编辑用,保持笔记身份稳定)。
// filename 非空表示文件入库(先经 mcp-py 解析);否则用 rawText。
// 返回 (retryable, err):解析失败=终态(坏输入重试无益);kb_ingest 基建失败=瞬时可重试。
func (h *Handler) runIngest(ctx context.Context, job, owner, kbName, scoped, forceDoc, filename string, data []byte, rawText string) (retryable bool, err error) {
func (h *Handler) runIngest(ctx context.Context, job, spaceID, owner, kbName, scoped, forceDoc, filename string, data []byte, rawText string) (retryable bool, err error) {
emit := func(ev contract.IngestEvent) { _ = h.bus.PublishIngest(job, &ev) }
time.Sleep(400 * time.Millisecond) // 给 SSE 客户端订阅时间(core NATS 无缓冲)
@@ -359,7 +359,7 @@ func (h *Handler) runIngest(ctx context.Context, job, owner, kbName, scoped, for
// 正文一律落对象存储(MinIO),PG 只留元数据+预览+对象键(不分大小,不把正文塞进 PG)。
// 仅当 MinIO 不可用或写失败时,才回退内联,保证正文不丢。
if h.blob.Ready() {
key := owner + "/" + kbName + "/" + docName
key := spaceID + "/" + kbName + "/" + docName // 对象键按 space 分区,避免跨空间同名撞键
if err := h.blob.Put(ctx, key, text); err == nil {
inline, objectKey = "", key
} else {
@@ -368,7 +368,7 @@ func (h *Handler) runIngest(ctx context.Context, job, owner, kbName, scoped, for
} else {
log.Printf("[gateway] MinIO 未就绪,正文回退内联存 PGdoc=%s", docName)
}
docID, oldKey, err := h.db.SaveDoc(ctx, owner, kbName, docName, ext, inline, objectKey, size, head(text, 500))
docID, oldKey, err := h.db.SaveDoc(ctx, spaceID, owner, kbName, docName, ext, inline, objectKey, size, head(text, 500))
if err != nil {
log.Printf("[gateway] 文件入库失败: %v", err)
} else if docID != "" {
@@ -378,8 +378,8 @@ func (h *Handler) runIngest(ctx context.Context, job, owner, kbName, scoped, for
h.blob.Delete(ctx, oldKey)
log.Printf("[gateway] 清理被覆盖的 MinIO 孤儿对象: %s", oldKey)
}
_ = h.db.ReplaceDocLinks(ctx, owner, kbName, docID, wikiLinks(text)) // 以本文件 ID 维护出链
_ = h.db.ResolveInboundLinks(ctx, owner, kbName, docName, docID) // 回填指向本文件的悬空链接
_ = h.db.ReplaceDocLinks(ctx, spaceID, owner, kbName, docID, wikiLinks(text)) // 以本文件 ID 维护出链
_ = h.db.ResolveInboundLinks(ctx, spaceID, kbName, docName, docID) // 回填指向本文件的悬空链接
}
}
@@ -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")