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:
parent
cac227223f
commit
3a7a72fa88
@ -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 默认未生效。
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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()); // Long→String 契约,防前端大整数精度丢失
|
||||
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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user