fix(knowledge): audit impact preview owners

This commit is contained in:
lili 2026-06-19 08:18:46 -07:00
parent 8c1696c59a
commit bc63a35e88
7 changed files with 129 additions and 11 deletions

View File

@ -148,7 +148,7 @@ fork yudao-cloud:保留 `system/infra/framework/gateway/member/pay/bpm/report/mp
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|---|:---:|---|
| **AI 编排** (muse-module-ai) | ✅ | ✅ | ✅ | ⚠ | ✅ | ✅ | **72%** | 未实跑/可编译 | 较好有偏差 | high | ADR 级完整包隔离仍未完成(已有最小 ArchUnit 守卫);前端全 MSW,SSE 疑漂移;真实 New-API 未端到端验收 |
| **作品/编辑器** (muse-module-content) | ✅ | ✅ | ✅ | ⚠ | ✅ | ✅ | **82%** | 未实跑/可编译 | 高度一致 | high | **前端 Accept Suggestion 断链**:后端齐全但 studio 未接 suggestion-merges,AI 候选进正文用户端不可用 |
| **知识库** (muse-module-knowledge) | ✅ | ⚠ | ✅ | ⚠ | ✅ | ✅ | **72%** | 未实跑/可编译 | 高度一致 | high | 前端 2/3 面缺失(草稿确认/绑定/发布/图谱无 hook);Task4 跨 BC owner 未实现;recheck/worker 默认未生效 |
| **知识库** (muse-module-knowledge) | ✅ | ⚠ | ✅ | ⚠ | ✅ | ✅ | **72%** | 未实跑/可编译 | 高度一致 | high | 前端 2/3 面缺失(草稿确认/绑定/发布/图谱无 hook);Task4 installed/source-binding owner 计数已按投影事实审计,document/export owner 仍后置;recheck/worker 默认未生效 |
| **市场** (muse-module-market) | ✅ | ⚠ | ✅ | ⚠ | ✅ | ✅ | **82%** | 未实跑/可编译 | 高度一致 | high | studio 仅消费侧(浏览/授权/安装);**生产侧发布/上架/申诉无 UI,飞轮 UI 层断裂** |
| **元治理/MetaSchema** (muse-module-meta) | ✅ | ❌ | ✅ | ⚠ | ✅ | ✅ | **82%** | 未实跑/可编译 | 高度一致 | high | **impact-preview facade 仅 Unavailable→写链路运行期端到端阻断**;facade-api 缺失,跨 BC 投影消费契约未建立 |
| **事件/SSE** (muse-module-events) | ✅ | ✅ | ✅ | ⚠ | ✅ | ✅ | **88%** | 未实跑/可编译 | 后端一致/前端漂移 | high | 前端 connectAIStream 契约漂移(被 mock 掩盖,真实联调会失败);统一事件客户端为孤儿无 UI 消费 |
@ -237,7 +237,7 @@ fork yudao-cloud:保留 `system/infra/framework/gateway/member/pay/bpm/report/mp
**实现摘要:** 本仓最完整业务模块之一。**59 端点(app 36 + admin 23)与契约 48 path/59 method 精确对齐**;service 共 6443 行(DocumentService 1068/KnowledgeBaseService 951/DraftService 646)。`confirmKnowledgeDraft` 真把 Shadow 草稿物化为 Canonical 实体/关系(entityMapper/relationMapper.insert),带冲突检测、决策审计、commandId 幂等、sourceVersion 追踪。RAGFlow 是真 HTTP:`HttpRagFlowKnowledgeRuntimeClient`(621 行)调 `/api/v1/datasets``/api/v1/retrieval``/run_graphrag``/knowledge_graph`,Bearer + SSRF 防护 + 超时 + 18 类 FailureClass,并有 `Unavailable*` 优雅降级(ADR-006)。绑定预检存 sourceSnapshotId/authorizationSnapshotId/handoffHash;发布 readiness/snapshot;source-event 传播;outbox worker。DB:V5(7 表)+ V14(18 表)+ V18 ≈ 26 张。测试 38 文件 209 @Test,含真实 RAGFlow 验收 IT。前端 `useKnowledge.ts`(405 行)真调 `/knowledge-bases``/installed-knowledge-bases``/documents`,但仅覆盖约 1/3 面。
**桩与缺口:**
- **跨 BC owner 计数未实现(Task 4):** installed KB owner / document owner count / export_task_owner 以 'unsupported'/'not implemented in Task 4' 占位返回
- **Task 4 owner 计数仍有边界:** installed/source-binding owner 已按 `muse_knowledge_source_binding_projection.owner_user_id` 输出审计计数;document owner count / export_task_owner 仍以 unsupported/fail-closed 后置,不能伪造跨 BC owner 事实
- **recheck 仅记状态**,未做外部来源真实重校验('external source validation is not configured')。
- linkUrl 材料化 fail-closed(无 SSRF allowlist/隔离抓取前一律拒绝),功能未通。
- **outbox 发布 worker 默认关闭**,启用需运维配置。
@ -254,7 +254,7 @@ fork yudao-cloud:保留 `system/infra/framework/gateway/member/pay/bpm/report/mp
- `MuseKnowledgeDraftService.java:194`(recheck 'external source validation is not configured')
- `HttpRagFlowKnowledgeRuntimeClient.java:1-621`(真 HttpClient 调四端点 + Bearer)
- `UnavailableRagFlowKnowledgeRuntimeClient.java:9-60`(ADR-006 降级)
- `MuseKnowledgeBaseService.java:221-248`(Task 4 scoped 缺口占位)
- `MuseKnowledgeBaseService.java:211-248`(impact preview 聚合 binding/projection/task/policy 事实;document/export owner 仍 scoped 后置)
- `MuseKnowledgeGraphQueryService.java:41-69`(读 Canonical 投影非实时图)
- `sql/muse/V5/V14/V18`(7+18+outbox 表)
- `muse-studio/src/pages/KnowledgePage.tsx:18,53,266`(仅 KB list + MaterialManager + CreateKBModal;drafts/bindings/publish/graph 无 hook)

File diff suppressed because one or more lines are too long

View File

@ -5,5 +5,5 @@
- **目标(owner 职责)**:Local KB / User KB / 全局知识处理 / Knowledge Draft / Knowledge Source Binding / 投影与索引状态。
- **边界(不可违反)**:**进 Local KB 唯一入口=用户显式确认草稿或手动修正**;全书解析章节确认只产/推 Draft,不写正式知识;来源优先级 Local KB > 用户绑定 > 市场/全局;依赖 RAGFlow GraphRAG(不可用时 Unavailable 优雅降级);守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。
- **out-of-scope**:正文写入(归 content)。
- **现状**:只读评估 72%;后端最完整模块之一(59 端点对齐契约),RAGFlow 真 HTTP + SSRF 防护;installed-KB 删除记录回显 bug 已修(2026-06-19:`MuseKnowledgeBindingMapperTest` + `MuseInstalledKnowledgeBaseServiceTest` 10/10,删除时写 `deleted=true` 且列表排除 `binding_status=deleted`)。
- **关键风险 / TODO**:前端仅约 1/3 面(草稿确认/绑定/发布/图谱缺 hook);**Task4 跨 BC owner 计数未实现**(占位 'not implemented');recheck/worker 默认未生效。
- **现状**:只读评估 72%;后端最完整模块之一(59 端点对齐契约),RAGFlow 真 HTTP + SSRF 防护;installed-KB 删除记录回显 bug 已修(2026-06-19:`MuseKnowledgeBindingMapperTest` + `MuseInstalledKnowledgeBaseServiceTest` 10/10,删除时写 `deleted=true` 且列表排除 `binding_status=deleted`);impact preview 已按 `muse_knowledge_source_binding_projection.owner_user_id` 输出 installed/source-binding owner 审计计数(2026-06-19:`MuseKnowledgeBaseServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 30/30)
- **关键风险 / TODO**:前端仅约 1/3 面(草稿确认/绑定/发布/图谱缺 hook);Task4 剩余 owner 缺口已收窄为 **document owner count / export_task_owner** 仍不能伪造,需后续 owner 事实或外部服务接入;recheck/worker 默认未生效。

View File

@ -211,15 +211,20 @@ public class MuseKnowledgeBaseService {
preview.setStatus("ready");
long affectedBindings = knowledgeBindingMapper.countByKbId(kbId);
long affectedInstalls = sourceBindingProjectionMapper.countByKbId(kbId);
MuseKnowledgeSourceBindingProjectionMapper.FanoutOwnerAuditSummary ownerAuditSummary =
sourceBindingProjectionMapper.selectOwnerAuditSummaryByKbId(kbId);
long affectedWorks = knowledgeBindingMapper.countDistinctWorksByKbId(kbId);
long runningTasks = processingTaskMapper.countRunningByKbId(kbId);
long activePolicies = accessPolicyMapper.countActiveByKbId(kbId);
preview.setImpactSummary(JsonUtils.toJsonString(impactSummary(reqVO.getActionType(), reqVO.getTargetVersion(),
draftId, affectedBindings, affectedInstalls, affectedWorks, runningTasks, activePolicies)));
draftId, affectedBindings, affectedInstalls, affectedWorks, runningTasks, activePolicies,
ownerAuditSummary)));
preview.setRiskSummary(JsonUtils.toJsonString(Map.of(
"riskSummary", riskSummary(affectedBindings, affectedInstalls, runningTasks),
"unsupported", List.of("unsupported_no_installed_kb_owner", "unsupported_no_export_task_owner"),
"unknown", List.of("installed knowledge base ownership is not implemented in Task 4"))));
"ownerAuditSummary", ownerAuditSummary(ownerAuditSummary),
"unsupported", List.of("unsupported_no_export_task_owner"),
"unknown", ownerAuditSummary.invalidOwnerProjectionCount() > 0
? List.of("invalid_installed_kb_owner_projection") : List.of())));
preview.setExpiresAt(LocalDateTime.now().plusMinutes(30));
preview.setTenantId(TenantContextHolder.getRequiredTenantId());
impactPreviewMapper.insert(preview);
@ -777,7 +782,9 @@ public class MuseKnowledgeBaseService {
private Map<String, Object> impactSummary(String actionType, Integer targetVersion, String draftId,
long affectedBindings, long affectedInstalls, long affectedWorks,
long runningTasks, long activePolicies) {
long runningTasks, long activePolicies,
MuseKnowledgeSourceBindingProjectionMapper.FanoutOwnerAuditSummary
ownerAuditSummary) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("actionType", actionType);
summary.put("targetVersion", targetVersion);
@ -792,11 +799,23 @@ public class MuseKnowledgeBaseService {
summary.put("affectedInstalls", affectedInstalls);
summary.put("affectedWorks", affectedWorks);
summary.put("runningTasks", runningTasks);
summary.put("unknown", List.of("installed_kb_owner"));
// 安装来源 owner 只来自 Knowledge 投影表不跨 BC 查询或补造 owner 事实
summary.put("ownerAuditSummary", ownerAuditSummary(ownerAuditSummary));
summary.put("unknown", ownerAuditSummary.invalidOwnerProjectionCount() > 0
? List.of("invalid_installed_kb_owner_projection") : List.of());
summary.put("unsupported", List.of("export_task_owner"));
return summary;
}
private Map<String, Object> ownerAuditSummary(
MuseKnowledgeSourceBindingProjectionMapper.FanoutOwnerAuditSummary summary) {
return Map.of(
"totalProjectionCount", summary.totalProjectionCount(),
"validOwnerProjectionCount", summary.validOwnerProjectionCount(),
"invalidOwnerProjectionCount", summary.invalidOwnerProjectionCount(),
"distinctTargetOwnerCount", summary.distinctTargetOwnerCount());
}
private String policyImpact(String actionType) {
return ACTION_POLICY_PUBLISH.equals(actionType) ? "policy_will_change" : "unchanged";
}

View File

@ -78,6 +78,24 @@ public interface MuseKnowledgeSourceBindingProjectionMapper extends BaseMapperX<
return new FanoutOwnerAuditSummary(total, valid, total - valid, distinctOwners);
}
default FanoutOwnerAuditSummary selectOwnerAuditSummaryByKbId(Long kbId) {
List<MuseKnowledgeSourceBindingProjectionDO> projections = selectList(
new LambdaQueryWrapperX<MuseKnowledgeSourceBindingProjectionDO>()
.eq(MuseKnowledgeSourceBindingProjectionDO::getKbId, kbId)
.eq(MuseKnowledgeSourceBindingProjectionDO::getDeleted, false));
// owner_user_id Knowledge 本域读模型已落库事实只统计有效正数 owner不跨 BC 猜测归属
long total = projections.size();
long valid = projections.stream()
.filter(projection -> projection.getOwnerUserId() != null && projection.getOwnerUserId() > 0)
.count();
long distinctOwners = projections.stream()
.map(MuseKnowledgeSourceBindingProjectionDO::getOwnerUserId)
.filter(ownerUserId -> ownerUserId != null && ownerUserId > 0)
.distinct()
.count();
return new FanoutOwnerAuditSummary(total, valid, total - valid, distinctOwners);
}
default int updateStatusByKbId(Long kbId, String status, String actionPolicy, String lastEventId) {
return updateStatusByKbId(kbId, status, actionPolicy, lastEventId, null, null);
}

View File

@ -348,6 +348,8 @@ class MuseKnowledgeBaseServiceTest extends BaseMockitoUnitTest {
when(knowledgeBindingMapper.countByKbId(4001L)).thenReturn(2L);
when(knowledgeBindingMapper.countDistinctWorksByKbId(4001L)).thenReturn(2L);
when(sourceBindingProjectionMapper.countByKbId(4001L)).thenReturn(1L);
when(sourceBindingProjectionMapper.selectOwnerAuditSummaryByKbId(4001L))
.thenReturn(new MuseKnowledgeSourceBindingProjectionMapper.FanoutOwnerAuditSummary(4L, 3L, 1L, 2L));
when(processingTaskMapper.countRunningByKbId(4001L)).thenReturn(3L);
when(accessPolicyMapper.countActiveByKbId(4001L)).thenReturn(1L);
@ -367,7 +369,10 @@ class MuseKnowledgeBaseServiceTest extends BaseMockitoUnitTest {
&& inserted.getImpactSummary().contains("\"affectedInstalls\":1")
&& inserted.getImpactSummary().contains("\"runningTasks\":3")
&& inserted.getImpactSummary().contains("\"authorization\"")
&& inserted.getRiskSummary().contains("unsupported_no_installed_kb_owner")));
&& inserted.getImpactSummary().contains("\"validOwnerProjectionCount\":3")
&& inserted.getRiskSummary().contains("\"distinctTargetOwnerCount\":2")
&& inserted.getRiskSummary().contains("invalid_installed_kb_owner_projection")
&& !inserted.getRiskSummary().contains("unsupported_no_installed_kb_owner")));
}
@Test
@ -378,6 +383,8 @@ class MuseKnowledgeBaseServiceTest extends BaseMockitoUnitTest {
when(knowledgeBindingMapper.countByKbId(4001L)).thenReturn(2L);
when(knowledgeBindingMapper.countDistinctWorksByKbId(4001L)).thenReturn(2L);
when(sourceBindingProjectionMapper.countByKbId(4001L)).thenReturn(1L);
when(sourceBindingProjectionMapper.selectOwnerAuditSummaryByKbId(4001L))
.thenReturn(new MuseKnowledgeSourceBindingProjectionMapper.FanoutOwnerAuditSummary(1L, 1L, 0L, 1L));
when(processingTaskMapper.countRunningByKbId(4001L)).thenReturn(0L);
when(accessPolicyMapper.countActiveByKbId(4001L)).thenReturn(1L);

View File

@ -0,0 +1,73 @@
package cn.iocoder.muse.module.knowledge.dal.mysql.muse;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceBindingProjectionDO;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.List;
import java.util.Locale;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Knowledge 来源绑定投影 Mapper 语义测试
*/
class MuseKnowledgeSourceBindingProjectionMapperTest {
@BeforeAll
static void initMybatisPlusTableInfo() {
// 轻量 Mapper 测试不启动 Spring需要先初始化 Lambda 字段映射缓存
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
MuseKnowledgeSourceBindingProjectionDO.class);
}
@Test
void should_countOwnerAuditSummaryByKbIdFromProjectionFacts() {
MuseKnowledgeSourceBindingProjectionMapper mapper =
mock(MuseKnowledgeSourceBindingProjectionMapper.class, CALLS_REAL_METHODS);
doReturn(List.of(
projection(2001L),
projection(2001L),
projection(2002L),
projection(0L),
projection(null)))
.when(mapper).selectList(org.mockito.ArgumentMatchers.<Wrapper<MuseKnowledgeSourceBindingProjectionDO>>any());
MuseKnowledgeSourceBindingProjectionMapper.FanoutOwnerAuditSummary summary =
mapper.selectOwnerAuditSummaryByKbId(4001L);
assertEquals(5L, summary.totalProjectionCount());
assertEquals(3L, summary.validOwnerProjectionCount());
assertEquals(2L, summary.invalidOwnerProjectionCount());
assertEquals(2L, summary.distinctTargetOwnerCount());
ArgumentCaptor<Wrapper<MuseKnowledgeSourceBindingProjectionDO>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper).selectList(captor.capture());
String sqlSegment = normalizeSql(captor.getValue());
assertTrue(sqlSegment.contains("kb_id ="));
assertTrue(sqlSegment.contains("deleted ="));
assertTrue(((AbstractWrapper<?, ?, ?>) captor.getValue()).getParamNameValuePairs().containsValue(4001L));
assertTrue(((AbstractWrapper<?, ?, ?>) captor.getValue()).getParamNameValuePairs().containsValue(false));
}
private static MuseKnowledgeSourceBindingProjectionDO projection(Long ownerUserId) {
MuseKnowledgeSourceBindingProjectionDO projection = new MuseKnowledgeSourceBindingProjectionDO();
projection.setOwnerUserId(ownerUserId);
return projection;
}
private static String normalizeSql(Wrapper<MuseKnowledgeSourceBindingProjectionDO> wrapper) {
return wrapper.getSqlSegment().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT).trim();
}
}