# 临时-03 · Market Install KB 下游物化执行 Plan(HOW,供 review 后执行)
> ⚠️ **已被 [`临时-04-market-KB物化-D0fork执行plan.md`](临时-04-market-KB物化-D0fork执行plan.md) supersede(2026-06-26)**:D1 澄清坐实真越权是「安装者读到**发布者私有**内容」(非本稿假设的安装者间互读),用户拍板改走 **D0-fork(发布侧 fork 纯公开副本、物理隔离)**,去掉本稿 S 分支的运行时 chunk 隔离、`metadata_filter`→`metadata_condition` 字段修复降级为潜伏 bug。**本稿保留作决策演进史**,新执行依据以临时-04 为准;本稿的 U0 spike 证据与四条硬事实仍有效(被临时-04 继承)。
- 版本:v1(执行版,待人类 review/拍板后执行;本稿只读出文档,不改任何代码)
- 更新日期:2026-06-26
- 目标读者:后端 / 架构 / PR reviewer
- 阅读时间:25–35 分钟
- 文档性质:**临时件**(执行依据,落定后归并入正式分册或删除,不作长期 SSOT)。本稿只给"怎么做",不重定义概念:方案权衡与决策收束见 [`临时-02-market-install下游物化方案.md`](临时-02-market-install下游物化方案.md);术语与 owner 边界以 [`架构-02-核心数据结构与双轨模型.md`](架构-02-核心数据结构与双轨模型.md) 为准(§6 市场资产、§11.2 绑定安装授权、第 122 行授权快照"引用对象必须保存快照 id");表结构归 `后端-04`;检索合同归 [`专题-03`](专题-03-AI编排上下文与质量评测实现规范.md) §5;授权快照字符串承载归 ADR-020;BC 边界规则归 [`.agents/rules/bc-boundaries.md`](../.agents/rules/bc-boundaries.md)。
- 配套人读图:[`临时-03-market-install-KB物化执行plan.html`](临时-03-market-install-KB物化执行plan.html)
> **一句话**:拍板已定——做 **B'(installed_ref 最小留痕)+ KB 先行**(agent 物化下一阶段、本 plan 不含 agent)。本 plan 把 B' 拆成 **U0…U4 五个可独立验证的实现单元**,物化落点在**知识库目标域绑定路径**(`MuseKnowledgeBindingService`,非 install)。但调查坐实了一个比评审版更硬的事实:**按当前代码,多租户"共享发布者同一 RAGFlow dataset"必然越权**(RAGFlow 不感知 muse 租户、检索链对返回 chunk 无任何租户/授权二次过滤)。因此 **U0 不是"验证隔离是否隔得住",而是"在共享落地前先把 chunk 级隔离补上"**——U0 是真前置硬门:U0 绿(隔离补到位)才允许"共享 dataset";U0 红/不做则本期 KB 物化只能走**每安装者独立复制 dataset(局部 C)**,或退回**暂缓**。这一岔路必须人类拍板后才进 U1。
>
> **2026-06-26 更新(D1 已拍 = S 补隔离后共享)**:U0 spike 已执行并坐实证据——spike 单测 13/0(2 case 钉死越权:他租户私有 chunk 原样返回 + `RetrieveChunksCommand` 的 documentIds/metadataFilter 均 null = 整库扫描)+ 活体真 RAGFlow 整库返回;并**揪出新潜伏 bug**:检索发 `metadata_filter`,但 RAGFlow 官方契约(context7 `/infiniflow/ragflow`)是 `metadata_condition`,被 RAGFlow **静默忽略**(活体对照:乱值条件仍返 baseline)。**S 分支据此定调**:① 隔离手段优先 `metadata_condition`(活体验证生效,document_ids 限定为备选),`metadata_filter→metadata_condition` 字段名修正纳入 U3 隔离前置;② 物化时给文档打安装维度元数据(否则 metadata_condition 把无元数据文档全滤为 0);③ U4 隔离回归复用 U0 的 2 个 spike case 作反向基线(隔离补到位后应从"原样返回他租户 chunk"转红)。spike 测试 +69 行已落 `MuseKnowledgeRetrievalApiImplTest`(零业务代码)。
---
## 0. 执行前必读:四条把工作量钉死的硬事实(基于真实代码,非评审版推断)
评审版(临时-02)把方向定对了,但执行前有四条一手代码事实会直接改变单元划分与工作量,必须先摆明(每条附证据,行号为只读核验所得):
**事实 1 — 知识库侧 `kb_id` 字段当前是被污染的,存的是 market assetId,不是任何真实 `muse_knowledge_base.id`。**
`MuseKnowledgeBindingService.createKnowledgeBindingPrecheck` 第 110 行 `precheck.setKbId(parseLong(reqVO.getSourceId()))`,而 studio 传入的 `sourceId` 实为 market **assetId**(`muse-studio/src/features/handoff/owners/KnowledgeHandoffLanding.tsx:44` `sourceId: assetId`,源头 `HandoffLandingPage.tsx:29` 取自 URL `?assetId=`)。绑定时这个值原样进 `muse_knowledge_binding.kb_id`(`createKnowledgeBinding` 第 148 行 `binding.setKbId(precheck.getKbId())`)。**结论**:market_kb 绑定既没有在 knowledge 域建本地 kb 实体,其 `kb_id` 也不是真实主键——物化要做的不只是"补建实体",还要**把 `kb_id` 从 assetId 改写成新建 installed_ref 行的真实主键**,并保证检索/读回端口都跟着改对。这是 U2 的核心难点,评审版未点破。
**事实 2 — `assetId → 发布者 kbId → 发布者 dataset_id` 这条"共享"必需的反查链,数据上通、跨 BC 上断。**
`muse_market_asset.source_id`(`V6__init_market_schema.sql:10`,BIGINT)在 admin 审核物化资产时确实写入发布者本地 kbId(`AdminMarketReviewServiceImpl.java:454` `asset.setSourceId(longValue(draftSnapshot.get("sourceId")))`,sourceId 源自发布者上架请求 `MarketPublishDraftReqVO.sourceId`)。但 knowledge 域**不能直读 market 的 `.dal`**(`bc-boundaries` ArchUnit),而 market 对外 API 包 `cn.iocoder.muse.module.market.api` 下**目前只有 `MarketHandoffTokenApi`(verify/consume)**,没有任何"按 assetId 查源对象"的读接口。**结论**:共享方案必须**新增一个跨 BC 读端口**(如 `MarketAssetSourceApi.getAssetSource(assetId) → {sourceOwner, sourceType, sourceId=发布者kbId, publisherUserId, tenantId}`,本地进程内 Bean、与 `MarketHandoffTokenApi` 同范式、不加 Feign)。这是 U2"共享分支"的隐藏前置工作,评审版未列。
**事实 3 — 按当前代码,多租户共享同一 `ragflow_dataset_id` 必然越权(这是 U0 性质的根本改变)。**
RAGFlow 检索 HTTP body(`HttpRagFlowKnowledgeRuntimeClient.retrieveChunks:143-158`)只发 `dataset_ids + question + top_k + similarity_threshold + metadata_filter`;`tenantId/ownerUserId/kbId` **不进 RAGFlow**(仅 muse 本地做入参校验/审计/归因)。RAGFlow 单 baseUrl + 单 API key、全租户共用、**完全不感知 muse 租户**。检索链 `MuseKnowledgeRetrievalApiImpl`:双门 + `selectActiveDatasetByKbId` 只控制"能否拿到这个 dataset_id",一旦 dataset_id 进入检索,`document_ids` 传 null → **整库返回全部 chunk**;`parseChunks` 回来只按 dataset_id 贴归因元数据,**对 chunk 无任何按 `request.tenantId()` 的二次过滤**。muse 的租户隔离只能保证"A 拿不到指向别人 dataset 的 binding",**一旦设计上让两租户 binding 指向同一 dataset_id,这道防线即被绕过**。两份既有 review 已佐证(`docs/agent-specs/2026-06-23-market资产物化-review.md:94`、`docs/agent-specs/2026-06-23-ai-knowledge-retrieval-review.md:26`)。**结论**:U0 不能是"验证能不能共享"——已知不能;U0 是"**要共享就必须先补 chunk 级隔离**(metadata_filter 把 chunk 限定到本安装授权子集,或 document_ids 限定),补到位且验证通过才允许共享"。
**事实 4 — 评审版/任务背景里提到的 `muse_source_propagation_target` 表在代码里不存在,U4 回滚不能引用它。**
全仓 SQL/Java 搜 `source_propagation_target` / `SourcePropagationTarget` 零命中。真实落地的 knowledge 侧来源传播是 `muse_knowledge_source_event` + `muse_knowledge_source_binding_projection`(`V14__...:203/245`);unbind 软删走 `sourceBindingProjectionMapper.markDeletedByBindingId`(`MuseKnowledgeBindingService.java:204`,且 `deleted` 是 `@TableLogic` 需 `setSql("deleted = true")` 才真软删——见模块 `.agent` 2026-06-19 记录)。**结论**:U4 的"召回/下架回滚已物化实体"必须基于这套真实机制设计,不得假装 `muse_source_propagation_target` 存在;若需要"market 召回→knowledge 停用 installed_ref"的跨 BC 传播,要么复用 knowledge source event 入口,要么如实标为"本期缺、需新增传播接口"的开放项。
> 这四条不推翻 B' 的方向,但把 B' 的真实成本从评审版的"中"上修:**核心增量 = 新跨 BC 读 API(事实 2)+ kb_id 去污染(事实 1)+ chunk 级隔离(事实 3,仅共享分支需要)**。下面的单元据此划分。
---
## 1. 总览:实现单元依赖图与决策门
```mermaid
flowchart TD
U0{"U0 spike 硬前置门
chunk 级隔离能否补上?
(已知:当前共享必越权)"}
U0 -->|"绿: 隔离补到位
(metadata_filter/document_ids 真隔)"| SHARE["分支S: 共享发布者 dataset"]
U0 -->|"红/暂不投入隔离"| COPY["分支C: 每安装者独立复制 dataset
(局部 C, 隔离天然干净)"]
U0 -->|"产品优先级不在此"| HOLD["分支H: 暂缓
(只做诚实标注未开放)"]
SHARE --> U1
COPY --> U1
U1["U1 数据模型
installed_ref kb 行落点 + dataset 关联策略
判定是否需 V32 迁移(人类门)"]
U1 --> U2
U2["U2 物化落点
绑定路径建本地 kb 行 + dataset 关联
kb_id 去污染 + 新增 MarketAssetSourceApi 跨 BC 读
幂等 + kbId 跨 BC 存在性校验"]
U2 --> U3
U3["U3 检索打通
selectActiveDatasetByKbId 返非空→命中
消除 no_dataset, 授权快照沿用 ADR-020 VARCHAR"]
U3 --> U4
U4["U4 验证
真 PG IT(物化→检索命中) + studio e2e
+ 召回/下架回滚 + 跨租户隔离回归"]
style U0 fill:#fde2e2,stroke:#c0392b,stroke-width:3px
style SHARE fill:#fff6d6,stroke:#b8860b
style COPY fill:#e2f7e8,stroke:#27ae60
style HOLD fill:#eef3fb,stroke:#3b6fb6
```
**数据流(B' 共享分支,物化后)**:
```mermaid
flowchart LR
A["用户跳转→knowledge 绑定路径
createKnowledgeBindingPrecheck/createKnowledgeBinding"]
A --> B["消费 handoff token(原有)
consumeMarketHandoff"]
B --> C["✅U2: 调 MarketAssetSourceApi.getAssetSource(assetId)
拿发布者 kbId"]
C --> D["✅U2: 建本地 muse_knowledge_base 行
kb_type=installed_ref
source_market_asset_id=assetId
license_snapshot_id=授权快照"]
D --> E["✅U2: 建 muse_knowledge_ragflow_binding
kb_id=新建本地 kbId
ragflow_dataset_id=发布者 dataset(共享)"]
D --> F["✅U2: binding.kb_id = 新建本地 kbId
(去污染:不再是 assetId)"]
F --> G["检索: selectActiveDatasetByKbId(本地kbId)
✅U3 返非空"]
E --> G
G --> H["✅U0 共享分支: metadata_filter/document_ids
把 chunk 限定到本安装授权子集"]
H --> I["🟢 命中且隔离, AI 生成含 grounding"]
style C fill:#fff6d6,stroke:#b8860b
style D fill:#e2f7e8,stroke:#27ae60
style E fill:#e2f7e8,stroke:#27ae60
style F fill:#e2f7e8,stroke:#27ae60
style H fill:#fde2e2,stroke:#c0392b
style I fill:#e2f7e8,stroke:#27ae60
```
---
## 2. 实现单元(U0…U4)
> 文件路径均为 repo 相对路径。每个单元含 Goal / Files / Approach / Test scenarios(输入+预期)/ Verification。测试场景"输入"指具体调用入参或数据状态,"预期"指可断言的可观测结果。
### U0 · spike:chunk 级隔离硬前置门(决定共享/复制/暂缓分支)
**Goal**:在落任何物化代码前,回答唯一硬问题——**"共享发布者同一 RAGFlow dataset"在当前检索链下能否做到租户/授权隔离**。调查已先验给出方向性结论(当前**必越权**,见 §0 事实 3),U0 要做的是**用最小成本把这个结论钉成可复现的证据 + 给出"补隔离"的可行手段验证 + 输出决策门**,让人类据此在"共享(S)/复制(C)/暂缓(H)"间拍板。U0 **不产出物化业务代码**,只产出证据与结论。
**Files(只读 + spike 验证,不改业务代码)**:
- 只读核验:`muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/HttpRagFlowKnowledgeRuntimeClient.java`(retrieveChunks `:143-158`,确认 HTTP body 不含 tenant、document_ids/metadata_filter 透传形态)
- 只读核验:`muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/api/MuseKnowledgeRetrievalApiImpl.java`(`parseChunks:170-198` 确认无 chunk 级租户过滤)
- spike 单测(验证层 A,零外部、零业务改动):`muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/api/MuseKnowledgeRetrievalApiImplTest.java`(如已存在则加 case,否则新建 spike 专用测试类)——mock `RagFlowKnowledgeRuntimeClient` 返回"混入他租户 magic token 的 chunk 集",断言当前 `retrieveForWork` 把混入 chunk 原样返回(**证伪"有租户过滤"**)。
- spike 活体(验证层 B,真 RAGFlow,mini-infra 100.64.0.8 在线):参照 `.../application/muse/facade/P1rRagFlowLiveAcceptanceIT.java` 范式,**只读地**对一个真 dataset 调 `/api/v1/retrieval`(document_ids=null),确认 RAGFlow 整库返回、不按任何 muse 维度过滤。
**Approach / 验证方法(多租户 RAGFlow dataset 隔离怎么验)**:
1. **造数据**:seed 两个真实租户 T_A、T_B(注意当前是单租户 + `tenant_id DEFAULT 0`,需先建第二租户并确认 yudao 租户拦截器在两租户上下文都生效);让"发布者"建一个 KB → 真上传一份文档 → RAGFlow 真生成出 `ragflow_dataset_id = D`。
2. **共享指向**:在 T_A、T_B 各建一条 installed_ref binding(spike 阶段可直接 SQL 注入读模型行,不依赖 U2 代码),两条的 `muse_knowledge_ragflow_binding.ragflow_dataset_id` **都 = D**(模拟 B' 共享),各自给齐 `bindingScope=search` + `authorizationSnapshotId` + work 归属。
3. **掺入可识别越权探针**:往 D 里放一段"只有发布者/T_B 该看"的、含唯一 magic token 的 chunk(**只共享同一份内容验不出问题,掺入差异内容才是验越权的核心**)。
4. **各自检索**:以 T_A 租户上下文调 `MuseKnowledgeRetrievalApi.retrieveForWork`(A 的 work + question 命中那段 magic chunk),看返回是否含本不该 A 看的内容;T_B 同样跑一遍。
5. **判定(决策门)**:
- 若 A 能检索到 D 的 magic chunk(预期会)→ **证实共享即越权** → 共享分支(S)**不可直接落地**,必须先做第 6 步的"补隔离"并复验通过,否则只能走复制(C)或暂缓(H)。
- 若已实现"补隔离"且 A 检索不到 B 的 magic chunk、且各自只拿到本授权子集 → 共享分支(S)**放行**。
6. **"补隔离"可行手段验证**(共享分支前置):验证以下任一路径能把 chunk 限定到"本安装授权子集":
- (a) `RetrieveChunksCommand.metadataFilter` —— 物化时给 RAGFlow 文档打租户/安装维度元数据,检索时按 `metadata_filter` 过滤;需验 RAGFlow `/api/v1/retrieval` 的 `metadata_filter` 语义是否真生效(**用 context7/RAGFlow 官方文档核对 + 活体打一发**,不要靠假设)。
- (b) `RetrieveChunksCommand.ragflowDocumentIds` —— 检索时把 document_ids 限定到本安装可见文档集(需本地维护"installed_ref→可见 documentId 集"映射)。
- 两条都不可行 → 共享分支判负,回落复制(C)。
**决策门输出(U0 交付物)**:一页结论——(i) 当前共享越权与否的可复现证据(单测层 + 活体层各一);(ii) "补隔离"手段 (a)/(b) 哪条可行、成本几何;(iii) 明确给人类的三选一建议:**S(补隔离后共享)/ C(每安装者复制 dataset)/ H(暂缓)**。后续 U1–U4 据此分支。
**Test scenarios(spike 自身的断言)**:
- 单测层|输入:mock RAGFlow 返回 `[{dataset_id:D, content:"含 MAGIC_B"}, {dataset_id:D, content:"公共内容"}]`,以 T_A 上下文 `retrieveForWork`|预期:返回结果**包含** `MAGIC_B`(证伪租户过滤,钉死越权事实)。
- 活体层|输入:真 dataset D(含 magic 文档),`/api/v1/retrieval` document_ids=null|预期:响应 `data.chunks[]` 含 magic chunk(RAGFlow 整库返回,无 muse 维度过滤)。
- 补隔离层(仅当尝试 metadata_filter)|输入:检索带 `metadata_filter={install_id: A}`|预期:返回仅含 A 授权子集、**不含** `MAGIC_B`(若 RAGFlow 该语义生效则共享可行)。
**Verification**:
- 单测:`cd muse-cloud && mvn -pl muse-module-knowledge/muse-module-knowledge-server -am test -Dtest=MuseKnowledgeRetrievalApiImplTest`(spike case 绿=越权事实成立)。
- 活体:按模块 `.agent` / `.agents/knowledge` 起栈(RAGFlow env 配齐,mini-infra 在线),`P1rRagFlowLiveAcceptanceIT` 同款 live profile 跑只读检索探针。
- **凭据红线**:RAGFlow base-url/api-key 由 env 注入(`muse.knowledge.ragflow.*`),spike 脚本与文档**不打印任何 key 值**(过滤 `password|secret|token|sk-`);活体只读检索,**不写 muse_slice_live**。
---
### U1 · 数据模型:installed_ref kb 行落点 + dataset 关联策略 + 迁移判定
**Goal**:定义物化产物的数据形态——installed_ref 知识库行写哪些列、dataset 如何关联(共享 or 复制,据 U0 分支),并**判定是否需要新 Flyway 迁移**。
**Files**:
- 只读核验地基:`muse-cloud/sql/muse/V5__init_knowledge_schema.sql`(`muse_knowledge_base` 第 4–28 行:`kb_type`/`source_market_asset_id`/`license_snapshot_id` 已备)、`muse-cloud/sql/muse/V14__extend_knowledge_ragflow_real_api_schema.sql`(`muse_knowledge_ragflow_binding` `:109-149`,含唯一索引 `uk_muse_knowledge_ragflow_binding_active` = `(tenant_id, kb_id) WHERE status='active' AND document_id IS NULL AND deleted=FALSE`)
- DO 对照:`.../knowledge/dal/dataobject/muse/MuseKnowledgeRagflowBindingDO.java`、`.../dal/dataobject/muse/MuseKnowledgeBaseDO.java`
- 可能的新迁移(**仅当判定需要**):`muse-cloud/sql/muse/V32__.sql`
**Approach**:
1. **installed_ref kb 行字段映射**(写入由 U2 执行,U1 只定字段语义):
- `kb_type = 'installed_ref'`(V5 已支持该取值,现状从不写本表)。
- `source_market_asset_id = assetId`(承载"来源市场资产"溯源;注意当前为 BIGINT,assetId 为数值,类型相容)。
- `license_snapshot_id` —— **类型核查项**:V5 该列是 BIGINT。market 授权快照主键 `muse_market_authorization_snapshot.id` 是 BIGINT(install 存的是数值主键,见 `MarketInstallServiceImpl.java:85` `setAuthorizationSnapshotId(authorization.getId())`),故此处填**授权快照行数值主键**与 BIGINT 相容;**但若决定改存 ADR-020 字符串 envelope(`rpe-local-`)则需 widen**(见决策点 D3)。U1 判定:**初版填数值主键、不 widen**(与 install 侧一致,避免牵动 market 两列 BIGINT 的独立议题)。
- `owner_user_id = 安装者 loginUserId`、`status='active'`、`active_version=1`、`name/description` 取自资产摘要(经 U2 的 MarketAssetSourceApi 带回或用占位)。
2. **dataset 关联策略(据 U0 分支)**:
- **分支 S(共享)**:建一行 `muse_knowledge_ragflow_binding`,`kb_id=新建本地 installed_ref kbId`、`ragflow_dataset_id=发布者 dataset id`、`document_id=null`、`status='active'`、`active_version=1`。形态参照 `MuseKnowledgeDocumentService.persistDatasetBinding`(`:321-347`,数据集级行 `setDocumentId(null)`)。**唯一索引落点核查**:`uk_...active` 是 `(tenant_id, kb_id)`,installed_ref 用**安装者租户内新建 kbId**,与发布者 kb 的 binding 不撞键(评审版担心的"沿用 sourceId 撞键"在去污染后消失)。
- **分支 C(复制)**:建新 dataset(调 `ragFlowClient.createDataset`)+ 复制文档/重解析,binding 指向新 dataset。**这是异步重活**,本期若选 C 需评估是否纳入(任务背景倾向 S/暂缓,C 留作 U0 红时的兜底,且复制本身另起子任务,不在本 plan 细化)。
3. **迁移判定(人类门 yes/no)**:
- **分支 S 初版(填数值 license_snapshot_id、kb_type/source_market_asset_id 复用 V5 已备列)→ 不需要新 Flyway 迁移**。所有写入落在 V5/V14 已有列上,DO 已有对应字段。
- **仅以下情形需 V32(DDL 人类门)**:(a) 决定 `license_snapshot_id` 改存字符串 envelope → `ALTER ... TYPE VARCHAR(128)`;(b) 需要新增"installed_ref→可见 documentId 集"映射表(U0 选 metadata_filter 路径 (b) 时);(c) 需要 market 召回→knowledge 传播的新读模型列。**这三项均非 B' 共享初版必需**,故**初版判定:不需要新迁移**。
**判定结论:是否需新 Flyway 迁移 = 否(B' 共享/复制初版均落在 V5+V14 已备列;新 V32 仅在上述 (a)/(b)/(c) 触发,届时标 DDL 人类门 + V32 版号 + `_test` 库铁律)。**
**Test scenarios(数据模型层,落到 U2 实现后由 IT 验,U1 阶段为设计断言)**:
- 字段映射|输入:assetId=X 的 market_kb 安装+绑定|预期:新建一行 `muse_knowledge_base`,`kb_type='installed_ref' AND source_market_asset_id=X AND owner_user_id=安装者 AND status='active'`。
- dataset 关联(S)|输入:发布者 kb 的 dataset=D|预期:新建一行 `muse_knowledge_ragflow_binding`,`kb_id=新建本地kbId AND ragflow_dataset_id=D AND document_id IS NULL AND status='active'`,且不违反 `uk_...active`。
**Verification**:U1 本身不跑测试(设计单元);其正确性由 U2/U3 的 IT 覆盖。若触发 V32,则 `cd muse-cloud && mvn -pl muse-server flyway:migrate`(**仅指向 `_test` 库,绝不指 muse_slice_live**)验证迁移可应用。
---
### U2 · 物化落点:绑定路径建本地 kb 行 + dataset 关联 + kb_id 去污染 + 跨 BC 读
**Goal**:在 knowledge 目标域绑定路径里,把"只写引用绑定"升级为"建出可用的本地 installed_ref 知识库实体 + dataset 关联",替代现 `kbId=parseLong(sourceId)` 裸引用;做到幂等(重复 install/bind 不重复造实体)+ 补 kbId 跨 BC 存在性校验。
**Files**:
- 主改:`muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/MuseKnowledgeBindingService.java`(`createKnowledgeBindingPrecheck:71-127`、`createKnowledgeBinding:129-177`、`consumeMarketHandoff:273-284`)
- 新增跨 BC 读端口(事实 2):
- 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`(新)
- 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` 等,**经 market 自己的 .dal**,不让 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**:
1. **新增 market 跨 BC 读端口**(事实 2 前置):`MarketAssetSourceApi.getAssetSource(Long assetId)` 返 `{sourceOwner, sourceType, sourceId(=发布者kbId), publisherUserId, tenantId}`,实现读 `muse_market_asset`(`source_id`/`asset_type`/`publisher_id`)。范式同 `MarketHandoffTokenApi`(本地 Bean,knowledge 注入它而非读 market.dal)。**保证不破 ArchUnit `bc-boundaries`**。
2. **kbId 跨 BC 存在性校验 + 去污染**:market_kb 绑定时,`sourceId` 当作 **assetId** 处理(不再 `parseLong` 当 kbId):
- 调 `MarketAssetSourceApi.getAssetSource(assetId)` 校验资产存在且为 `knowledge_base` 类型(不存在/类型不符 → fail-closed 抛错,替代现状裸信客户端)。
- 拿到发布者 kbId,再(共享分支)经……拿发布者 dataset id。**注意**:knowledge 读"发布者 kb 的 dataset"也跨 owner——发布者 kb 与安装者不同 owner/可能不同 tenant,现有 `selectActiveDatasetByKbId` 带租户拦截器,需确认能在物化事务的租户上下文里读到发布者 dataset(**这是共享分支的一个未决技术点**:要么 MarketAssetSourceApi 直接把发布者 dataset id 一并带回[由 market 在其能力内读取 knowledge 投影,但 dataset 属 knowledge 域→更适合 knowledge 内部按发布者 kbId + 发布者 tenant 读],要么新增 knowledge 内部"按 kbId+tenant 读 active dataset"的 `@TenantIgnore` 受控查询。**U2 需在实现时定夺并在 plan 复审时回填**)。
3. **建本地 installed_ref 实体(落点:`createKnowledgeBinding` 同一 `@Transactional` 事务内)**:
- 在写 `muse_knowledge_binding` 之前,先 insert `muse_knowledge_base`(字段按 U1 §2.1),拿到**新建本地 kbId**。
- 共享分支:insert `muse_knowledge_ragflow_binding`(`kb_id=新建本地kbId`、`ragflow_dataset_id=发布者dataset`、`document_id=null`),形态仿 `persistDatasetBinding`。
- **去污染**:`binding.setKbId(新建本地kbId)`(不再是 assetId);`precheck.setKbId(...)` 同步——precheck 阶段已需建实体还是 bind 阶段建,二选一:**建议放 `createKnowledgeBinding`(用户确认绑定的写事实点)**,precheck 仅校验资产存在性,避免 precheck 失败留下孤儿 kb 行。
- **来源溯源**:`source_market_asset_id=assetId`、`license_snapshot_id=授权快照主键`。
4. **幂等**(重复 install/bind 不重复造实体):
- 绑定本身已有命令回放(`commandService.reserveCommand`/`recordCompleted`)+ `uk_muse_knowledge_binding_work_kb (tenant_id, work_id, kb_id)`——去污染后 kb_id 是稳定的本地主键,但**首次绑定才知道本地 kbId**,故幂等键需落在"(安装者, 市场资产)"维度:建 installed_ref kb 行前先 `SELECT ... WHERE source_market_asset_id=assetId AND owner_user_id=安装者 AND deleted=false`,命中则复用既有 installed_ref kbId,不重复建。
- dataset binding 复用 `persistDatasetBinding` 的 `DataIntegrityViolationException`→回读模式应对并发。
5. **事务原子性**:物化与"消费 handoff token + 写绑定事实"在**同一事务**(现状 `consumeMarketHandoff` 已沿用调用方 `@Transactional`),失败整体回滚,无中间态。契合 `架构-02` §7 / `流程-02B` §13"目标 owner 自己消费 token + 写 owner 事实"。
**Test scenarios(输入+预期)**:
- 首次安装绑定|输入:T_A 安装 assetId=X(knowledge_base 类型,发布者 kb=K、dataset=D),`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, document_id=null, status=active)`。
- 幂等-重复绑定|输入:同一 (A, X) 再次 install+bind(不同 commandId 或回放)|预期:**不新建第二行 installed_ref kb**(复用 K2);不违反 `uk_...active`;不抛 500。
- 资产不存在|输入:`sourceId` 指向不存在的 assetId|预期:`MarketAssetSourceApi` 返空 → fail-closed 抛 `KNOWLEDGE_MARKET_HANDOFF_UNAVAILABLE` 或新增明确错误码,**不写任何 kb/binding 行**(替代现状裸信客户端)。
- 资产类型不符|输入: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'`(嵌入式 H2 往返,覆盖去污染 + 幂等 + fail-closed)。
- **改了 market-api 模块务必先 `mvn -pl muse-module-market/muse-module-market-api -am install`** 再跑 knowledge 模块测试,否则 stale jar 假红(memory `muse-market-install-isacquired-enrich` 教训)。
- ArchUnit:`mvn -pl ... test -Dtest=BcBoundaryArchTest`。
---
### U3 · 检索打通:消除 no_dataset,命中
**Goal**:物化后,检索第四门 `selectActiveDatasetByKbId(本地kbId)` 返非空 → 命中(消除 `no_dataset` 静默省略);授权快照沿用 ADR-020 VARCHAR envelope,不回退 BIGINT/parseLong。
**Files**:
- 主路径(多半**无需改**,靠 U2 数据正确即自然打通):`.../knowledge/api/MuseKnowledgeRetrievalApiImpl.java`(第四门 `:122-127`、`parseChunks:170-198`)
- 仅共享分支 U0 选 metadata_filter/document_ids 时需改:`RetrieveChunksCommand` 构造处(`MuseKnowledgeRetrievalApiImpl.java:148-151`)+ `HttpRagFlowKnowledgeRuntimeClient.retrieveChunks:143-158`(透传 metadata_filter/document_ids)
**Approach**:
1. **共享分支基线打通**:U2 把 `binding.kb_id` 改成本地真实 kbId、并建好数据集级 `muse_knowledge_ragflow_binding(kb_id=本地kbId, ragflow_dataset_id=发布者dataset)` 后,检索链第四门 `selectActiveDatasetByKbId(本地kbId)` 自然返非空 → `no_dataset` 消除 → 进 RAGFlow 检索。**这一步 RetrievalApiImpl 代码无需改动**(这是 B' 共享"地基已备"的真实体现)。
2. **授权快照沿用 ADR-020**:检索门读 `binding.getAuthorizationSnapshotId()`(已是 VARCHAR,V14 `muse_knowledge_binding.authorization_snapshot_id` 已 widen),installed_ref binding 写入时**沿用 VARCHAR 字符串承载**(U2 写 binding 时不改这一列类型)。chunk §5.3 合同字段 `authorizationSnapshot` 透传字符串,不 parseLong。
3. **隔离(仅共享分支,且仅 U0 绿后)**:把 U0 验证通过的隔离手段落到 `RetrieveChunksCommand`——metadata_filter 或 document_ids 限定本安装授权子集。**U0 红则本单元不走共享、检索直接对接复制分支的独立 dataset(天然隔离)**。
**Test scenarios(输入+预期)**:
- no_dataset 消除|输入:U2 物化后的 installed_ref(本地kbId=K2, 关联 dataset=D),work 绑定含 search 用途 + 授权快照,`retrieveForWork(question 命中 D 内容)`|预期:结果**非** `empty("no_dataset")`,`authorized` 非空,进入 RAGFlow 检索;对照现状(同输入必返 `no_dataset`)。
- 命中 grounding|输入:同上,RAGFlow 返含 D chunk|预期:`RetrievalResult.ok(chunks)`,chunk 带齐 §5.3 字段(sourceOwner/authorizationSnapshot 等),`authorizationSnapshot` 为字符串 envelope。
- 隔离(共享分支,U0绿后)|输入:T_A 检索共享 dataset D(D 含 T_B magic chunk)|预期:返回**不含** T_B magic chunk(隔离手段生效)。
- 授权快照字符串|输入:installed_ref binding 的 `authorization_snapshot_id` 为 `rpe-local-` 形态|预期:检索门正常放行、chunk 字段透传该字符串,无 NumberFormatException、不落 null。
**Verification**:
- 单测:`mvn -pl muse-module-knowledge/muse-module-knowledge-server -am test -Dtest=MuseKnowledgeRetrievalApiImplTest`(mock RAGFlow,覆盖 no_dataset 消除 + 字符串授权快照 + 隔离过滤断言)。
- 活体并入 U4 端到端。
---
### U4 · 验证:真 PG 端到端 + studio e2e + 回滚 + 隔离回归
**Goal**:用真实证据证明"市场 KB 安装→绑定→物化→检索命中"端到端可真用,且召回/下架时已物化实体能回滚、跨租户隔离不被共享绕过。
**Files**:
- 真 PG IT(新):`muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/.../P1rMarketInstallKbMaterializationIT.java`(参照既有 `P1r*IT` 范式 + `P1rRagFlowLiveAcceptanceIT` live 范式)
- studio e2e(新):`muse-studio/e2e/market-install-kb-retrieval.spec.ts`(参照 `muse-studio/e2e/knowledge-bindings.spec.ts` + `market-install.spec.ts`)
- 回滚相关(据真实机制,事实 4):`.../knowledge/application/muse/MuseInstalledKnowledgeBaseService.java`(disable/delete 路径)+ knowledge source event/projection(`muse_knowledge_source_event`/`muse_knowledge_source_binding_projection`)
**Approach / Test scenarios(输入+预期)**:
1. **知识库 B' 端到端(真 PG + 真 RAGFlow)**|输入:安装市场 KB 资产 → 作品绑定(物化 installed_ref + dataset 关联)→ 触发检索 `retrieveForWork`|预期:第四门拿到(发布者)dataset → RAGFlow chunk 真命中 → 生成上下文含该来源 grounding;对照现状(必 `no_dataset` 省略)。
2. **幂等**|输入:重复 install + 重复 bind(回放 + 不同 commandId 两路)|预期:物化实体不重复创建(命令回放 + (安装者,资产) 幂等键 + 唯一键),DB 中 installed_ref kb 仅一行。
3. **跨租户隔离回归(共享分支必跑,承 U0)**|输入:T_A、T_B 各装同一发布者 KB(共享 dataset D,D 含两租户差异内容)→ 各自检索|预期:A 检索**不越权读** B 的私有 chunk(隔离手段生效);**若走复制分支则验各自独立 dataset 天然隔离**。
4. **回滚/来源传播(基于真实机制,不引用不存在的 `muse_source_propagation_target`)**|输入:发布者资产被 admin 召回/下架(market `adminRecallAsset`/`adminDelistAsset`)|预期:已物化 installed_ref 实体被**阻断新使用**(不自动删,符合 `架构-02` 来源事件"不删除正式知识"语义)。**实现路径待定项**:market 召回当前对目标 owner 的传播是 fail-closed 记录式(见 market `.agent` `adminRecallAsset` 描述"目标 owner 传播 fail-closed"),knowledge 侧消费"market 召回→停用 installed_ref"的入口**本期可能缺**——U4 需如实验证现状传播是否触达 knowledge installed_ref;若不触达,则标为**开放项**("召回回滚未自动化,需新增 market→knowledge 传播接口或手动停用"),不假装已闭环。
5. **删除/停用已物化实体**|输入:用户 `disableInstalledKnowledgeBase`/`deleteInstalledKnowledgeBase`|预期:installed_ref binding 软删(`@TableLogic` + `setSql("deleted = true")`,见 §0 事实 4),检索读回排除;物化的 dataset binding(共享分支)**不删发布者 dataset**(只解除本安装的引用),复制分支需评估是否清理本安装独占 dataset。
6. **机械门禁**|输入:ArchUnit `BcBoundaryArchTest` + 覆盖 JSON 门|预期:物化只经目标 owner facade(`MuseKnowledgeBindingService` + `MarketAssetSourceApi`)写自己的实体,market 不直写 knowledge `.dal`,test 绿。
**Verification**:
- 真 PG IT:从 `muse-cloud/` 跑,按 memory `muse-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-`;不 rm `dump.rdb`/`lefthook`。
---
## 3. 决策点(待人类拍板,按优先级)
| # | 决策点 | 选项 | 倾向 | 阻塞谁 |
|---|---|---|---|---|
| **D1** | **dataset 共享 vs 复制**(最硬,U0 门) | S 补隔离后共享 / C 每安装者复制 / H 暂缓 | **先看 U0 spike 证据再拍**;已知当前共享必越权,S 需先投入 chunk 级隔离(metadata_filter/document_ids)并验证;不投入则 C 或 H | U1–U4 全部分支 |
| D2 | 物化时机 | 绑定同步 / 惰性首用 | **绑定同步**(与 token 消费原子,不进高频热路径) | U2 |
| D3 | `license_snapshot_id` 承载 | 数值主键(不 widen)/ 字符串 envelope(需 V32) | **数值主键**(与 install 侧一致,避免牵动 market 两列 BIGINT 独立议题) | U1 是否需 V32 |
| D4 | 跨 BC 读"发布者 dataset"的实现路径 | MarketAssetSourceApi 带回 dataset id / knowledge 内部 `@TenantIgnore` 受控查发布者 kb dataset | U2 实现时定夺并回填本 plan | U2/U3 共享分支 |
| D5 | 召回/下架回滚自动化 | 复用现有 market→目标 owner 传播 / 本期标开放项手动停用 | 先验现状传播是否触达 installed_ref,**不假装已有 `muse_source_propagation_target`** | U4 |
---
## 4. 风险
- **R1(最高)跨租户越权**:共享分支若未补 chunk 级隔离即上线 → A 读到 B 私有内容。**缓解**:U0 硬门 + U4 隔离回归;U0 红绝不放行共享。当前单租户运行使风险"暂不触发",但这是部署现状非代码隔离,不能作为放行理由。
- **R2 kb_id 去污染的回归面**:`kb_id` 从 assetId 改本地主键,牵动检索/读回/installed-KB 列表(`MuseInstalledKnowledgeBaseService` 读 `muse_knowledge_binding`)等所有读 kb_id 的路径。**缓解**:U2/U4 覆盖读回链;改动限定 market_kb 绑定路径,user_kb/global_kb 绑定不动。
- **R3 跨 BC 读端口扩面**:新增 `MarketAssetSourceApi` 是新的 knowledge→market 依赖。**缓解**:严格本地 Bean + ArchUnit 守边界;只读不写。
- **R4 发布者改版/撤权冲击安装者**:共享分支下发布者改 dataset/撤权直接影响安装者可用性。**缓解**:installed_ref 的 `license_snapshot_id` 钉授权来源;召回传播(D5)阻断新使用。这是 B' 相对 C 的固有取舍,非 bug。
- **R5 复制分支(C)的异步重活**:若 U0 红回落 C,文档复制+重解析是异步长任务(失败/重试/幂等/部分成功),**不在本 plan 细化,需另起子任务**。
---
## 5. 回滚(本 plan 执行后如何撤回)
- **代码回滚**:U2/U3 改动集中在 `MuseKnowledgeBindingService` + 新增 `MarketAssetSourceApi`(market-api/server)+ 可选 `RetrievalApiImpl` 隔离透传。revert 这些 commit 即回到"只写引用绑定"现状,检索回落 `no_dataset` 静默省略(功能不可用但不报错,与现状一致)。
- **数据回滚**:已物化的 installed_ref kb 行 + dataset binding 经软删(`deleted=true`)停用;**共享分支不删发布者 dataset**(本就共享,无副作用);复制分支需清理本安装独占 dataset。
- **迁移回滚**:初版不引入 V32(D3 选数值主键),无迁移需回滚;若触发 V32 则按 Flyway 不可逆约定,回滚靠新 forward 迁移(且 `_test` 库先验)。
---
## 6. Scope 边界(明确不做什么)
- **KB 限定**:本 plan **只做知识库(KB)物化**。
- **agent 下一阶段**:智能体(agent)物化**不在本 plan**——`muse_agent` 的 `market_installed` 落地 + 运行期 `requireVisibleAgent` 放行(`MuseAgentSlotServiceImpl`/`MuseAiTaskServiceImpl`)是独立的下一阶段任务,本 plan 不触碰 ai 模块。
- **install 解耦不动**:`MarketInstallServiceImpl.installMarketplaceAsset` 维持"只记账、`targetFactsWritten=false`"现状,**不在 install 侧物化**——物化落点严格在 knowledge 目标域绑定路径。这是对的解耦,不改。
- **handoff 非物化桥**:handoff token 只负责跳转 + 被目标域消费,**不负责物化**(`MarketHandoffTokenApi.verify/consume` 不动)。
- **复制分支(C)细节不展开**:C 仅作 U0 红时的兜底方向标注,文档复制/重解析的异步实现另起子任务。
- **market 两列 BIGINT 议题不纳入**:`muse_market_installation.authorization_snapshot_id`/`source_snapshot_id` 仍 BIGINT 是独立契约议题(临时-02 §3.3 已列备查),本 plan 不处理。
---
## 7. 关联阅读
- 方案权衡与决策收束(本 plan 的 WHAT):[`临时-02-market-install下游物化方案.md`](临时-02-market-install下游物化方案.md)
- 概念与 owner 边界:[`架构-02-核心数据结构与双轨模型.md`](架构-02-核心数据结构与双轨模型.md)(§6 市场资产、§11.2 绑定安装授权、授权快照语义)
- 检索合同 / fail-closed:[`专题-03-AI编排上下文与质量评测实现规范.md`](专题-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`](../.agents/rules/bc-boundaries.md)