feat(knowledge): 补 GET bindings 读端点(作品已绑来源读回)

AppMuseKnowledgeBindingController 加 GET /muse/works/{workId}/knowledge-bindings → bindingService.listKnowledgeBindings:先 requireWorkOwner 过信任边界防越权读他人作品来源绑定(IDOR),再 selectActiveByWorkId 读回投影读模型,id 转字符串对齐契约(防前端大整数精度丢失),X-API-Version guard 同既有端点。

至此 bindings 读回后端腿(绑定确认写投影→按 work 读回→GET 端点)闭环,仅余 FE hook。新增 AppKnowledgeBindingVO.BindingListRespVO/BindingItemVO。

测试:listKnowledgeBindings happy-path 字段映射 + 越权 fail-closed(requireWorkOwner 抛错则绝不查投影);knowledge-server 整套件 218/218 绿(clean 重编译,反假绿)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-19 13:30:53 -07:00
parent cac227223f
commit 3a7a72fa88
5 changed files with 106 additions and 1 deletions

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`);impact preview 已按 `muse_knowledge_source_binding_projection.owner_user_id` 输出 installed/source-binding owner 审计计数(2026-06-19:`MuseKnowledgeBaseServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 30/30);source binding projection 回填闭环已修(2026-06-19:绑定确认为权威回填点写读模型行、unbind 按 bindingId 作用域撤销不误伤同源跨作品投影、唯一键补 work_id 修跨作品复用误判冲突V24;`MuseKnowledgeBindingServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 11/11、整套件 214/214 绿);**读回端到端坐实 + unbind 真软删修复(2026-06-19)**:嵌入式 DB(H2)往返证 bind 写投影→`selectActiveByWorkId` 读回该来源、unbind 后读回消失、同源他作品投影存活;并修 unbind 软删真 bug——`deleted` 是 `@TableLogic`,实体 `setDeleted(true)` 被 MP 普通 update 剥离成空操作,改 `setSql("deleted = true")` 才真软删(`MuseKnowledgeSourceBindingProjectionRoundTripTest` 2/2、整套件 216/216 绿;新增 knowledge 模块首套嵌入式 DB 测试基建)。
- **现状**:只读评估 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);source binding projection 回填闭环已修(2026-06-19:绑定确认为权威回填点写读模型行、unbind 按 bindingId 作用域撤销不误伤同源跨作品投影、唯一键补 work_id 修跨作品复用误判冲突V24;`MuseKnowledgeBindingServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 11/11、整套件 214/214 绿);**读回端到端坐实 + unbind 真软删修复(2026-06-19)**:嵌入式 DB(H2)往返证 bind 写投影→`selectActiveByWorkId` 读回该来源、unbind 后读回消失、同源他作品投影存活;并修 unbind 软删真 bug——`deleted` 是 `@TableLogic`,实体 `setDeleted(true)` 被 MP 普通 update 剥离成空操作,改 `setSql("deleted = true")` 才真软删(`MuseKnowledgeSourceBindingProjectionRoundTripTest` 2/2、整套件 216/216 绿;新增 knowledge 模块首套嵌入式 DB 测试基建)。**GET bindings 读端点已补(2026-06-19)**:`AppMuseKnowledgeBindingController` 加 `GET /muse/works/{workId}/knowledge-bindings`→`listKnowledgeBindings`(requireWorkOwner 防 IDOR + selectActiveByWorkId 读回投影,id→String;happy-path + 越权 fail-closed 单测,整套件 218/218 绿);**bindings 读回后端腿(写→读→端点)已闭环,仅余 FE hook**。
- **关键风险 / TODO**:前端仅约 1/3 面(草稿确认/绑定/发布/图谱缺 hook);Task4 剩余 owner 缺口已收窄为 **document owner count / export_task_owner** 仍不能伪造,需后续 owner 事实或外部服务接入;recheck/worker 默认未生效。

View File

@ -198,6 +198,38 @@ public class MuseKnowledgeBindingService {
return respVO;
}
/**
* 按作品读回当前生效的来源绑定local-knowledge.sourceBindings 之外的专用读回端口
*
* <p>读模型 {@code muse_knowledge_source_binding_projection} 由绑定确认权威回填unbind 软删此处按 work 读回
* 未删{@code deleted=false}的来源绑定供前端展示本作品已绑定哪些知识来源</p>
*/
public AppKnowledgeBindingVO.BindingListRespVO listKnowledgeBindings(Long loginUserId, String apiVersion,
Long workId) {
MuseKnowledgeApiVersionGuard.requireVersion(apiVersion);
// 读回同样过信任边界校验作品归属防越权读他人作品的来源绑定IDOR
workOwnerFacade.requireWorkOwner(workId, loginUserId);
List<MuseKnowledgeSourceBindingProjectionDO> rows = sourceBindingProjectionMapper.selectActiveByWorkId(workId);
List<AppKnowledgeBindingVO.BindingItemVO> items = new ArrayList<>(rows.size());
for (MuseKnowledgeSourceBindingProjectionDO row : rows) {
AppKnowledgeBindingVO.BindingItemVO item = new AppKnowledgeBindingVO.BindingItemVO();
// id 一律转字符串避免前端 JS 大整数精度丢失 BindRespVO 约定一致
item.setBindingId(row.getBindingId() == null ? null : String.valueOf(row.getBindingId()));
item.setKbId(row.getKbId() == null ? null : String.valueOf(row.getKbId()));
item.setSourceOwner(row.getSourceOwner());
item.setSourceType(row.getSourceType());
item.setSourceId(row.getSourceId());
item.setSourceRevision(row.getSourceRevision());
item.setStatus(row.getStatus());
item.setActionPolicy(row.getActionPolicy());
item.setCheckedAt(row.getCheckedAt() == null ? null : row.getCheckedAt().toString());
items.add(item);
}
AppKnowledgeBindingVO.BindingListRespVO resp = new AppKnowledgeBindingVO.BindingListRespVO();
resp.setBindings(items);
return resp;
}
private void writeSourceBindingProjection(Long ownerUserId, Long workId, MuseKnowledgeBindingDO binding,
MuseKnowledgeBindPrecheckDO precheck, Map<String, Object> summary,
List<String> purposes) {

View File

@ -11,6 +11,7 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@ -32,6 +33,16 @@ public class AppMuseKnowledgeBindingController {
@Resource
private MuseKnowledgeBindingService bindingService;
@GetMapping
@Operation(summary = "查询作品已绑定的知识来源")
public CommonResult<AppKnowledgeBindingVO.BindingListRespVO> getKnowledgeBindings(
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
@PathVariable Long workId) {
MuseKnowledgeApiVersionGuard.requireVersion(apiVersion);
return success(bindingService.listKnowledgeBindings(SecurityFrameworkUtils.getLoginUserId(),
apiVersion, workId));
}
@PostMapping("/prechecks")
@Operation(summary = "目标 owner 绑定预检")
public CommonResult<AppKnowledgeBindingVO.PrecheckRespVO> createKnowledgeBindingPrecheck(

View File

@ -92,4 +92,22 @@ public final class AppKnowledgeBindingVO {
private String status;
}
@Data
public static class BindingListRespVO {
private List<BindingItemVO> bindings;
}
@Data
public static class BindingItemVO {
private String bindingId;
private String kbId;
private String sourceOwner;
private String sourceType;
private String sourceId;
private String sourceRevision;
private String status;
private String actionPolicy;
private String checkedAt;
}
}

View File

@ -30,6 +30,7 @@ import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDG
import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDGE_CONFIRMATION_REQUIRED;
import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDGE_INVALID_PAGE_PARAM;
import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDGE_MARKET_HANDOFF_UNAVAILABLE;
import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDGE_RESOURCE_FORBIDDEN;
import static cn.iocoder.muse.module.knowledge.enums.ErrorCodeConstants.KNOWLEDGE_SOURCE_STALE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@ -37,6 +38,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ -335,6 +337,48 @@ class MuseKnowledgeBindingServiceTest extends BaseMockitoUnitTest {
verify(bindingMapper, never()).updateById(any(MuseKnowledgeBindingDO.class));
}
@Test
void should_listKnowledgeBindingsByWorkFromProjectionWithStringifiedIds() {
MuseKnowledgeSourceBindingProjectionDO row = new MuseKnowledgeSourceBindingProjectionDO();
row.setBindingId(6001L);
row.setKbId(4001L);
row.setSourceOwner("market");
row.setSourceType("market_kb");
row.setSourceId("4001");
row.setSourceRevision("3");
row.setStatus("active");
row.setActionPolicy("allowed");
row.setCheckedAt(LocalDateTime.of(2026, 6, 19, 10, 0));
when(sourceBindingProjectionMapper.selectActiveByWorkId(9001L)).thenReturn(List.of(row));
AppKnowledgeBindingVO.BindingListRespVO resp = bindingService.listKnowledgeBindings(2001L, "1", 9001L);
// 读回也必须先过作品归属信任边界IDOR 防护
verify(workOwnerFacade).requireWorkOwner(9001L, 2001L);
assertEquals(1, resp.getBindings().size());
AppKnowledgeBindingVO.BindingItemVO item = resp.getBindings().get(0);
assertEquals("6001", item.getBindingId()); // LongString 契约防前端大整数精度丢失
assertEquals("4001", item.getKbId());
assertEquals("market", item.getSourceOwner());
assertEquals("market_kb", item.getSourceType());
assertEquals("4001", item.getSourceId());
assertEquals("3", item.getSourceRevision());
assertEquals("active", item.getStatus());
assertEquals("allowed", item.getActionPolicy());
assertEquals("2026-06-19T10:00", item.getCheckedAt());
}
@Test
void should_failClosedAndNotReadWhenWorkOwnerCheckRejectsBindingList() {
// 越权作品不归属当前用户 requireWorkOwner 抛错读回必须 fail-closed 且绝不查询投影
doThrow(new ServiceException(KNOWLEDGE_RESOURCE_FORBIDDEN))
.when(workOwnerFacade).requireWorkOwner(9001L, 2002L);
assertThrows(ServiceException.class, () -> bindingService.listKnowledgeBindings(2002L, "1", 9001L));
verify(sourceBindingProjectionMapper, never()).selectActiveByWorkId(any());
}
private static AppKnowledgeBindingVO.PrecheckReqVO precheckReq(String sourceType, String sourceStatus,
List<String> purposes) {
AppKnowledgeBindingVO.PrecheckReqVO reqVO = new AppKnowledgeBindingVO.PrecheckReqVO();