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 -1
View File
@@ -298,7 +298,7 @@ export default function App() {
) : view === "studio" ? (
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
) : view === "kb" ? (
<KbView identity={identity} />
<KbView identity={identity} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
) : view === "report" ? (
<ReportView identity={identity} />
) : view === "runs" ? (
@@ -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<KbInfo[]>([]);
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 }) {
</div>
)}
{tab === "vault" && <VaultPanel identity={identity} kb={kb} />}
{tab === "vault" && <VaultPanel identity={identity} kb={kb} spaceReadOnly={spaceReadOnly} />}
{tab === "search" && (
<div className="h-full overflow-y-auto p-4">
@@ -436,7 +440,7 @@ export function KbView({ identity }: { identity: Identity }) {
// VaultPanelObsidian 式文库 —— 列表(仅元数据) / 正文按需加载 / [[双链]]可点 / 反链 / 笔记关系图。
// 列表与正文分离 + 链接走服务端索引,不再一次拉回整库正文,可扛十几万字大文件。
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<VaultDoc[]>([]);
const [links, setLinks] = useState<DocLink[]>([]);
@@ -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", "笔记名与内容不能为空");
+5
View File
@@ -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 零拷贝骨干网 + 声明任务流
+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")
+8 -7
View File
@@ -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) // 护栏命中安全事件流(倒序,翻页)
}
+55 -54
View File
@@ -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 幂等登记一个知识库到某工作区(已存在则保持,不覆盖 kindowner 记创建人)。
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
}
+65
View File
@@ -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
}
+3 -2
View File
@@ -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"` // 暂存对象键:原始文件字节 或 纯文本