D1 澄清坐实真越权=安装者读到发布者私有内容(非安装者间):market 对 knowledge/ragflow 零依赖(src+pom 双空 spot-check)→上架不 fork、安装者共享发布者活 dataset、上架后 KB 仍可加私有文档(上传无 market 门禁)。用户拍 D0-fork(发布侧 fork 纯公开副本、物理隔离)。 临时-04 四单元:U-fork(发布侧 fork 公开副本核心新工程:上架事件→knowledge 异步 createDataset+逐文档 uploadDocuments+重索引,RAGFlow 无 copy API 必重走三步 spot-check 确认)/U-materialize(安装侧物化去隔离、binding 指公开副本)/U-retrieve(检索去隔离)/U-verify(发布者私有不泄露、复用临时-03 U0 反向基线)。关键推荐:fork 时机=上架 markListed(副本=公开快照语义)、接线=事件驱动(复用 market EventPublishOutbox+ADR-017、不绑架审核事务、保 market 不反依赖 knowledge)、DDL=初版不需(V5/V14 列+JSONB)。临时-03 标 superseded 保留演进史、U0 证据+四硬事实继承。待拍 D4-D10 后执行。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
55 KiB
临时-04 · Market KB 物化 D0-fork(发布侧 fork 纯公开副本)执行 Plan
- 版本:v1(执行版,待人类 review/拍板后执行;本稿只读出文档,不改任何代码)
- 更新日期:2026-06-26
- 目标读者:后端 / 架构 / PR reviewer
- 阅读时间:30–40 分钟
- 文档性质:临时件(执行依据,落定后归并入正式分册或删除,不作长期 SSOT)。本稿只给"怎么做",不重定义概念:方案演进与 C/B'/暂缓权衡见
临时-02-market-install下游物化方案.md;上一版(共享发布者活 dataset + 运行时 chunk 隔离)见临时-03-market-install-KB物化执行plan.md(已被本稿 supersede,保留作决策演进史);术语与 owner 边界以架构-02-核心数据结构与双轨模型.md为准(§6 市场资产、§11.2 绑定安装授权、授权快照"引用对象必须保存快照 id");表结构归后端-04;检索合同归专题-03§5;授权快照字符串承载归 ADR-020;Source 传播事件驱动归 ADR-017;BC 边界规则归.agents/rules/bc-boundaries.md。 - 配套人读图:
临时-04-market-KB物化-D0fork执行plan.html
一句话:拍板已定——做 D0-fork:知识库上架时,在发布侧 fork 出一份只含公开文档的专用 dataset("公开副本"),所有安装者只读共享这一份副本。这从物理上把发布者的私有内容隔在副本之外,从根上消除"安装者读到发布者私有内容"的越权,不需要运行时 chunk 隔离、不依赖那个
metadata_filter字段 bug。代价是新增一块发布侧的 fork 工程(建副本 dataset + 逐文档复制 + 重索引,异步重活),并要打破"market 对 knowledge 零依赖"的现状接线。本 plan 把 D0-fork 拆成 U-fork / U-materialize / U-retrieve / U-verify 四个可独立验证的实现单元。越权真因(D1 澄清坐实,区别于临时-03 的假设):临时-03 当时把越权理解为"多租户共享同一 dataset → 安装者之间互读",方向定在"共享前补 chunk 级隔离"。D1 澄清重新坐实了更要命的一条:真越权是安装者读到发布者的私有内容。链条是——market 对 knowledge/ragflow 零依赖(上架审核
markListed只翻 market 自有资产状态,从不 fork 任何 dataset)→ 安装者绑定后若走"共享"必然指向发布者那一份活 dataset→ 而发布者上架后仍可往自己 KB 继续加私有文档(whole-KB 上架、KB 生命周期不冻结)→ 安装者检索这份活 dataset 就会读到上架时根本不存在、发布者事后才加的私有内容。这不是租户拦截器能拦的(两边 binding 指向同一个 RAGFlow dataset_id,检索链对返回 chunk 无任何二次过滤,见 §1 事实 1)。D0-fork 用"复制一份只含公开快照的独立 dataset"把这条链从物理上斩断。
0. 与临时-03 的差异(读过临时-03 的人只需看这一节就能对齐)
| 维度 | 临时-03(共享发布者活 dataset,superseded) | 临时-04(D0-fork,本稿) |
|---|---|---|
| 越权模型 | 安装者之间越权(多租户共享 dataset) | 安装者读到发布者私有内容(上架后新增/未公开文档) |
| 隔离手段 | 运行时 chunk 隔离(metadata_filter/document_ids 把 chunk 限到本授权子集) |
物理隔离:fork 一份只含公开文档的专用 dataset,安装者只读它 |
| 检索时 dataset | 发布者活 dataset(共享) | 公开副本 dataset(与发布者活 dataset 物理分离) |
metadata_filter 字段 bug |
U3 隔离前置必须先修(metadata_filter→metadata_condition) |
降级为潜伏 bug:不在 D0-fork 关键路径(公开副本纯公开、检索不靠它过滤);单独记、留待修 |
| 核心新增工程 | chunk 级隔离 + 物化时给文档打安装维度元数据 | 发布侧 fork 公开副本(建 dataset + 复制文档 + 重索引,异步重活)+ 打破 market→knowledge 零依赖接线 |
| 复制方向 | 否决了"每安装者各复制一份"(C 分支) | fork 一份公开副本、N 个安装者共享只读(副本仅 1 份,非每安装者一份) |
临时-03 哪些资产被本稿继承:
- U0 spike 的 2 个 spike case 保留:机制事实"检索链对返回 chunk 无 chunk 级过滤"(混入他源 magic chunk 原样返回 +
RetrieveChunksCommand的documentIds/metadataFilter均 null = 整库扫描)仍然成立、仍是 D0-fork 必须斩断越权的根因证据;它在本稿转作 U-verify 的反向基线——用来证明"发布者私有不泄露"(fork 后安装者检索副本,混入副本之外的发布者私有 magic chunk 应检索不到)。spike 测试已落MuseKnowledgeRetrievalApiImplTest(+69 行,零业务代码),不必重写。 - 临时-03 的四条硬事实(kb_id 被 assetId 污染、跨 BC 反查链断需新
MarketAssetSourceApi、muse_source_propagation_target表不存在、检索链无 chunk 二次过滤)全部仍然有效,是本稿 U-materialize/U-retrieve 的地基,§1 复述。
1. 执行前必读:把工作量钉死的硬事实(基于真实代码,行号为只读核验所得)
事实 1 — 检索链对返回 chunk 无任何二次过滤,"谁能进检索、就看到该 dataset 整库"。这是 D0-fork 必须做物理隔离、而非运行时隔离的根因。
RAGFlow 检索 HTTP body(HttpRagFlowKnowledgeRuntimeClient.retrieveChunks:144-157)只发 dataset_ids + question + top_k + similarity_threshold + metadata_filter,tenantId/ownerUserId/kbId 不进 RAGFlow。检索链 MuseKnowledgeRetrievalApiImpl.retrieveForWork:双门 + selectActiveDatasetByKbId 只控制"能否拿到这个 dataset_id",一旦 dataset_id 进入检索,document_ids 传 null(:149 第五个参数 null)→ 整库返回;parseChunks:171-198 回来只按 dataset_id 贴归因元数据,对 chunk 无任何按授权子集的二次过滤。结论:只要安装者 binding 指向发布者活 dataset,就能读到该 dataset 里发布者后加的私有 chunk。D0-fork 让安装者 binding 指向的是只含公开快照的副本 dataset,从源头让"整库返回"返回的也只有公开内容。
事实 2 — 那个 metadata_filter 字段在 RAGFlow 侧被静默忽略(潜伏 bug,D0-fork 下不在关键路径)。
检索发 body.put("metadata_filter", ...)(HttpRagFlowKnowledgeRuntimeClient.retrieveChunks:155),但 RAGFlow /api/v1/retrieval 官方契约字段是 metadata_condition(context7 /infiniflow/ragflow 核对 + 临时-03 活体对照:乱值条件仍返 baseline),故当前 metadata_filter 被 RAGFlow 静默忽略、根本不过滤。结论:临时-03 的共享分支要靠它做隔离,所以必须先修;D0-fork 不靠它(公开副本纯公开,检索不需要再按维度过滤),故此 bug 在本期降级为潜伏 bug——§7 风险 R6 单独记、留待后续修,不进 D0-fork 关键路径。
事实 3 — kb_id 字段当前被污染:存的是 market assetId,不是真实 muse_knowledge_base.id。
MuseKnowledgeBindingService.createKnowledgeBindingPrecheck:110 precheck.setKbId(parseLong(reqVO.getSourceId())),studio 传入的 sourceId 实为 market assetId;createKnowledgeBinding:148 binding.setKbId(precheck.getKbId()) 原样进 muse_knowledge_binding.kb_id。结论:物化要把 kb_id 从 assetId 改写成新建本地 installed_ref kb 行的真实主键,并保证检索/读回端口跟着改对。这是 U-materialize 的核心难点。
事实 4 — assetId → 发布者 kbId 反查链数据上通、跨 BC 上断;market 对外 API 包当前只有 MarketHandoffTokenApi。
muse_market_asset.source_id(V6:10 BIGINT)在审核物化资产时写入发布者本地 kbId(AdminMarketReviewServiceImpl.resolveApprovedAssetBinding:454 asset.setSourceId(longValue(draftSnapshot.get("sourceId"))))。但 knowledge 不能直读 market 的 .dal(ArchUnit BcBoundaryArchTest),market api 包下目前只有 MarketHandoffTokenApi(verify/consume),没有"按 assetId 查源对象/查公开副本 dataset"的读接口。结论:D0-fork 下安装侧物化要拿到"公开副本 dataset id",必须新增跨 BC 读端口(MarketAssetSourceApi.getAssetSource(assetId) → {sourceType, sourceId=发布者kbId, publisherUserId, publicForkDatasetId, forkStatus},本地进程内 Bean,与 MarketHandoffTokenApi 同范式、不加 Feign)。注意 handoff token 不带这个信息:token snapshot(MarketHandoffServiceImpl.handoffSnapshot:450)只含 assetId/authorizationSnapshotId/targetWorkId/targetPage,没有发布者 kbId/dataset,故拿不到副本 dataset,必须走这个新读端口。
事实 5 — 发布快照 muse_knowledge_publish_snapshot 不含文档子集,印证"上架时刻 KB 全部文档 = 公开集"。
MuseKnowledgePublishService.createKBPublishSnapshot:140-159 写入的快照只有 sourceSnapshotId/authorizationSnapshotId/packageHash 等字符串,没有"哪些文档算公开"的文档清单(DO MuseKnowledgePublishSnapshotDO 也无文档子集字段)。结论:因为 draft/上架没有"公开文档子集"概念,whole-KB 上架时刻 KB 的全部文档即公开集(U-fork §3.3 据此界定)。这也意味着 fork 的"公开快照"= 上架审核那一刻 KB 的全部文档版本,发布者上架后新增的文档不属于这个快照、不进副本——这正是隔离发布者私有的语义支点。
事实 6 — dataset 与文档复制无现成 RAGFlow "copy" API;fork 必须重走 createDataset + 逐文档 upload + startParse(重索引)。
RagFlowKnowledgeRuntimeClient 接口(11 个操作)有 createDataset/uploadDocuments/startParseDocuments,没有 copyDataset/copyDocument。现有上传链路 MuseKnowledgeDocumentService.continueRagflowIfMaterialized:248-298 的形态是:ensureDatasetBinding(懒建 dataset)→ uploadDocuments(带 fileRef/content byte[])→ startParseDocuments(触发解析/索引)。结论:fork 一份副本 = 新建一个副本 dataset(createDataset)+ 把发布者每个公开文档的物化文件(muse_knowledge_document_version.storageRef + 物化内容)逐个 uploadDocuments 到副本 + 逐个 startParseDocuments 重索引。这是 异步长任务重活(多次外部调用、可能失败/部分成功),是 D0-fork 相对临时-03 最大的新增成本,U-fork §3.5 据此设计。
事实 7 — market 召回/下架对目标 owner 的传播当前是 fail-closed blocked 记录式,knowledge 侧无消费入口。
AdminMarketGovernanceServiceImpl.writeSourceStatusBlocked:361 把召回/下架的目标 owner 传播落成 propagationStatus=blocked / reason=target_owner_unavailable / errorCode=TARGET_OWNER_UNAVAILABLE,注释自证"Market Task 9 未接入目标 owner 传播"。结论:召回/下架回滚到 knowledge installed_ref + 副本 dataset 的自动化,本期是开放项(U-verify §回滚 + 决策点 D5 如实标,不假装已闭环;也不引用临时-03 已证伪的 muse_source_propagation_target 表)。
事实 8 — installed_ref 落点的列与唯一键已就绪,B' 共享/D0-fork 初版均不需要新 DDL(除可选 fork 状态列,见 §6)。
muse_knowledge_base(V5:4-28)已备 kb_type/source_market_asset_id(BIGINT)/license_snapshot_id(BIGINT);muse_knowledge_ragflow_binding(V14)唯一键 uk_..._active = (tenant_id, kb_id) WHERE status='active' AND document_id IS NULL AND deleted=FALSE——副本 dataset binding 挂在新建本地 installed_ref kbId 上不撞发布者 kb 的键。结论:安装侧物化(U-materialize)落在 V5/V14 已有列,不需要新迁移;唯一可能需要 DDL 的是发布侧"副本 dataset id + fork 状态"的存放(§6 给两种落点,倾向复用 JSONB 不加列 → 仍不需要迁移)。
这八条把 D0-fork 的真实成本钉死:核心增量 = 发布侧 fork 公开副本(事实 5/6,异步重活,全新)+ 打破 market→knowledge 零依赖接线(事实 4/7)+ kb_id 去污染 + 新跨 BC 读端口(事实 3/4);运行时隔离与
metadata_filter修复(临时-03 的核心增量)在 D0-fork 下不需要。
2. 总览:实现单元、数据流与 fork 时序
2.1 实现单元依赖图
flowchart TD
UFORK["U-fork(发布侧 fork 公开副本,核心新工程)<br/>上架事件 → knowledge 异步建副本 dataset<br/>+ 逐文档复制 + 重索引 + 幂等/重试/部分成功"]
UMAT["U-materialize(安装侧物化,原 U2 简化去隔离)<br/>建 installed_ref kb 行 + binding 指向公开副本 dataset<br/>kb_id 去污染 + 新 MarketAssetSourceApi 跨 BC 读 + 幂等"]
URET["U-retrieve(检索打通,原 U3 简化)<br/>selectActiveDatasetByKbId 返非空→命中<br/>无运行时隔离(副本纯公开)+ 授权快照 ADR-020 VARCHAR"]
UVER["U-verify(验证)<br/>真 PG IT(物化→检索命中)+ studio e2e<br/>+ 发布者私有不泄露(复用 U0 反向基线)+ 召回/下架回滚"]
UFORK --> UMAT
UMAT --> URET
URET --> UVER
DEC{"前置决策门(人类拍板,§5)<br/>fork 时机:上架 vs 懒 fork(倾向上架)<br/>接线:事件驱动 vs facade(倾向事件)<br/>副本更新策略:重新上架才更新(版本快照语义)"}
DEC --> UFORK
style UFORK fill:#fde2e2,stroke:#c0392b,stroke-width:3px
style UMAT fill:#e2f7e8,stroke:#27ae60
style URET fill:#e2f7e8,stroke:#27ae60
style UVER fill:#fff6d6,stroke:#b8860b
style DEC fill:#eef3fb,stroke:#3b6fb6,stroke-width:2px
2.2 物理隔离:发布侧 vs 安装侧(D0-fork 的越权解法)
flowchart LR
subgraph PUB["发布者域(owner = 发布者)"]
LIVE["发布者活 dataset<br/>kb-{发布者kbId}-v{ver}<br/>含公开 + 上架后新增私有文档"]
FORK["U-fork: 公开副本 dataset<br/>fork-asset-{assetId}-public<br/>只含上架时刻公开快照文档"]
LIVE -.上架时刻公开快照<br/>逐文档复制+重索引.-> FORK
end
subgraph INS["安装者域(owner = 各安装者)"]
IREF["installed_ref kb 行(每安装者一行)<br/>kb_type=installed_ref<br/>source_market_asset_id=assetId"]
IBIND["ragflow_binding<br/>kb_id=本地installed_ref kbId<br/>ragflow_dataset_id=公开副本"]
end
FORK ==>|N 安装者共享只读<br/>副本仅 1 份| IBIND
IREF --> IBIND
IBIND --> RET["检索 selectActiveDatasetByKbId<br/>→ 公开副本 dataset → RAGFlow 整库返回<br/>🟢 整库 = 纯公开,发布者私有物理不在副本内"]
style LIVE fill:#fde2e2,stroke:#c0392b
style FORK fill:#e2f7e8,stroke:#27ae60,stroke-width:2px
style RET fill:#e2f7e8,stroke:#27ae60
2.3 fork 时序(上架触发 + 事件驱动 + 异步重活,含失败/重试/幂等)
sequenceDiagram
participant Admin as 管理员审核
participant MReview as AdminMarketReviewService(market)
participant Outbox as market 上架事件(outbox/source event)
participant KConsumer as Knowledge fork 消费者(knowledge)
participant KFork as KnowledgeMarketForkService(knowledge)
participant RAG as RAGFlow
Admin->>MReview: approvePublishRequest(含 markListed)
MReview->>MReview: 资产/版本 listed(market 自有事实,同事务)
MReview->>Outbox: 落上架事件 listed{assetId, 发布者kbId, version}(同事务,不等 fork)
Note over MReview,Outbox: 审核事务到此提交,不被 fork 成败绑架
MReview-->>Admin: 审核通过(fork 异步进行中)
Outbox-->>KConsumer: 异步派发上架事件
KConsumer->>KFork: forkPublicSnapshot(assetId, 发布者kbId, version)
KFork->>KFork: 幂等检查(已 fork 过该 assetId+version?)命中则跳过
KFork->>RAG: createDataset(fork-asset-{assetId}-public)
loop 每个上架时刻公开文档
KFork->>RAG: uploadDocuments(副本dataset, 文档物化内容)
KFork->>RAG: startParseDocuments(副本dataset, 重索引)
end
alt 全部成功
KFork->>KFork: forkStatus=ready + 记副本 datasetId(供 MarketAssetSourceApi 带回)
else 部分失败/RAGFlow 502
KFork->>KFork: forkStatus=partial/failed + 标可重试(不阻断已建部分)
KConsumer->>KFork: 重试(幂等:已成功文档不重复传)
end
2.4 安装侧物化 + 检索数据流(D0-fork 后)
flowchart LR
A["用户跳转→knowledge 绑定路径<br/>createKnowledgeBindingPrecheck/Binding"]
A --> B["消费 handoff token(原有,不动)"]
B --> C["U-materialize: MarketAssetSourceApi.getAssetSource(assetId)<br/>带回 {发布者kbId, publicForkDatasetId, forkStatus}"]
C --> D{forkStatus=ready?}
D -->|否,fork 中/失败| E["fail-closed: 暂不可绑定/绑定但检索 no_dataset<br/>(决策点 D6)"]
D -->|是| F["建本地 muse_knowledge_base 行<br/>kb_type=installed_ref, source_market_asset_id=assetId<br/>license_snapshot_id=授权快照"]
F --> G["建 muse_knowledge_ragflow_binding<br/>kb_id=新建本地kbId, ragflow_dataset_id=公开副本"]
F --> H["binding.kb_id=新建本地kbId(去污染,不再是 assetId)"]
H --> I["检索 selectActiveDatasetByKbId(本地kbId)<br/>→ 公开副本 dataset → 🟢 命中且纯公开"]
G --> I
style C fill:#fff6d6,stroke:#b8860b
style F fill:#e2f7e8,stroke:#27ae60
style G fill:#e2f7e8,stroke:#27ae60
style I fill:#e2f7e8,stroke:#27ae60
style E fill:#fde2e2,stroke:#c0392b
3. 实现单元
文件路径均为 repo 相对路径。每个单元含 Goal / Files / Approach / Test scenarios(输入+预期)/ Verification。测试场景"输入"指具体调用入参或数据状态,"预期"指可断言的可观测结果。
U-fork · 发布侧 fork 公开副本(核心新工程)
Goal:知识库上架时,在发布侧把"上架时刻 KB 的全部公开文档"fork 成一份专用公开副本 dataset(建副本 dataset + 逐文档复制内容 + 重索引),并把副本 dataset id + fork 状态留存,供安装侧 MarketAssetSourceApi 带回。物理隔离发布者私有内容(副本只含上架快照、发布者上架后新增不进副本)。这是 D0-fork 相对临时-03 全新的、工程量最大的一块。
Files:
- 新增 knowledge 侧 fork 服务(核心):
muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/KnowledgeMarketForkService.java(新) - 新增上架事件消费者(接线,事件驱动方案):
muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/KnowledgeMarketListedEventConsumer.java(新) - 复用范本(fork 复制三步的写法):
.../knowledge/application/muse/MuseKnowledgeDocumentService.java(ensureDatasetBinding:300-319createDataset 形态、continueRagflowIfMaterialized:248-298upload+startParse 形态、persistDatasetBinding:321-347数据集级 binding 写法) - 复用运行时边界(无需改接口):
.../knowledge/application/muse/facade/RagFlowKnowledgeRuntimeClient.java(createDataset/uploadDocuments/startParseDocuments已够用,事实 6) - 发布侧上架事件落点(接线,事件驱动方案,market 侧):
muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/AdminMarketReviewServiceImpl.java(markListed:338-358/approvePublishRequest内落上架事件) - 副本 dataset id + fork 状态存放:见 §6(倾向复用
muse_knowledge_ragflow_binding数据集级行 +muse_market_asset.tagsJSONB,不加 DDL) - 读取发布者公开文档清单与物化内容:
.../knowledge/dal/mysql/muse/MuseKnowledgeDocumentMapper.java/MuseKnowledgeDocumentVersionMapper.java(按发布者 kbId 读 active 文档版本 + storageRef)+.../facade/KnowledgeFileFacade.java(物化内容回读,复用上传链路同源)
Approach:
① fork 时机(决策点 D4,给推荐 + 理由)——两个选项中立呈现,推荐"上架 markListed 时 fork":
- 选项 A(推荐):上架时 fork。审核通过
markListed落 market 资产 listed 事实的同时,发上架事件触发 knowledge 异步 fork。- 优点:① 版本快照语义最干净——副本 = 上架审核那一刻 KB 的公开集,天然绑
muse_market_asset_version版本,契合"市场资产 = 发布快照"既有模型(事实 5);② 安装侧保持轻——安装者绑定时副本已就绪,只建 installed_ref kb + binding(与 token 消费同事务原子,无异步等待,U-materialize 干净);③ 浪费可控——上架是低频审核动作,上架本就意味着"打算供人安装"。 - 代价:未必有人安装就先占一份 RAGFlow dataset 资源(浪费);且
markListed当前是 market 域纯资产状态更新,要打破 market→knowledge 零依赖触发 fork(接线问题,见②)。
- 优点:① 版本快照语义最干净——副本 = 上架审核那一刻 KB 的公开集,天然绑
- 选项 B:首次安装懒 fork。第一个安装者绑定时才触发 fork。
- 优点:无人安装则不浪费 RAGFlow 资源。
- 代价:① 首装者承受 fork 全程异步延迟(建 dataset + 逐文档上传 + 重索引是长任务);② 版本快照语义被破坏——懒 fork 时刻发布者 KB 可能已变(已加私有文档),要么上架时先冻结文档清单(等于把"确定公开集"的工作提前到上架,等于 A 的一半),要么懒 fork 复制的不再是"上架时刻公开集"(隔离语义失守);③ 并发首装需抢锁 + 幂等防重复 fork。
- 权衡结论:浪费(A 的代价)比"安装延迟 + 版本快照语义失守 + 并发抢锁"(B 的代价)更可接受,且 A 的语义正确性是隔离成立的前提。推荐 A。RAGFlow 资源浪费可后续用"下架/无安装超时清理副本"缓解(非本期,§7 R5 标)。
② 打破 market→knowledge 零依赖的接线(决策点 D5,给推荐 + 理由)——推荐"事件驱动(market 落上架事件 → knowledge 监听异步 fork)":
- 选项 A(推荐):事件驱动。
markListed在审核事务内落一条 market 自有"上架事件"(复用 outbox/source event 模式,如MuseMarketSourceStatusEventDO同构或新事件类型),knowledge 侧消费者异步消费触发 fork。- 优点:① 审核事务不被 fork 成败绑架——审核只管落 market 资产事实 + 发事件(快、不挂在 RAGFlow 上、RAGFlow 502 不回滚审核);② 契合已拍板 ADR-017(Source 传播 = 事件驱动 + 各模块自治);③ 保 BC 边界——market 不新增对 knowledge 的依赖,knowledge 作为消费者自治 fork(与现有
muse_knowledge_source_event消费模式同构);④ fork 失败在 knowledge 侧重试,天然契合异步重活。 - 代价:需建 market→knowledge 的上架事件投影 + knowledge 消费者(比 facade 直调工程量大),且引入"fork 异步、安装时副本可能未就绪"的中间态(U-materialize 须 fail-closed 处理,见 U-materialize ④ / 决策点 D6)。
- 优点:① 审核事务不被 fork 成败绑架——审核只管落 market 资产事实 + 发事件(快、不挂在 RAGFlow 上、RAGFlow 502 不回滚审核);② 契合已拍板 ADR-017(Source 传播 = 事件驱动 + 各模块自治);③ 保 BC 边界——market 不新增对 knowledge 的依赖,knowledge 作为消费者自治 fork(与现有
- 选项 B:market 调 knowledge facade。
markListed同步调KnowledgeMarketForkApi.forkPublicSnapshot(...)。- 优点:实现直接、无事件基础设施、无"副本未就绪"中间态。
- 致命缺点:① fork 是异步重活,塞进审核同步事务 = 审核请求挂在 RAGFlow 多次调用上,失败则审核回滚(审核不该被 fork 成败绑架);② 方向是 market→knowledge 的新反向依赖(当前 knowledge→market 已有
MuseKnowledgeBindingService依赖MarketHandoffTokenApi),要 knowledge 定义端口、market 适配(BC 上可行但与异步重活天然冲突)。
- 权衡结论:fork 异步重活 + ADR-017 已定调事件驱动,推荐 A。代价(事件设施 + 副本未就绪中间态)是正确解耦的必要成本,如实计入工程量。
③ whole-KB 公开快照界定:fork 的"公开集"= 上架审核那一刻发布者 KB 的全部 active 文档版本(事实 5:draft/发布快照无文档子集概念,whole-KB 上架)。fork 消费上架事件时按 发布者 kbId 读 muse_knowledge_document + 各文档 selectLatestByDocumentId 的 active 版本作为复制清单。发布者上架后新增的文档因 create_time 晚于上架事件,不在这一次 fork 的清单内——这就是隔离发布者私有的语义支点。(若上架时刻有 scan_blocked/failed/deleting 的文档,fork 应跳过,只复制 searchable/generative 可检索文档。)
④ 复制机制(事实 6):fork 一份副本 dataset 三步走,复用上传链路同源逻辑:
ragFlowClient.createDataset(CreateDatasetCommand):副本 dataset 名建议fork-asset-{assetId}-v{version}-public(区别于发布者活 datasetkb-{kbId}-v{ver}),config 带source=market_public_fork, assetId, version。- 对上架清单每个公开文档版本:从
muse_knowledge_document_version.storageRef+KnowledgeFileFacade回读物化内容(byte[]/fileRef,与发布者上传时同源),ragFlowClient.uploadDocuments(UploadDocumentsCommand)上传到副本 dataset。 - 对每个已上传文档
ragFlowClient.startParseDocuments(StartParseDocumentsCommand)触发副本侧重解析/重索引(副本是独立 dataset,必须重新索引,不能共享发布者索引)。
⑤ 异步重活:失败/重试/幂等/部分成功(CLAUDE.md 外部交互铁律):
- 幂等键:
(assetId, version)维度。fork 前先查"该 assetId+version 是否已 fork"(按副本存放落点查,§6),命中且forkStatus=ready则整体跳过;命中但partial/failed则只补未成功的文档(已成功文档不重复上传)。 - 部分成功:建副本 dataset 成功但部分文档上传/解析失败 →
forkStatus=partial,记已成功文档集,不删已建部分,标可重试;下次消费/重试只补差集。 - 失败分类沿用:RAGFlow 调用失败按
RagFlowKnowledgeRuntimeClient.FailureClass分可重试(RAGFLOW_UNAVAILABLE/TIMEOUT/RATE_LIMITED/CONFIG_MISSING/AUTH_MISSING)与不可重试;可重试走消费者重试,不可重试落forkStatus=failed+ errorCode 供人工介入。 - 审计:fork 的每次 RAGFlow 调用复用
MuseKnowledgeAuditService.recordRagflowCall(attributionStatus标market_public_fork),correlation 区分 operation 防唯一键冲突(同auditRagflowCall范式)。 - 重索引耗时:startParse 是异步触发,副本文档真正
searchable需 RAGFlow 后台解析完成;forkStatus=ready的判定应是"全部文档 startParse 已 accepted"(而非"已 searchable"),searchable 由 RAGFlow 后台推进——这点要在 U-verify 用真 RAGFlow 验证副本最终可检索(§U-verify 场景 1)。
Test scenarios(输入+预期):
- fork 公开副本|输入:发布者 kb=K(含 3 个 searchable 公开文档 + 1 个上架后新增的私有文档 P),assetId=X 上架触发 fork|预期:新建副本 dataset D_pub;D_pub 含 K 上架时刻的 3 个公开文档(重索引后可检索),不含私有文档 P;
forkStatus=ready+ 副本 datasetId 可被MarketAssetSourceApi带回。 - whole-KB 公开集界定|输入:上架时刻 K 有文档 {d1 searchable, d2 generative, d3 scan_blocked, d4 deleting}|预期:副本只复制 {d1, d2}(可检索的),跳过 d3/d4。
- 幂等-重复 fork|输入:同一 (assetId=X, version=1) 再次触发 fork(重新派发事件/重试)|预期:不新建第二个副本 dataset(命中 ready 跳过);已 ready 副本不变。
- 部分成功重试|输入:首次 fork 时 d2 上传失败(RAGFlow 502)→
forkStatus=partial;重试再来|预期:副本 dataset 不重建,d1 不重复上传,只补 d2;补成功后forkStatus=ready。 - 失败不阻断审核|输入:fork 全程 RAGFlow 不可用|预期:market 审核已提交 listed(事件已落),
forkStatus=failed+可重试;安装侧据forkStatus≠readyfail-closed(不会让安装者绑到不存在的副本)。
Verification:
- 模块单测/IT:
cd muse-cloud && mvn -pl muse-module-knowledge/muse-module-knowledge-server -am test -Dtest='KnowledgeMarketForkServiceTest'(mockRagFlowKnowledgeRuntimeClient,覆盖公开集界定 + 幂等 + 部分成功重试 + 私有文档不进副本)。 - 活体(真 RAGFlow,mini-infra 100.64.0.8 在线):按模块
.agent/.agents/knowledge起栈,真 fork 一份副本并对副本retrieveChunks确认可检索 + 不含私有 magic(并入 U-verify 端到端)。 - 凭据红线:RAGFlow base-url/api-key 由 env 注入,脚本/文档不打印任何 key 值(过滤
password|secret|token|sk-)。
U-materialize · 安装侧物化(原 U2 简化,去隔离)
Goal:在 knowledge 目标域绑定路径里,把"只写引用绑定"升级为"建出可用的本地 installed_ref 知识库实体 + binding 指向公开副本 dataset(非发布者活 dataset)";做到 kb_id 去污染(assetId→本地主键)+ 新增 MarketAssetSourceApi 跨 BC 读(带回公开副本 dataset id + forkStatus)+ 幂等 + kbId 存在性校验。相对临时-03 U2 的简化:去掉给文档打安装维度元数据、去掉运行时隔离前置(D0-fork 不需要)。
Files:
- 主改:
.../knowledge/application/muse/MuseKnowledgeBindingService.java(createKnowledgeBindingPrecheck:71-127、createKnowledgeBinding:129-177、consumeMarketHandoff:273-284) - 新增跨 BC 读端口(事实 4):
- market 侧 API(被 knowledge 依赖):
muse-cloud/muse-module-market/muse-module-market-api/src/main/java/cn/iocoder/muse/module/market/api/asset/MarketAssetSourceApi.java(新)+ DTO.../api/asset/dto/MarketAssetSourceRespDTO.java(新,含sourceType/sourceId=发布者kbId/publisherUserId/publicForkDatasetId/forkStatus) - market 侧实现:
muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/api/asset/MarketAssetSourceApiImpl.java(新,读muse_market_asset的 source_id 等 + 副本 dataset 存放,经 market 自己的 .dal)
- market 侧 API(被 knowledge 依赖):
- installed_ref kb 写入 DAL:
.../knowledge/dal/mysql/muse/MuseKnowledgeBaseMapper.java(复用 insert)、MuseKnowledgeRagflowBindingMapper.java(复用 insert) - 参照范本:
.../knowledge/application/muse/MuseKnowledgeDocumentService.java(persistDatasetBinding:321-347数据集级行写法 +DataIntegrityViolationException回读幂等)
Approach:
- 新增 market 跨 BC 读端口(事实 4):
MarketAssetSourceApi.getAssetSource(Long assetId)返{sourceType, sourceId=发布者kbId, publisherUserId, publicForkDatasetId, forkStatus}。范式同MarketHandoffTokenApi(本地 Bean,knowledge 注入它而非读 market.dal)。与临时-03 的关键区别:临时-03 带回"发布者活 dataset"(要 knowledge 跨 owner 读发布者 kb 的 dataset,有租户拦截器难题 D4),D0-fork 带回的是"公开副本 dataset id"——副本是 fork 时新建的、其 dataset id 已由 U-fork 留存在 market 可读的存放(§6),market 直接读自有数据即可带回,不再需要 knowledge 跨 owner 读发布者 dataset,临时-03 的 D4 难题在 D0-fork 下消失。保证不破 ArchUnitbc-boundaries。 - kbId 跨 BC 存在性校验 + 去污染:market_kb 绑定时
sourceId当作 assetId 处理(不再parseLong当 kbId):调MarketAssetSourceApi.getAssetSource(assetId)校验资产存在且为knowledge_base类型且forkStatus=ready(不存在/类型不符/副本未就绪 → fail-closed 抛错,替代现状裸信客户端)。 - 建本地 installed_ref 实体(落点:
createKnowledgeBinding同一@Transactional事务内):- 写
muse_knowledge_binding之前先 insertmuse_knowledge_base:kb_type='installed_ref'、source_market_asset_id=assetId、license_snapshot_id=授权快照主键、owner_user_id=安装者、status='active'、active_version=1、name/description取资产摘要(MarketAssetSourceApi可一并带回或占位);拿到新建本地 kbId。 - insert
muse_knowledge_ragflow_binding:kb_id=新建本地kbId、ragflow_dataset_id=公开副本datasetId、document_id=null、status='active'、active_version=1,形态仿persistDatasetBinding。副本是共享只读的,多个安装者的 installed_ref binding 都指向同一 publicForkDatasetId(副本仅 1 份),但各自 kb_id 是各安装者本地新建主键,不撞uk_..._active。 - 去污染:
binding.setKbId(新建本地kbId)(不再是 assetId);建实体放createKnowledgeBinding(用户确认绑定的写事实点),precheck 仅校验资产存在性 + forkStatus,避免 precheck 失败留孤儿 kb 行。
- 写
- fork 未就绪的 fail-closed(决策点 D6):若
getAssetSource返forkStatus≠ready(fork 中/失败),有两种处置:(a) 绑定阶段直接拒(抛"来源副本准备中"错误码,让用户稍后重试);(b) 允许建 installed_ref kb 但 binding 指向空/检索 no_dataset(绑定成功、检索暂不命中,副本就绪后自动可用)。倾向 (a)(fail-closed 更清晰,避免"绑了但用不了"的迷惑态);(b) 需 U-retrieve 容忍 no_dataset(本就容忍)。U-materialize 实现时定夺并回填。 - 幂等(重复 install/bind 不重复造实体):建 installed_ref kb 行前先
SELECT ... WHERE source_market_asset_id=assetId AND owner_user_id=安装者 AND deleted=false,命中则复用既有 installed_ref kbId,不重复建;dataset binding 复用persistDatasetBinding的DataIntegrityViolationException→回读模式应对并发。 - 事务原子性:物化与"消费 handoff token + 写绑定事实"在同一事务(现状
consumeMarketHandoff已沿用调用方@Transactional),失败整体回滚。
Test scenarios(输入+预期):
- 首次安装绑定(副本就绪)|输入:T_A 安装 assetId=X(knowledge_base 类型,公开副本 D_pub,forkStatus=ready),
createKnowledgeBinding(合法 handoff token + precheckId)|预期:新建muse_knowledge_base(kb_type=installed_ref, source_market_asset_id=X, owner_user_id=A)记 id=K2;muse_knowledge_binding.kb_id=K2(非 X);新建muse_knowledge_ragflow_binding(kb_id=K2, ragflow_dataset_id=D_pub, document_id=null, status=active)。 - 多安装者共享副本|输入:A、B 各装 assetId=X|预期:建两行 installed_ref kb(K2、K3,各自 owner),两条 ragflow_binding 都指向同一 D_pub(副本仅 1 份),不违反
uk_..._active(kb_id 各异)。 - 幂等-重复绑定|输入:同一 (A, X) 再次 install+bind|预期:不新建第二行 installed_ref kb(复用 K2),不抛 500。
- 副本未就绪 fail-closed|输入:assetId=X 的
forkStatus=partial/failed|预期:按 D6 处置(倾向拒绝绑定,0 写 installed_ref/binding,抛明确错误码)。 - 资产不存在/类型不符|输入:
sourceId指向不存在 assetId 或 work 类型|预期:fail-closed 拒绝,0 写。 - BC 边界|输入:ArchUnit
BcBoundaryArchTest|预期:knowledge 不直接 import market 的.dal(只经MarketAssetSourceApi),test 绿。
Verification:
- 模块单测/IT:
cd muse-cloud && mvn -pl muse-module-knowledge/muse-module-knowledge-server -am test -Dtest='MuseKnowledgeBindingServiceTest,*RoundTripTest'。 - 改了 market-api 模块务必先
mvn -pl muse-module-market/muse-module-market-api -am install再跑 knowledge 模块测试,否则 stale jar 假红(memorymuse-market-install-isacquired-enrich教训)。 - ArchUnit:
mvn -pl ... test -Dtest=BcBoundaryArchTest。
U-retrieve · 检索打通(原 U3 简化,无运行时隔离)
Goal:物化后,检索第四门 selectActiveDatasetByKbId(本地kbId) 返非空 → 命中(消除 no_dataset 静默省略);因检索命中的是纯公开副本,无需任何运行时 chunk 隔离;授权快照沿用 ADR-020 VARCHAR envelope,不回退 BIGINT/parseLong。相对临时-03 U3 的简化:删掉 metadata_filter/document_ids 隔离透传(D0-fork 不需要),metadata_filter 字段 bug 不在本单元修(潜伏,§7 R6)。
Files:
- 主路径(无需改,靠 U-materialize 数据正确即自然打通):
.../knowledge/api/MuseKnowledgeRetrievalApiImpl.java(第四门:122-127、parseChunks:171-198) - 不改:
RetrieveChunksCommand构造(:148-151)保持document_ids=null, metadata_filter=null现状(副本纯公开,整库返回即纯公开内容)
Approach:
- 检索基线打通:U-materialize 把
binding.kb_id改成本地真实 kbId、并建好数据集级muse_knowledge_ragflow_binding(kb_id=本地kbId, ragflow_dataset_id=公开副本)后,检索链第四门selectActiveDatasetByKbId(本地kbId)自然返非空 →no_dataset消除 → 进 RAGFlow 检索副本。RetrievalApiImpl 代码无需改动。 - 无运行时隔离(D0-fork 核心简化):检索命中的是公开副本 dataset,其"整库返回"返回的本就只有公开内容(发布者私有物理不在副本内,事实 1 的"整库返回"在这里是安全的)。因此不需要 metadata_filter/document_ids 把 chunk 限到子集——这正是 D0-fork 比临时-03 共享分支简单的地方。
- 授权快照沿用 ADR-020:检索门读
binding.getAuthorizationSnapshotId()(V14 已 VARCHAR),installed_ref binding 写入时沿用 VARCHAR 字符串承载;chunk §5.3 合同字段authorizationSnapshot透传字符串,不 parseLong。
Test scenarios(输入+预期):
- no_dataset 消除 + 命中|输入:U-materialize 物化后的 installed_ref(本地kbId=K2,关联公开副本 D_pub),work 绑定含 search 用途 + 授权快照,
retrieveForWork(question 命中 D_pub 公开内容)|预期:结果非empty("no_dataset"),进入 RAGFlow 检索,RetrievalResult.ok(chunks),chunk 带齐 §5.3 字段;对照现状(同输入必返no_dataset)。 - 发布者私有不泄露(承 U0 反向基线,核心)|输入:发布者活 dataset 含私有 magic chunk(上架后新增、未进副本),安装者 T_A 检索其 installed_ref(指向 D_pub)|预期:返回 chunk 不含发布者私有 magic(因 D_pub 物理不含该文档),证明 D0-fork 隔离成立。
- 授权快照字符串|输入:installed_ref binding 的
authorization_snapshot_id为字符串 envelope 形态|预期:检索门正常放行、chunk 透传字符串,无 NumberFormatException、不落 null。
Verification:
- 单测:
mvn -pl muse-module-knowledge/muse-module-knowledge-server -am test -Dtest=MuseKnowledgeRetrievalApiImplTest(mock RAGFlow,覆盖 no_dataset 消除 + 字符串授权快照;私有不泄露并入 U-verify 真 RAGFlow)。 - 活体并入 U-verify 端到端。
U-verify · 验证(真 PG 端到端 + studio e2e + 发布者私有不泄露 + 回滚)
Goal:用真实证据证明"知识库上架→fork 公开副本→安装→绑定→物化→检索命中"端到端可真用;且发布者私有内容不泄露给安装者(D0-fork 的核心安全目标);召回/下架时已物化实体能回滚。
Files:
- 真 PG IT(新):
muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/.../P1rMarketKbForkMaterializationIT.java(参照既有P1r*IT+P1rRagFlowLiveAcceptanceITlive 范式) - studio e2e(新):
muse-studio/e2e/market-install-kb-retrieval.spec.ts(参照knowledge-bindings.spec.ts+market-install.spec.ts) - U0 反向基线(复用,不重写):
.../knowledge/api/MuseKnowledgeRetrievalApiImplTest.java(临时-03 已落的 2 个 spike case:混入他源 magic chunk 原样返回 + documentIds/metadataFilter 均 null = 整库扫描) - 回滚相关(据真实机制,事实 7):
.../knowledge/application/muse/MuseInstalledKnowledgeBaseService.java(disableInstalledKnowledgeBase:64/deleteInstalledKnowledgeBase:72路径)+ knowledge source event/projection
Approach / Test scenarios(输入+预期):
- fork→安装→检索端到端(真 PG + 真 RAGFlow)|输入:发布者建 KB(3 公开文档)→ 上架触发 fork 出公开副本 → 安装市场 KB 资产 → 作品绑定(物化 installed_ref + binding 指向副本)→
retrieveForWork|预期:检索第四门拿到公开副本 dataset → RAGFlow chunk 真命中 → 生成上下文含该来源 grounding;对照现状(必no_dataset)。 - 发布者私有不泄露(D0-fork 核心安全验证,复用 U0 反向基线)|输入:发布者上架后往活 dataset 加一段含唯一 magic token 的私有 chunk(未进副本)→ 安装者检索其 installed_ref(指向副本)|预期:返回不含该 magic chunk(副本物理不含)。对照 U0 spike 反向基线:U0 钉死了"指向同一 dataset 时 magic chunk 原样返回",本场景证明"D0-fork 指向副本后 magic chunk 消失"——隔离从"原样泄露"转为"物理不可见"即验收通过。
- 幂等|输入:重复触发 fork(同 assetId+version)+ 重复 install/bind|预期:副本 dataset 不重建、installed_ref kb 不重复,DB 中各仅一行。
- 多安装者共享一份副本|输入:A、B 各装同一资产|预期:两个 installed_ref kb 指向同一副本 dataset(副本仅 1 份),各自检索命中、互不影响。
- 回滚/来源传播(基于真实机制,事实 7,不引用不存在的
muse_source_propagation_target)|输入:发布者资产被 admin 召回/下架(recallAsset/delistAsset)|预期:已物化 installed_ref 实体被阻断新使用(不自动删,符合架构-02来源事件"不删除正式知识"语义)。开放项:market 召回当前对目标 owner 传播是 fail-closed blocked 记录式(事实 7,writeSourceStatusBlockederrorCode=TARGET_OWNER_UNAVAILABLE),knowledge 侧消费"召回→停用 installed_ref + 副本"的入口本期缺——U-verify 须如实验证现状传播是否触达 installed_ref;若不触达,标为开放项("召回回滚未自动化,需新增 market→knowledge 传播消费者或手动停用"),不假装已闭环。 - 删除/停用已物化实体|输入:用户
disableInstalledKnowledgeBase/deleteInstalledKnowledgeBase|预期:installed_ref 软删(现状updateInstalledStatus),检索读回排除;不删公开副本 dataset(副本是共享的,单个安装者停用不影响其他安装者)——副本生命周期绑资产下架/无安装清理(§7 R5,非本期)。 - 机械门禁|输入:ArchUnit
BcBoundaryArchTest+ 覆盖 JSON 门|预期:fork 经 knowledge 自有 + 上架事件消费;安装物化只经 knowledge facade +MarketAssetSourceApi,market 不直写 knowledge.dal,test 绿。
Verification:
- 真 PG IT:从
muse-cloud/跑,按 memorymuse-p1r-it-run-recipe配p1r.flyway.*argLine + 密码经 env(~/.config/muse-repo/infra.env,set -a && . it && set +a),online(RAGFlow/New-API 在 mini-infra 在线)。_test库铁律:IT 的 flyway 目标库绝不指 muse_slice_live。 - studio e2e:起全栈 app(PG 宿主 100.64.0.8 在线时),
npx playwright test market-install-kb-retrieval.spec.ts。 - 凭据红线:所有脚本/日志过滤
password|secret|token|sk-;不 rmdump.rdb/lefthook。
4. 决策点(待人类拍板,按优先级)
| # | 决策点 | 选项 | 倾向 | 阻塞谁 |
|---|---|---|---|---|
| D4 | fork 时机 | 上架 markListed 时 fork / 首次安装懒 fork |
上架时 fork(版本快照语义干净 + 安装侧轻 + 浪费可控;懒 fork 破坏快照语义 + 首装延迟 + 并发抢锁) | U-fork 全部 |
| D5 | 打破 market→knowledge 零依赖的接线 | 事件驱动(market 落上架事件→knowledge 异步消费 fork)/ market 调 knowledge facade 同步 fork | 事件驱动(审核不被 fork 成败绑架 + 契合 ADR-017 + 保 BC 边界;facade 同步会让审核挂在 RAGFlow 上) | U-fork 接线 |
| D6 | fork 未就绪时安装侧处置 | 绑定阶段拒(fail-closed)/ 允许绑定但检索 no_dataset 待就绪 | 绑定阶段拒(清晰,避免"绑了用不了"迷惑态);U-materialize 实现时定夺回填 | U-materialize |
| D7 | 副本更新策略(发布者改版后) | 重新上架才更新副本(版本快照语义)/ 不更新 / 实时跟随 | 重新上架才更新(与"市场资产 = 发布快照"模型一致;实时跟随会重新引入私有泄露风险) | U-fork |
| D8 | license_snapshot_id 承载 |
数值主键(不 widen)/ 字符串 envelope(需 V32) | 数值主键(与 install 侧一致,避免牵动 market 两列 BIGINT 独立议题) | 是否需 V32 |
| D9 | 召回/下架回滚自动化 | 复用现有 market→目标 owner 传播 / 本期标开放项手动停用 | 先验现状传播是否触达 installed_ref,不假装已有 muse_source_propagation_target;倾向本期标开放项 |
U-verify |
| D10 | 副本 dataset id + fork 状态存放(DDL 关键) | 复用 muse_knowledge_ragflow_binding 数据集级行 + muse_market_asset.tags JSONB(不加 DDL)/ 新增列/表(需 V32) |
复用现有结构不加 DDL(见 §6);新增列仅在复用不够时触发 | 是否需 V32 |
5. fork 时机推荐与接线推荐(返回必答,独立成节便于决策)
- fork 时机推荐 = 上架
markListed时 fork(非首次安装懒 fork)。核心理由:副本语义必须是"上架审核那一刻的公开快照"才能成立隔离(事实 5),上架时 fork 让这个语义天然成立、且安装侧无异步延迟;懒 fork 要么把"确定公开集"提前到上架(等于做了一半上架 fork)、要么复制的不再是上架快照(隔离失守),还要扛首装延迟与并发抢锁。RAGFlow 资源浪费是上架时 fork 的唯一代价,可后续用"下架/无安装超时清理副本"缓解。 - 接线推荐 = 事件驱动(market 落上架事件 → knowledge 监听异步 fork),非 market 调 knowledge facade 同步。核心理由:fork 是异步重活(多次 RAGFlow 调用、可能失败/重试),绝不能塞进审核同步事务把审核绑架在 RAGFlow 上;事件驱动让审核事务只落 market 资产事实 + 发事件即返回,fork 在 knowledge 侧自治重试,契合已拍板 ADR-017,且保住 market 不反向依赖 knowledge 的 BC 边界。代价是需建上架事件投影 + knowledge 消费者,并引入"fork 异步、安装时副本可能未就绪"的中间态(U-materialize 用
forkStatusfail-closed 兜住)。
6. DDL 判定:是否需要新 Flyway 迁移
判定结论:D0-fork 初版倾向不需要新 Flyway 迁移(落在 V5/V14 已有列 + 现有 JSONB),但有一个"副本存放落点"的选择会影响这一结论:
- 安装侧物化(U-materialize):落在
muse_knowledge_base(V5 已备kb_type/source_market_asset_id/license_snapshot_id)+muse_knowledge_ragflow_binding(V14 已有列),不需要迁移(事实 8)。 - 发布侧副本 dataset id + fork 状态存放(决策点 D10,唯一可能触发 DDL 处):
- 倾向方案(不加 DDL):副本 dataset 本身落一行
muse_knowledge_ragflow_binding(这正是该表语义——kb→dataset 映射,副本可挂在发布者 kb 上以"market_public_fork"类型的 binding_summary 标记,或挂在一个专用载体行),forkStatus与副本 datasetId 通过binding_summaryJSONB +muse_market_asset.tagsJSONB(MarketAssetSourceApi从这里带回)承载。这样不需要新列。 - 需 V32 的情形:若评审认为 fork 状态/副本映射必须强类型列(而非 JSONB),则新增
muse_market_asset的public_fork_dataset_id VARCHAR + fork_status VARCHAR列,或新建muse_market_public_fork映射表 → 需 V32(DDL 人类门 +_test库铁律 + Flyway 不可逆约定)。 - 另:
license_snapshot_id若改存字符串 envelope(D8 选字符串)需ALTER ... TYPE VARCHAR→ V32;倾向数值主键不 widen,不触发。
- 倾向方案(不加 DDL):副本 dataset 本身落一行
给人类的明确建议:先按"复用现有结构 + JSONB 承载 fork 状态/副本映射"做,不引入 V32;仅当评审坚持强类型列时才走 V32(届时标 DDL 人类门 + V32 版号 + _test 库先验)。
7. 风险
- R1(最高)发布者私有泄露:D0-fork 的全部价值在于"副本只含公开快照"。若 fork 的公开集界定错(误把私有文档纳入)、或副本更新策略选"实时跟随活 dataset"(D7)→ 私有重新泄露。缓解:U-fork §3.3 严格按"上架时刻 active 文档"界定 + D7 选"重新上架才更新" + U-verify 场景 2 真验私有不泄露(复用 U0 反向基线)。
- R2 fork 异步重活的复杂度(如实计入,不掩盖):fork 一份副本 = 建 dataset + N 文档逐个 upload + N 文档逐个 startParse + 重索引,是多次外部调用的长任务,必须处理失败/重试/幂等/部分成功(U-fork §3.5)。这是 D0-fork 相对临时-03 最大的新增工程量,比"共享 + 运行时隔离"重——诚实呈现:D0-fork 安全模型更干净(物理隔离),但发布侧工程更重(异步复制管线)。
- R3 打破 market→knowledge 零依赖的影响面(如实写清):当前 market 对 knowledge 零依赖(架构既有事实),D0-fork 必须打破它。事件驱动方案下,market 侧改动 =
markListed落上架事件(新增 outbox/事件类型),knowledge 侧新增消费者;影响面集中在"上架审核落事件"这一点 + knowledge 新增 fork 消费链,不改 market 既有审核/资产逻辑主体。但这是架构层面的新接缝,需架构 review 确认(ADR-017 事件驱动是其依据)。 - R4 副本就绪与安装的时序竞态:上架 fork 异步,安装者可能在
forkStatus=ready前就来绑定。缓解:U-materialize ④ 用forkStatusfail-closed(D6),副本就绪后再绑/检索命中。 - R5 副本生命周期/资源回收(本期不做,标开放):上架即 fork 会占 RAGFlow dataset 资源,下架/长期无安装的副本清理本期不做。缓解:标开放项("下架/无安装超时清理副本 dataset"),非 D0-fork 关键路径。
- R6
metadata_filter字段 bug(潜伏,单独记,留待修):HttpRagFlowKnowledgeRuntimeClient.retrieveChunks:155发的metadata_filter被 RAGFlow 静默忽略(应为metadata_condition,事实 2)。D0-fork 不依赖它(公开副本纯公开),故本期不修、降级为潜伏 bug;但它是真 bug(任何未来想靠元数据过滤检索的功能都会踩),留待后续单独修,不随 D0-fork 关键路径处理。 - R7 召回/下架回滚未自动化(开放项):事实 7,本期标开放,不假装闭环(D9)。
8. 回滚(本 plan 执行后如何撤回)
- 代码回滚:U-materialize/U-retrieve 改动集中在
MuseKnowledgeBindingService+ 新增MarketAssetSourceApi(market-api/server);U-fork 改动是新增KnowledgeMarketForkService+ 消费者 + marketmarkListed落事件。revert 这些 commit 即回到"只写引用绑定 + market 零依赖 + 不 fork"现状,检索回落no_dataset静默省略(功能不可用但不报错,与现状一致)。 - 数据回滚:已物化的 installed_ref kb 行 + binding 经软删停用;fork 出的公开副本 dataset 可删(按副本存放落点找到 datasetId,调 RAGFlow 删 dataset;副本是 D0-fork 新建的、删除无副作用于发布者活 dataset)。
- 迁移回滚:初版不引入 V32(D8 数值主键 + D10 JSONB 承载),无迁移需回滚;若触发 V32 则按 Flyway 不可逆约定,靠新 forward 迁移回滚(且
_test库先验)。
9. Scope 边界(明确不做什么)
- KB 限定:本 plan 只做知识库(KB)的 D0-fork 物化。
- agent 下一阶段:智能体(agent)物化不在本 plan——
muse_agent的market_installed落地 + 运行期放行是独立的下一阶段任务,本 plan 不触碰 ai 模块。 - install 解耦不动:
MarketInstallServiceImpl.installMarketplaceAsset维持"只记账、targetFactsWritten=false"现状(:96),不在 install 侧物化——物化落点严格在 knowledge 目标域绑定路径。 - handoff 非物化桥:handoff token 只负责跳转 + 被目标域消费(
MarketHandoffTokenApi.verify/consume不动)。 - 运行时 chunk 隔离不做:D0-fork 用物理隔离替代,临时-03 的 metadata_filter/document_ids 运行时隔离本期不实现。
metadata_filter字段 bug 本期不修:降级为潜伏 bug 单记(R6),留待后续。- 副本资源回收不做:下架/无安装清理副本 dataset 本期不做(R5),标开放。
- 召回/下架回滚自动化不做:本期标开放项(R7/D9),不假装闭环。
- market 两列 BIGINT 议题不纳入:
muse_market_installation.authorization_snapshot_id/source_snapshot_id仍 BIGINT 是独立契约议题,本 plan 不处理。
10. 关联阅读
- 方案演进与权衡(本 plan 的 WHAT 上游):
临时-02-market-install下游物化方案.md - 上一版执行 plan(共享 + 运行时隔离,已被本稿 supersede,保留作决策演进史):
临时-03-market-install-KB物化执行plan.md - 概念与 owner 边界:
架构-02-核心数据结构与双轨模型.md(§6 市场资产、§11.2 绑定安装授权、授权快照语义) - 检索合同 / fail-closed:
专题-03-AI编排上下文与质量评测实现规范.md(§5 检索和图查询、§5.3 检索结果合同) - 关键决策:
架构-03-关键决策与原则(ADR).md(ADR-017 Source 传播事件驱动、ADR-020 授权快照字符串承载) - 表结构 SSOT:
后端-04-统一数据库Schema-v1.md(§5 Knowledge) - BC 边界规则:
.agents/rules/bc-boundaries.md