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:
@@ -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 }) {
|
||||
|
||||
// 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<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", "笔记名与内容不能为空");
|
||||
|
||||
@@ -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 零拷贝骨干网 + 声明任务流
|
||||
|
||||
@@ -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 未就绪,正文回退内联存 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) // 回填指向本文件的悬空链接
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) // 护栏命中安全事件流(倒序,翻页)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"` // 暂存对象键:原始文件字节 或 纯文本
|
||||
|
||||
Reference in New Issue
Block a user