From a8c0bb42a75dc714321a94ef5d7466b70108f840 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 13 Jul 2026 13:00:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(space):=20=E5=85=B1=E4=BA=AB=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E5=A2=9E=E9=87=8F3b=20=E2=80=94=E2=80=94=20K?= =?UTF-8?q?B=20=E7=9F=A5=E8=AF=86=E5=BA=93=E6=8C=89=20Space=20=E5=85=B1?= =?UTF-8?q?=E4=BA=AB(=E5=AD=98=E5=82=A8=20re-key)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 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 --- sundynix-desktop/frontend/src/App.tsx | 2 +- .../frontend/src/views/KbView.tsx | 13 ++- sundynix-gateway/cmd/server/main.go | 5 + sundynix-gateway/internal/handler/kb.go | 58 +++++----- sundynix-gateway/internal/handler/space.go | 43 +++++++ sundynix-gateway/internal/router/router.go | 15 +-- sundynix-gateway/internal/store/model.go | 109 +++++++++--------- sundynix-gateway/internal/store/space.go | 65 +++++++++++ sundynix-shared/contract/ingest.go | 5 +- 9 files changed, 218 insertions(+), 97 deletions(-) diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 994c092..c661413 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -298,7 +298,7 @@ export default function App() { ) : view === "studio" ? ( ) : view === "kb" ? ( - + ) : view === "report" ? ( ) : view === "runs" ? ( diff --git a/sundynix-desktop/frontend/src/views/KbView.tsx b/sundynix-desktop/frontend/src/views/KbView.tsx index 5f5e5fd..7949940 100644 --- a/sundynix-desktop/frontend/src/views/KbView.tsx +++ b/sundynix-desktop/frontend/src/views/KbView.tsx @@ -114,7 +114,7 @@ function dedupTriples(ts: Triple[]): Triple[] { } // 知识库:owner 隔离 + 项目/案件/文件夹组织;Tab 分(入库 / 文库(Obsidian 式) / 检索 / 图谱)。 -export function KbView({ identity }: { identity: Identity }) { +export function KbView({ identity, spaceId = "", spaceReadOnly = false }: { identity: Identity; spaceId?: string; spaceReadOnly?: boolean }) { const toast = useToast(); const [kbs, setKbs] = useState([]); const [kb, setKb] = useState("default"); @@ -150,11 +150,13 @@ export function KbView({ identity }: { identity: Identity }) { /* 降级用默认库 */ } }, [identity]); + // 切换工作区(spaceId 变)后重拉该空间的知识库(共享工作区隔离)。 useEffect(() => { void refreshKbs(); - }, [refreshKbs]); + }, [refreshKbs, spaceId]); const onCreate = async () => { + if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权新建知识库"); const name = newName.trim(); if (!name) return; try { @@ -212,6 +214,7 @@ export function KbView({ identity }: { identity: Identity }) { }; const onIngest = async () => { + if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权入库"); if (!text.trim()) return; try { const job = await ingestKb(identity, kb, text); @@ -223,6 +226,7 @@ export function KbView({ identity }: { identity: Identity }) { }; const ingestFiles = (list: FileList | File[] | null | undefined) => { + if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权入库"); const arr = Array.from(list ?? []); arr.forEach((file, idx) => { const fid = `${file.name}-${idx}-${stamp()}-${Math.round(file.size)}`; @@ -387,7 +391,7 @@ export function KbView({ identity }: { identity: Identity }) { )} - {tab === "vault" && } + {tab === "vault" && } {tab === "search" && (
@@ -436,7 +440,7 @@ export function KbView({ identity }: { identity: Identity }) { // VaultPanel:Obsidian 式文库 —— 列表(仅元数据) / 正文按需加载 / [[双链]]可点 / 反链 / 笔记关系图。 // 列表与正文分离 + 链接走服务端索引,不再一次拉回整库正文,可扛十几万字大文件。 -function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { +function VaultPanel({ identity, kb, spaceReadOnly = false }: { identity: Identity; kb: string; spaceReadOnly?: boolean }) { const toast = useToast(); const [docs, setDocs] = useState([]); const [links, setLinks] = useState([]); @@ -528,6 +532,7 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { setDraft(content); }; const onSave = async () => { + if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权编辑笔记"); const name = (creatingNew ? draftName : current?.name ?? "").trim(); if (!name || !draft.trim()) { toast.push("error", "笔记名与内容不能为空"); diff --git a/sundynix-gateway/cmd/server/main.go b/sundynix-gateway/cmd/server/main.go index 6e8b227..d81cb58 100644 --- a/sundynix-gateway/cmd/server/main.go +++ b/sundynix-gateway/cmd/server/main.go @@ -64,6 +64,11 @@ func main() { if err := db.MigrateAgentSpaces(context.Background()); err != nil { log.Printf("[startup] Agent 空间作用域迁移失败: %v", err) } + // KB/Doc/DocLink 的 PG 作用域迁移(space_id 回填 + 唯一索引换新)。存储层(Milvus/Bleve/Neo4j) + // 的存量向量另由一次性重灌迁移处理(不塞启动,避免每次重启重嵌 + 依赖 mcp-go 启动顺序)。 + if err := db.MigrateKBSpaces(context.Background()); err != nil { + log.Printf("[startup] KB 空间作用域迁移失败: %v", err) + } cache := store.OpenRedis(redisAddr) // CacheDB: Session / Rate Limit(连不上则降级) defer cache.Close() bus := nats.MustConnect(natsURL) // 接入 NATS 零拷贝骨干网 + 声明任务流 diff --git a/sundynix-gateway/internal/handler/kb.go b/sundynix-gateway/internal/handler/kb.go index 0717159..483f625 100644 --- a/sundynix-gateway/internal/handler/kb.go +++ b/sundynix-gateway/internal/handler/kb.go @@ -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.,由入库 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 未就绪,正文回退内联存 PG(doc=%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) // 回填指向本文件的悬空链接 } } diff --git a/sundynix-gateway/internal/handler/space.go b/sundynix-gateway/internal/handler/space.go index 4b083a9..bf5a177 100644 --- a/sundynix-gateway/internal/handler/space.go +++ b/sundynix-gateway/internal/handler/space.go @@ -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") diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 1c3c635..253de37 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -65,14 +65,14 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert) p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板) p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=) - p.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离) - p.POST("/kb/create", h.KbCreate) // 新建知识库 - p.POST("/kb/ingest", h.KbIngest) // 文本入库 - p.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库 - p.POST("/kb/search", h.KbSearch) // 检索台 + p.GET("/kb/list", h.KbList) // 当前空间的知识库列表(共享工作区) + p.POST("/kb/create", middleware.RequireSpaceRole(db, store.RoleMember), h.KbCreate) // 新建知识库:空间只读 viewer 拦下 + p.POST("/kb/ingest", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngest) // 文本入库:viewer 拦下 + p.POST("/kb/ingest_file", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngestFile) // 文件入库:viewer 拦下 + p.POST("/kb/search", h.KbSearch) // 检索台(读,全员) p.GET("/kb/vault", h.KbVault) // 文库列表 p.GET("/kb/doc", h.KbDoc) // 取单篇文档 - p.DELETE("/kb/doc", h.KbDeleteDoc) // 级联删文档(三库+MinIO+PG) + p.DELETE("/kb/doc", middleware.RequireSpaceRole(db, store.RoleMember), h.KbDeleteDoc) // 级联删文档:viewer 拦下 // Prompt 控制面(平台级配置:建版本 → 激活 → 控制面热下发各服务) p.GET("/prompts", h.PromptList) // 列出全部版本 + 可配键 @@ -80,7 +80,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. p.POST("/prompts/activate", middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计) p.POST("/prompts/deactivate", middleware.Audit(db), h.PromptDeactivate) // 撤销激活 → 回退代码默认(热,审计) p.GET("/kb/links", h.KbLinks) // 某库双链 - p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记 + p.POST("/kb/note", middleware.RequireSpaceRole(db, store.RoleMember), h.KbSaveNote) // 新建/编辑笔记:viewer 拦下 p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组 p.GET("/agents", h.AgentList) // 当前空间的编排列表(共享工作区,含创建人) p.POST("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentSave) // 保存/更新编排:空间只读 viewer 拦下 @@ -127,6 +127,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册 admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康 admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 + admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性) admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页) admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页) } diff --git a/sundynix-gateway/internal/store/model.go b/sundynix-gateway/internal/store/model.go index 8a9c600..3a1b1a5 100644 --- a/sundynix-gateway/internal/store/model.go +++ b/sundynix-gateway/internal/store/model.go @@ -11,32 +11,34 @@ import ( "github.com/sundynix/sundynix-shared/contract" ) -// KB 是一个知识库(按 owner 隔离 + 按 kind 组织:文件夹/项目/案件/通用)。 -// 表名 sundynix_kb。(owner,name) 唯一 —— 同一用户下知识库名不重复。 -// 向量/全文/图谱实际以 "owner/name" 作分区键,保证只有 owner 能查到自己的库。 +// KB 是一个知识库(增量3:按 space 共享 + 按 kind 组织:文件夹/项目/案件/通用)。 +// 表名 sundynix_kb。作用域从 owner 改为 space_id——(space_id,name) 唯一,同一工作区内库名不重复; +// owner 降级为创建人。向量/全文/图谱以 "space_id/name" 作分区键(见 handler.scopedKB)。 +// 唯一索引不在结构体标记里建(AutoMigrate 早于回填会撞车),由 MigrateKBSpaces 回填后显式建。 type KB struct { BaseModel TenantID string `gorm:"size:64;index"` // 多租户作用域(gorm tenant 插件按上下文自动填/过滤) - Owner string `gorm:"size:64;uniqueIndex:idx_kb_owner_name"` - Name string `gorm:"size:64;uniqueIndex:idx_kb_owner_name"` + SpaceID string `gorm:"size:64;index"` // 所属工作区(共享单位;唯一约束见 MigrateKBSpaces) + Owner string `gorm:"size:64;index"` // 创建人 user.id(归属,非隔离键) + Name string `gorm:"size:64"` Kind string `gorm:"size:16"` // folder / project / case / general } func (KB) TableName() string { return "sundynix_kb" } func (KB) isTenantScoped() {} -// ListKB 列出某 owner 的全部知识库(按创建时间)。 -func (p *Postgres) ListKB(ctx context.Context, owner string) ([]KB, error) { +// ListKB 列出某工作区的全部知识库(按创建时间;tenant 插件仍按 ctx 租户过滤)。 +func (p *Postgres) ListKB(ctx context.Context, spaceID string) ([]KB, error) { if p.db == nil { return nil, nil } var rows []KB - err := p.db.WithContext(ctx).Where("owner = ?", owner).Order("id").Find(&rows).Error + err := p.db.WithContext(ctx).Where("space_id = ?", spaceID).Order("id").Find(&rows).Error return rows, err } -// EnsureKB 幂等登记一个知识库(已存在则保持,不覆盖 kind)。 -func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error { +// EnsureKB 幂等登记一个知识库到某工作区(已存在则保持,不覆盖 kind;owner 记创建人)。 +func (p *Postgres) EnsureKB(ctx context.Context, spaceID, owner, name, kind string) error { if p.db == nil { return nil // 降级模式:不持久化注册表,不阻断入库 } @@ -44,9 +46,9 @@ func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error kind = "general" } return p.db.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "owner"}, {Name: "name"}}, + Columns: []clause.Column{{Name: "space_id"}, {Name: "name"}}, DoNothing: true, - }).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error + }).Create(&KB{SpaceID: spaceID, Owner: owner, Name: name, Kind: kind}).Error } // Agent 是一份保存的 Agent 编排(React Flow 图 JSON)。 @@ -126,14 +128,16 @@ func (p *Postgres) DeleteAgent(ctx context.Context, spaceID, name string) error } // Doc 是入库的一份文件/笔记 —— 文件主表(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。 -// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。文档间关联一律用本表的雪花 ID 关联。 +// 表名 sundynix_doc。增量3:作用域 owner→space_id,(space_id,kb,name) 唯一;owner 降级为创建人(上传者)。 +// 唯一索引由 MigrateKBSpaces 回填后显式建(AutoMigrate 早于回填会撞车)。 type Doc struct { BaseModel TenantID string `gorm:"size:64;index"` // 多租户作用域(入库异步无请求 ctx,SaveDoc 按 owner 补) - Owner string `gorm:"size:64;uniqueIndex:idx_doc_okn"` - KB string `gorm:"size:64;uniqueIndex:idx_doc_okn"` - Name string `gorm:"size:160;uniqueIndex:idx_doc_okn"` // 文件名称(不含扩展名 / 笔记名) - Ext string `gorm:"size:16"` // 文件后缀(.md/.pdf/.docx…;笔记/文本为空) + SpaceID string `gorm:"size:64;index"` // 所属工作区(共享单位;唯一约束见 MigrateKBSpaces) + Owner string `gorm:"size:64;index"` // 创建人/上传者 user.id(归属,非隔离键) + KB string `gorm:"size:64;index"` + Name string `gorm:"size:160"` // 文件名称(不含扩展名 / 笔记名) + Ext string `gorm:"size:16"` // 文件后缀(.md/.pdf/.docx…;笔记/文本为空) Size int // 原文字数(rune) Preview string `gorm:"size:600"` // 前若干字预览(列表/反链用,不拉全文) Content string `gorm:"type:text"` // 正文一律落 MinIO 后置空;仅 MinIO 不可用时回退内联存这里 @@ -143,14 +147,15 @@ type Doc struct { func (Doc) TableName() string { return "sundynix_doc" } func (Doc) isTenantScoped() {} -// DocLink 是文档间 [[双链]] 的索引(owner+kb 内 from→to),以 Doc.ID 关联,供反链/笔记关系图按 SQL 查询, -// 避免在前端扫全部正文。入库/编辑时按 from 文档重建其出链;目标尚未入库时 ToID 为空(悬空,记 ToName 待回填)。 +// DocLink 是文档间 [[双链]] 的索引(space+kb 内 from→to),以 Doc.ID 关联,供反链/笔记关系图按 SQL 查询。 +// 增量3:作用域 owner→space_id。入库/编辑时按 from 文档重建其出链;目标尚未入库时 ToID 为空(悬空)。 type DocLink struct { BaseModel - TenantID string `gorm:"size:64;index"` // 多租户作用域(ReplaceDocLinks 按 owner 补) - Owner string `gorm:"size:64;index:idx_link_of"` - KB string `gorm:"size:64;index:idx_link_of"` - FromID string `gorm:"size:24;index:idx_link_of"` // 源文档 Doc.ID + TenantID string `gorm:"size:64;index"` // 多租户作用域(ReplaceDocLinks 按 space 的租户补) + SpaceID string `gorm:"size:64;index:idx_link_sf"` + Owner string `gorm:"size:64;index"` // 创建人(归属) + KB string `gorm:"size:64;index:idx_link_sf"` + FromID string `gorm:"size:24;index:idx_link_sf"` // 源文档 Doc.ID ToID string `gorm:"size:24;index"` // 目标文档 Doc.ID(空=悬空:目标尚未入库) ToName string `gorm:"size:160"` // [[原始名]],供悬空链接展示 / 目标入库后回填 ToID } @@ -158,19 +163,17 @@ type DocLink struct { func (DocLink) TableName() string { return "sundynix_doc_link" } func (DocLink) isTenantScoped() {} -// SaveDoc 写入/更新一份文件(owner+kb+name 唯一,重名覆盖),返回 (文件雪花 ID, 被覆盖的旧对象键)。 -// oldObjectKey 是重名覆盖前该文档的 ObjectKey(新建则为空)—— 供调用方清理 MinIO 孤儿对象。 -// content 为内联正文(大文档转 MinIO 时传空 + objectKey);ext/md5/preview/size 由调用方按全文给出。 -func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, ext, content, objectKey string, size int, preview string) (id, oldObjectKey string, err error) { +// SaveDoc 写入/更新一份文件(space_id+kb+name 唯一,重名覆盖),返回 (文件雪花 ID, 被覆盖的旧对象键)。 +// space_id 是作用域,owner 记创建人(上传者)。tenant_id 从 space 的租户补(异步入库无请求 ctx 租户)。 +func (p *Postgres) SaveDoc(ctx context.Context, spaceID, owner, kb, name, ext, content, objectKey string, size int, preview string) (id, oldObjectKey string, err error) { if p.db == nil { return "", "", nil } - // 入库为异步 worker(无请求 ctx 租户)→ 显式按 owner 补 tenant_id(插件在此不会自动填)。 - tenantID := p.tenantIDForOwner(ctx, owner) + tenantID := p.tenantIDForSpace(ctx, spaceID) var d Doc - qerr := p.db.WithContext(ctx).Where("owner = ? AND kb = ? AND name = ?", owner, kb, name).First(&d).Error + qerr := p.db.WithContext(ctx).Where("space_id = ? AND kb = ? AND name = ?", spaceID, kb, name).First(&d).Error if errors.Is(qerr, gorm.ErrRecordNotFound) { - d = Doc{TenantID: tenantID, Owner: owner, KB: kb, Name: name, Ext: ext, Content: content, ObjectKey: objectKey, Size: size, Preview: preview} + d = Doc{TenantID: tenantID, SpaceID: spaceID, Owner: owner, KB: kb, Name: name, Ext: ext, Content: content, ObjectKey: objectKey, Size: size, Preview: preview} if err := p.db.WithContext(ctx).Create(&d).Error; err != nil { return "", "", err } @@ -181,7 +184,7 @@ func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, ext, content, o } oldObjectKey = d.ObjectKey // 覆盖前的旧对象键 if tenantID != "" { - d.TenantID = tenantID // 覆盖路径也补齐(老行可能为空);查不到租户则不动,别把已有值抹空 + d.TenantID = tenantID // 覆盖路径也补齐(老行可能为空);查不到则不动,别把已有值抹空 } d.Ext, d.Content, d.ObjectKey, d.Size, d.Preview = ext, content, objectKey, size, preview if err := p.db.WithContext(ctx).Save(&d).Error; err != nil { @@ -191,52 +194,50 @@ func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, ext, content, o } // ListVault 返回文库列表(仅元数据 + 预览,不含全文),避免一次拉回整库正文。 -func (p *Postgres) ListVault(ctx context.Context, owner, kb string) ([]Doc, error) { +func (p *Postgres) ListVault(ctx context.Context, spaceID, kb string) ([]Doc, error) { if p.db == nil { return nil, nil } var rows []Doc err := p.db.WithContext(ctx). - Select("id", "name", "ext", "size", "preview", "object_key", "updated_at"). - Where("owner = ? AND kb = ?", owner, kb).Order("updated_at desc").Find(&rows).Error + Select("id", "name", "ext", "size", "preview", "object_key", "owner", "updated_at"). + Where("space_id = ? AND kb = ?", spaceID, kb).Order("updated_at desc").Find(&rows).Error return rows, err } -// GetDocByID 按文件 ID 取单篇文档(含全文 Content 与 ObjectKey),owner 作用域防越权。 -func (p *Postgres) GetDocByID(ctx context.Context, owner, id string) (*Doc, error) { +// GetDocByID 按文件 ID 取单篇文档(含全文 Content 与 ObjectKey),space 作用域防越权。 +func (p *Postgres) GetDocByID(ctx context.Context, spaceID, id string) (*Doc, error) { if p.db == nil { return nil, nil } var d Doc - if err := p.db.WithContext(ctx).Where("owner = ? AND id = ?", owner, id).First(&d).Error; err != nil { + if err := p.db.WithContext(ctx).Where("space_id = ? AND id = ?", spaceID, id).First(&d).Error; err != nil { return nil, err } return &d, nil } -// DeleteDocByID 删除一份文件的 PG 痕迹(owner 作用域防越权):doc 行 + 其出链/入链。 -// 入链(别处指向本文件)一并删,避免悬空脏链。文档不存在视作已删(返回 nil)。 -func (p *Postgres) DeleteDocByID(ctx context.Context, owner, kb, id string) error { +// DeleteDocByID 删除一份文件的 PG 痕迹(space 作用域防越权):doc 行 + 其出链/入链。 +func (p *Postgres) DeleteDocByID(ctx context.Context, spaceID, kb, id string) error { if p.db == nil { return nil } return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("owner = ? AND kb = ? AND (from_id = ? OR to_id = ?)", owner, kb, id, id).Delete(&DocLink{}).Error; err != nil { + if err := tx.Where("space_id = ? AND kb = ? AND (from_id = ? OR to_id = ?)", spaceID, kb, id, id).Delete(&DocLink{}).Error; err != nil { return err } - return tx.Where("owner = ? AND id = ?", owner, id).Delete(&Doc{}).Error + return tx.Where("space_id = ? AND id = ?", spaceID, id).Delete(&Doc{}).Error }) } // ReplaceDocLinks 以源文件 ID 重建其出链(先删旧,再按 [[名称]] 解析目标 ID 后插新)—— 入库/编辑时调用。 -// 目标文档尚未入库时 ToID 留空(悬空),待其入库时由 ResolveInboundLinks 回填。 -func (p *Postgres) ReplaceDocLinks(ctx context.Context, owner, kb, fromID string, toNames []string) error { +func (p *Postgres) ReplaceDocLinks(ctx context.Context, spaceID, owner, kb, fromID string, toNames []string) error { if p.db == nil { return nil } - tenantID := p.tenantIDForOwner(ctx, owner) // 异步入库路径无请求 ctx 租户,按 owner 补 + tenantID := p.tenantIDForSpace(ctx, spaceID) return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("owner = ? AND kb = ? AND from_id = ?", owner, kb, fromID).Delete(&DocLink{}).Error; err != nil { + if err := tx.Where("space_id = ? AND kb = ? AND from_id = ?", spaceID, kb, fromID).Delete(&DocLink{}).Error; err != nil { return err } for _, name := range toNames { @@ -245,13 +246,13 @@ func (p *Postgres) ReplaceDocLinks(ctx context.Context, owner, kb, fromID string } var t Doc toID := "" - if e := tx.Select("id").Where("owner = ? AND kb = ? AND name = ?", owner, kb, name).First(&t).Error; e == nil { + if e := tx.Select("id").Where("space_id = ? AND kb = ? AND name = ?", spaceID, kb, name).First(&t).Error; e == nil { toID = t.ID } if toID == fromID { // 自链跳过 continue } - if err := tx.Create(&DocLink{TenantID: tenantID, Owner: owner, KB: kb, FromID: fromID, ToID: toID, ToName: name}).Error; err != nil { + if err := tx.Create(&DocLink{TenantID: tenantID, SpaceID: spaceID, Owner: owner, KB: kb, FromID: fromID, ToID: toID, ToName: name}).Error; err != nil { return err } } @@ -259,23 +260,23 @@ func (p *Postgres) ReplaceDocLinks(ctx context.Context, owner, kb, fromID string }) } -// ResolveInboundLinks 把指向 name 的悬空链接(ToID 空)回填为 id —— 目标文档入库后调用,使反链/关系图即时成形。 -func (p *Postgres) ResolveInboundLinks(ctx context.Context, owner, kb, name, id string) error { +// ResolveInboundLinks 把指向 name 的悬空链接(ToID 空)回填为 id —— 目标文档入库后调用。 +func (p *Postgres) ResolveInboundLinks(ctx context.Context, spaceID, kb, name, id string) error { if p.db == nil { return nil } return p.db.WithContext(ctx).Model(&DocLink{}). - Where("owner = ? AND kb = ? AND to_name = ? AND (to_id = '' OR to_id IS NULL)", owner, kb, name). + Where("space_id = ? AND kb = ? AND to_name = ? AND (to_id = '' OR to_id IS NULL)", spaceID, kb, name). Update("to_id", id).Error } // ListLinks 返回某 kb 已解析(两端均为本库文件)的 [[双链]](FromID→ToID),供反链/笔记关系图按 ID 渲染。 -func (p *Postgres) ListLinks(ctx context.Context, owner, kb string) ([]DocLink, error) { +func (p *Postgres) ListLinks(ctx context.Context, spaceID, kb string) ([]DocLink, error) { if p.db == nil { return nil, nil } var rows []DocLink - err := p.db.WithContext(ctx).Where("owner = ? AND kb = ? AND to_id <> ''", owner, kb).Find(&rows).Error + err := p.db.WithContext(ctx).Where("space_id = ? AND kb = ? AND to_id <> ''", spaceID, kb).Find(&rows).Error return rows, err } diff --git a/sundynix-gateway/internal/store/space.go b/sundynix-gateway/internal/store/space.go index e562742..e78e240 100644 --- a/sundynix-gateway/internal/store/space.go +++ b/sundynix-gateway/internal/store/space.go @@ -45,6 +45,18 @@ const ( // ---- 基础查询 ---- +// tenantIDForSpace 返回某空间的租户 id(异步入库路径按 space 补 tenant_id 用)。 +func (p *Postgres) tenantIDForSpace(ctx context.Context, spaceID string) string { + if p.db == nil || spaceID == "" { + return "" + } + var s Space + if err := p.db.WithContext(ctx).Select("tenant_id").First(&s, "id = ?", spaceID).Error; err != nil { + return "" + } + return s.TenantID +} + // GetSpace 按 id 取空间(不存在返回 nil)。 func (p *Postgres) GetSpace(ctx context.Context, id string) (*Space, error) { if p.db == nil { @@ -335,3 +347,56 @@ func (p *Postgres) MigrateAgentSpaces(ctx context.Context) error { } return nil } + +// DocForRestorage 是存储层重灌迁移用的一条文档视图(拿回原文所需的键)。 +type DocForRestorage struct { + ID string + SpaceID string + Owner string + KB string + Name string + Content string + ObjectKey string +} + +// DocsForRestorage 列出全部有 space_id 的存量文档(系统级跨租户;供存储层 owner/kb→space/kb 重灌)。 +func (p *Postgres) DocsForRestorage(ctx context.Context) ([]DocForRestorage, error) { + if p.db == nil { + return nil, errStoreDisabled + } + var rows []DocForRestorage + err := p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_doc"). + Select("id, space_id, owner, kb, name, content, object_key"). + Where("space_id <> '' AND owner <> ''").Order("created_at asc").Scan(&rows).Error + return rows, err +} + +// MigrateKBSpaces 把存量 KB/Doc/DocLink 的 space_id 回填为其 (tenant_id, owner) 个人空间, +// 再 DROP 旧唯一索引、建新唯一索引(同 MigrateAgentSpaces 的顺序坑规避)。全程幂等。 +// 注:仅迁移 PG 元数据作用域;Milvus/Bleve/Neo4j 的存量向量/全文/图谱另由 kb 存储重灌迁移。 +func (p *Postgres) MigrateKBSpaces(ctx context.Context) error { + if p.db == nil { + return errStoreDisabled + } + sub := "SELECT id FROM sundynix_space s WHERE s.tenant_id = t.tenant_id AND s.creator = t.owner AND s.kind = 'personal' ORDER BY s.created_at ASC LIMIT 1" + for _, tbl := range []string{"sundynix_kb", "sundynix_doc", "sundynix_doc_link"} { + if err := p.db.WithContext(ctx).Exec( + "UPDATE "+tbl+" t SET space_id = ("+sub+") WHERE (t.space_id IS NULL OR t.space_id = '') AND t.owner <> ''", + ).Error; err != nil { + return err + } + } + // 旧唯一索引 DROP + 新唯一索引建(回填后,安全)。DocLink 无唯一约束,不涉及。 + stmts := []string{ + "DROP INDEX IF EXISTS idx_kb_owner_name", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_kb_sn ON sundynix_kb(space_id, name)", + "DROP INDEX IF EXISTS idx_doc_okn", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_skn ON sundynix_doc(space_id, kb, name)", + } + for _, s := range stmts { + if err := p.db.WithContext(ctx).Exec(s).Error; err != nil { + return err + } + } + return nil +} diff --git a/sundynix-shared/contract/ingest.go b/sundynix-shared/contract/ingest.go index a585ed2..f0628c3 100644 --- a/sundynix-shared/contract/ingest.go +++ b/sundynix-shared/contract/ingest.go @@ -19,9 +19,10 @@ func IngestSubject(jobID string) string { return SubjectIngest + "." + jobID } // 这样作业消息恒小(不受 JetStream max_payload 限制),且崩溃重投只搬一个引用。 type IngestJob struct { JobID string `json:"job_id"` - Owner string `json:"owner"` // 雪花 user.id + Owner string `json:"owner"` // 雪花 user.id(上传者/创建人,归属) + SpaceID string `json:"space_id"` // 所属工作区(增量3:资源共享作用域) KBName string `json:"kb_name"` // 知识库展示名(原文留存分区) - Scoped string `json:"scoped"` // owner/kb 作向量/全文/图谱分区键 + Scoped string `json:"scoped"` // space_id/kb 作向量/全文/图谱分区键 ForceDoc string `json:"force_doc,omitempty"` // 非空=强制文档名(笔记编辑保持身份稳定) Filename string `json:"filename,omitempty"` // 非空=文件入库(StageKey 指向原始字节,需先解析) StageKey string `json:"stage_key"` // 暂存对象键:原始文件字节 或 纯文本