feat(handoff): P2 agent 放宽接纳 market 来源 + verify/consume(后端)
跨空间 handoff(market→agent)兑现侧:放宽 requireVisibleActiveSourceAgent 的 agentType 校验仅接纳 verify 通过的 market token(precheck+bind 两处);precheck 阶段 verify(targetOwner= agent)+consume(token 用完即弃)、落 source_owner/handoff_hash;bind 凭 precheck.source_owner= market 识别放宽路径(不重复核验、凭 precheckId 信任)。保护节点/work 属主/agent-version active/ 非 handoff 原校验全不放宽。 - ai-server pom +market-api;V27 加 source_owner/handoff_hash + muse_agent.source_market_asset_id(可空) - AgentSlotPrecheckReqVO +sourceType/handoffToken/sourceId/sourceVersion/authorizationSummaryId - ErrorCodeConstants +AI_MARKET_HANDOFF_UNAVAILABLE;precheckAgentSlot 签名 +apiVersion(consume 需要) 验证:MuseAgentSlotServiceTest 25/0(含 5 P2 红线单测:正路放宽接纳/verify 拒/无 token 拒/ 非 handoff 维持 system-user 校验/bind 凭 source_owner 放宽);Bc+AiGrantRuntime ArchUnit 3/0 (ai 依赖 market-api 合法、consume 在 application 层不破 runtime 隔离)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d42e2b8be3
commit
65f949c997
@ -90,5 +90,7 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode AI_AGENT_SLOT_PRECHECK_EXPIRED = new ErrorCode(1_040_100_021, "AI Agent 槽位预检已过期");
|
||||
ErrorCode AI_AUDIT_LOG_NOT_EXISTS = new ErrorCode(1_040_100_022, "AI 审计接口日志不存在");
|
||||
ErrorCode AI_AUDIT_EVENT_NOT_EXISTS = new ErrorCode(1_040_100_023, "AI 业务审计事件不存在");
|
||||
// 跨空间 handoff(market→agent):Market 一次性 token 核验失败(不存在/非属主/已过期/已消费/owner-action 不匹配)
|
||||
ErrorCode AI_MARKET_HANDOFF_UNAVAILABLE = new ErrorCode(1_040_100_024, "AI Market handoff 凭据不可用");
|
||||
|
||||
}
|
||||
|
||||
@ -64,6 +64,12 @@
|
||||
<artifactId>muse-module-content-server</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<!-- handoff 兑现:核验/核销 Market 一次性 token(MarketHandoffTokenApi,跨空间安全交接 P2) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>muse-module-market-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 业务组件 -->
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ public interface MuseAgentSlotService {
|
||||
/**
|
||||
* 创建短期槽位替换预检凭证。
|
||||
*/
|
||||
AgentSlotPrecheckRespVO precheckAgentSlot(Long ownerUserId, Long workId, String slotKey,
|
||||
AgentSlotPrecheckRespVO precheckAgentSlot(Long ownerUserId, String apiVersion, Long workId, String slotKey,
|
||||
AgentSlotPrecheckReqVO request);
|
||||
|
||||
/**
|
||||
|
||||
@ -18,12 +18,20 @@ import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentSlotBindingMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentSlotPrecheckMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentVersionMapper;
|
||||
import cn.iocoder.muse.module.market.api.handoff.MarketHandoffTokenApi;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffConsumeReqDTO;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyReqDTO;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyRespDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
@ -33,6 +41,7 @@ import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_SCOPE_
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_SLOT_PRECHECK_EXPIRED;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_SLOT_PROTECTED;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_SLOT_REVISION_CONFLICT;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_MARKET_HANDOFF_UNAVAILABLE;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_RESOURCE_FORBIDDEN;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_SOURCE_BLOCKED;
|
||||
|
||||
@ -47,6 +56,9 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
private static final String TARGET_TYPE_AGENT_SLOT = "agentSlot";
|
||||
private static final String STATUS_PENDING = "pending";
|
||||
private static final String STATUS_ACTIVE = "active";
|
||||
// 跨空间 handoff(market→agent):来源类型标识 + precheck 落库的来源 owner 标记(bind 凭此识别 market 放宽路径)
|
||||
private static final String SOURCE_TYPE_MARKET_AGENT = "market_agent";
|
||||
private static final String SOURCE_OWNER_MARKET = "market";
|
||||
|
||||
@Resource
|
||||
private MuseAgentSlotBindingMapper slotBindingMapper;
|
||||
@ -62,6 +74,8 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
private MuseAiAuditService auditService;
|
||||
@Resource
|
||||
private MuseContentWorkOwnerFacade workOwnerFacade;
|
||||
@Resource
|
||||
private MarketHandoffTokenApi marketHandoffTokenApi;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
@ -72,7 +86,7 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AgentSlotPrecheckRespVO precheckAgentSlot(Long ownerUserId, Long workId, String slotKey,
|
||||
public AgentSlotPrecheckRespVO precheckAgentSlot(Long ownerUserId, String apiVersion, Long workId, String slotKey,
|
||||
AgentSlotPrecheckReqVO request) {
|
||||
String requestHash = commandService.buildRequestHash(request);
|
||||
MuseAiCommandService.CommandEnvelope envelope = commandEnvelope(request.getCommandId(), OPERATION_PRECHECK,
|
||||
@ -88,12 +102,23 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
requireSlotBinding(binding);
|
||||
requireRevision(binding, request.getExpectedSlotRevision());
|
||||
rejectBlockedSource(request.getAuthorizationSnapshotId());
|
||||
requireVisibleActiveSourceAgent(ownerUserId, request.getSourceAgentId(), request.getSourceAgentVersion());
|
||||
|
||||
AgentSlotPrecheckRespVO response = new AgentSlotPrecheckRespVO();
|
||||
response.setAgentSlotPrecheckId("precheck-" + UUID.randomUUID());
|
||||
response.setExpiresAt(LocalDateTime.now().plusMinutes(10));
|
||||
|
||||
// market_agent 跨空间来源:服务端核验 + 核销 Market 一次性 handoff token(token 明文仅 precheck 阶段持有);
|
||||
// verify/consume 沿用本方法 @Transactional,与 precheck 落库原子(失败一并回滚)。非 handoff 来源不走此分支、维持原校验。
|
||||
boolean marketHandoff = isMarketAgentSource(request.getSourceType());
|
||||
if (marketHandoff) {
|
||||
validateMarketHandoff(request);
|
||||
consumeMarketHandoff(request, apiVersion, workId, response.getAgentSlotPrecheckId());
|
||||
}
|
||||
// 来源可见性校验:market handoff(token 已被 Market verify)放宽 agentType 可见性、接纳 market 类型 agent;
|
||||
// 非 handoff 维持 system/user 校验。agent/version active 不论来源都校验,不放宽。
|
||||
requireVisibleActiveSourceAgent(ownerUserId, request.getSourceAgentId(), request.getSourceAgentVersion(),
|
||||
marketHandoff);
|
||||
|
||||
MuseAgentSlotPrecheckDO precheck = new MuseAgentSlotPrecheckDO();
|
||||
precheck.setPrecheckId(response.getAgentSlotPrecheckId());
|
||||
precheck.setCommandId(request.getCommandId());
|
||||
@ -109,6 +134,9 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
precheck.setResultSummary(JsonUtils.toJsonString(response));
|
||||
precheck.setOwnerUserId(ownerUserId);
|
||||
precheck.setActorUserId(ownerUserId);
|
||||
// 落库 source_owner/handoff_hash:bind 阶段凭 source_owner=market 识别放宽路径;handoff_hash 仅审计(明文不入库)
|
||||
precheck.setSourceOwner(marketHandoff ? SOURCE_OWNER_MARKET : null);
|
||||
precheck.setHandoffHash(marketHandoff ? hashNullable(request.getHandoffToken()) : null);
|
||||
precheck.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
precheckMapper.insert(precheck);
|
||||
|
||||
@ -143,7 +171,11 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
}
|
||||
requirePrecheckMatchesBindRequest(precheck, request);
|
||||
rejectBlockedSource(precheck.getAuthorizationSnapshotId());
|
||||
requireVisibleActiveSourceAgent(ownerUserId, precheck.getSourceAgentId(), precheck.getSourceAgentVersion());
|
||||
// bind 凭 precheck.sourceOwner 识别 market 放宽路径:token 已在 precheck 阶段核销,bind 不重复核验,
|
||||
// 凭 precheckId(悲观锁 + owner/work/slot 三维校验)信任来源;非 market 维持原 agentType 校验。
|
||||
boolean marketHandoff = SOURCE_OWNER_MARKET.equals(precheck.getSourceOwner());
|
||||
requireVisibleActiveSourceAgent(ownerUserId, precheck.getSourceAgentId(), precheck.getSourceAgentVersion(),
|
||||
marketHandoff);
|
||||
|
||||
MuseAgentSlotBindingDO binding = slotBindingMapper.selectActiveByWorkAndSlotForUpdate(
|
||||
TenantContextHolder.getRequiredTenantId(), workId, slotKey);
|
||||
@ -218,16 +250,20 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireVisibleActiveSourceAgent(Long ownerUserId, Long sourceAgentId, Integer sourceAgentVersion) {
|
||||
private void requireVisibleActiveSourceAgent(Long ownerUserId, Long sourceAgentId, Integer sourceAgentVersion,
|
||||
boolean marketHandoff) {
|
||||
MuseAgentDO agent = agentMapper.selectById(sourceAgentId);
|
||||
if (agent == null) {
|
||||
throw new ServiceException(AI_AGENT_NOT_EXISTS);
|
||||
}
|
||||
// Slot 绑定只允许当前用户可见的 active Agent 与 active Version,避免历史 precheck 被跨用户或停用资源消费。
|
||||
// Slot 绑定只允许 active Agent 与 active Version,避免历史 precheck 被跨用户或停用资源消费。
|
||||
if (!STATUS_ACTIVE.equals(agent.getStatus())) {
|
||||
throw new ServiceException(AI_AGENT_SCOPE_FORBIDDEN);
|
||||
}
|
||||
if (!"system".equals(agent.getAgentType())
|
||||
// 跨空间 handoff 来源(token 已被 Market verify 通过)放宽 agentType 可见性:接纳 market 类型 agent;
|
||||
// 非 handoff 路径维持原校验(system 全可见 / user 仅属主),只开"合法 handoff 来源"这一道、不普遍放开 scope。
|
||||
if (!marketHandoff
|
||||
&& !"system".equals(agent.getAgentType())
|
||||
&& (!"user".equals(agent.getAgentType()) || !ownerUserId.equals(agent.getOwnerUserId()))) {
|
||||
throw new ServiceException(AI_AGENT_SCOPE_FORBIDDEN);
|
||||
}
|
||||
@ -254,6 +290,48 @@ public class MuseAgentSlotServiceImpl implements MuseAgentSlotService {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isMarketAgentSource(String sourceType) {
|
||||
return SOURCE_TYPE_MARKET_AGENT.equals(sourceType);
|
||||
}
|
||||
|
||||
/**
|
||||
* market_agent 来源必带 handoff token:token 是跨空间唯一合法入口,缺失即拒(不进入 verify/放宽)。
|
||||
*/
|
||||
private void validateMarketHandoff(AgentSlotPrecheckReqVO request) {
|
||||
if (request.getHandoffToken() == null || request.getHandoffToken().isBlank()) {
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* market_agent 来源:服务端核验 handoff token(属主/未过期/未消费/未取消 + owner-action 匹配)并核销(消费换 session)。
|
||||
* 替代"信任客户端来源 agent"——红线("不信任客户端 URL 参数")在 agent 兑现侧落地的关键一环。
|
||||
* 沿用调用方 @Transactional 事务,verify/consume 与 precheck 落库原子(失败一并回滚);token 明文仅 precheck 阶段持有。
|
||||
*/
|
||||
private void consumeMarketHandoff(AgentSlotPrecheckReqVO request, String apiVersion, Long workId, String precheckId) {
|
||||
HandoffVerifyRespDTO verify = marketHandoffTokenApi.verify(
|
||||
new HandoffVerifyReqDTO(request.getHandoffToken(), "agent", "bind"));
|
||||
if (!verify.valid()) {
|
||||
// token 不存在/非属主/已过期/已消费/owner-action 不匹配 → 阻断兑现,不放宽、不写任何绑定事实
|
||||
throw new ServiceException(AI_MARKET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
// 核销 token(pending→completed);bindingRef 用 precheckId,目标事实引用对齐 knowledge
|
||||
marketHandoffTokenApi.consume(new HandoffConsumeReqDTO(request.getHandoffToken(), request.getCommandId(),
|
||||
apiVersion, verify.status(), workId, precheckId));
|
||||
}
|
||||
|
||||
private String hashNullable(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePrecheckMatchesBindRequest(MuseAgentSlotPrecheckDO precheck, AgentSlotBindReqVO request) {
|
||||
if (!Objects.equals(precheck.getSourceAgentId(), request.getSourceAgentId())
|
||||
|| !Objects.equals(precheck.getSourceAgentVersion(), request.getSourceAgentVersion())
|
||||
|
||||
@ -59,7 +59,7 @@ public class AppMuseAgentSlotController {
|
||||
@PathVariable String slotKey,
|
||||
@Valid @RequestBody AgentSlotPrecheckReqVO request) {
|
||||
MuseAiApiVersionGuard.requireVersion(apiVersion);
|
||||
return success(slotService.precheckAgentSlot(getLoginUserId(), workId, slotKey, request));
|
||||
return success(slotService.precheckAgentSlot(getLoginUserId(), apiVersion, workId, slotKey, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{slotKey}/bind")
|
||||
|
||||
@ -24,4 +24,20 @@ public class AgentSlotPrecheckReqVO {
|
||||
private String authorizationSnapshotId;
|
||||
private Integer expectedSlotRevision;
|
||||
|
||||
// —— 跨空间 handoff(market→agent)来源字段;sourceType=market_agent 时由 service 层校验 handoffToken 必填 ——
|
||||
@Schema(description = "来源类型:同空间(user/system,默认)或 market_agent(跨空间 handoff)")
|
||||
private String sourceType;
|
||||
|
||||
@Schema(description = "Market 一次性 handoff token(sourceType=market_agent 时必填;明文仅 precheck 阶段瞬时持有)")
|
||||
private String handoffToken;
|
||||
|
||||
@Schema(description = "来源资产 ID(market_agent 来源)")
|
||||
private String sourceId;
|
||||
|
||||
@Schema(description = "来源版本(market_agent 来源)")
|
||||
private Integer sourceVersion;
|
||||
|
||||
@Schema(description = "授权摘要 ID(market_agent 来源)")
|
||||
private String authorizationSummaryId;
|
||||
|
||||
}
|
||||
|
||||
@ -40,5 +40,9 @@ public class MuseAgentSlotPrecheckDO extends TenantBaseDO {
|
||||
private String resultSummary;
|
||||
private Long ownerUserId;
|
||||
private Long actorUserId;
|
||||
/** 兑现来源标记(market=跨空间 handoff,bind 凭此识别 market 放宽路径;null=同空间原校验路径) */
|
||||
private String sourceOwner;
|
||||
/** Market handoff token 的 sha256(审计;明文不入库) */
|
||||
private String handoffHash;
|
||||
|
||||
}
|
||||
|
||||
@ -19,6 +19,9 @@ import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentSlotBindingMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentSlotPrecheckMapper;
|
||||
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAgentVersionMapper;
|
||||
import cn.iocoder.muse.module.market.api.handoff.MarketHandoffTokenApi;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyReqDTO;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyRespDTO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
@ -33,6 +36,7 @@ import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_SLOT_P
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_SLOT_REVISION_CONFLICT;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_NOT_EXISTS;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_AGENT_SCOPE_FORBIDDEN;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_MARKET_HANDOFF_UNAVAILABLE;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_RESOURCE_FORBIDDEN;
|
||||
import static cn.iocoder.muse.module.ai.enums.ErrorCodeConstants.AI_SOURCE_BLOCKED;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@ -68,6 +72,8 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
private MuseAiAuditService auditService;
|
||||
@Mock
|
||||
private MuseContentWorkOwnerFacade workOwnerFacade;
|
||||
@Mock
|
||||
private MarketHandoffTokenApi marketHandoffTokenApi;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
@ -114,7 +120,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
doThrow(new ServiceException(AI_RESOURCE_FORBIDDEN)).when(workOwnerFacade).requireWorkOwner(9001L, 10001L);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, 9001L, "writer", request));
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request));
|
||||
|
||||
assertEquals(AI_RESOURCE_FORBIDDEN.getCode(), exception.getCode());
|
||||
verify(commandService).reserveCommand(any());
|
||||
@ -146,7 +152,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "protected:core")).thenReturn(binding);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, 9001L, "protected:core", request));
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L,"protected:core", request));
|
||||
|
||||
assertEquals(AI_AGENT_SLOT_PROTECTED.getCode(), exception.getCode());
|
||||
verify(commandService).reserveCommand(any());
|
||||
@ -160,7 +166,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "writer")).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, 9001L, "writer", request));
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request));
|
||||
|
||||
assertEquals(AI_RESOURCE_FORBIDDEN.getCode(), exception.getCode());
|
||||
verify(commandService).reserveCommand(any());
|
||||
@ -176,7 +182,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "writer")).thenReturn(binding);
|
||||
|
||||
MuseAgentSlotConflictException exception = assertThrows(MuseAgentSlotConflictException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, 9001L, "writer", request));
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request));
|
||||
|
||||
assertEquals(AI_AGENT_SLOT_REVISION_CONFLICT.getCode(), exception.getServiceException().getCode());
|
||||
verify(commandService).reserveCommand(any());
|
||||
@ -192,7 +198,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "writer")).thenReturn(binding);
|
||||
|
||||
MuseAgentSlotConflictException exception = assertThrows(MuseAgentSlotConflictException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, 9001L, "writer", request));
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request));
|
||||
|
||||
assertEquals(AI_SOURCE_BLOCKED.getCode(), exception.getServiceException().getCode());
|
||||
verify(commandService).reserveCommand(any());
|
||||
@ -208,7 +214,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
when(agentMapper.selectById(1001L)).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, 9001L, "writer", request));
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request));
|
||||
|
||||
assertEquals(AI_AGENT_NOT_EXISTS.getCode(), exception.getCode());
|
||||
verify(commandService).reserveCommand(any());
|
||||
@ -225,7 +231,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "别人的助手", "user", 10002L, "active"));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, 9001L, "writer", request));
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request));
|
||||
|
||||
assertEquals(AI_AGENT_SCOPE_FORBIDDEN.getCode(), exception.getCode());
|
||||
verify(commandService).reserveCommand(any());
|
||||
@ -242,7 +248,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "停用助手", "system", null, "inactive"));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, 9001L, "writer", request));
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request));
|
||||
|
||||
assertEquals(AI_AGENT_SCOPE_FORBIDDEN.getCode(), exception.getCode());
|
||||
verify(commandService).reserveCommand(any());
|
||||
@ -261,7 +267,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
when(agentVersionMapper.selectByAgentIdAndVersion(1001L, "2")).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, 9001L, "writer", request));
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request));
|
||||
|
||||
assertEquals(AI_AGENT_NOT_EXISTS.getCode(), exception.getCode());
|
||||
verify(commandService).reserveCommand(any());
|
||||
@ -280,7 +286,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-precheck");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
|
||||
AgentSlotPrecheckRespVO result = slotService.precheckAgentSlot(10001L, 9001L, "writer", request);
|
||||
AgentSlotPrecheckRespVO result = slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request);
|
||||
|
||||
verify(precheckMapper).insert(argThat((MuseAgentSlotPrecheckDO precheck) ->
|
||||
precheck.getPrecheckId().equals(result.getAgentSlotPrecheckId())
|
||||
@ -300,7 +306,7 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-precheck");
|
||||
when(commandService.reserveCommand(any())).thenReturn(command(JsonUtils.toJsonString(replay)));
|
||||
|
||||
AgentSlotPrecheckRespVO result = slotService.precheckAgentSlot(10001L, 9001L, "writer", request);
|
||||
AgentSlotPrecheckRespVO result = slotService.precheckAgentSlot(10001L, "1", 9001L,"writer", request);
|
||||
|
||||
assertEquals("precheck-1", result.getAgentSlotPrecheckId());
|
||||
verify(workOwnerFacade, never()).requireWorkOwner(any(), any());
|
||||
@ -438,6 +444,136 @@ class MuseAgentSlotServiceTest extends BaseMockitoUnitTest {
|
||||
verify(agentVersionMapper, never()).selectByAgentIdAndVersion(any(), any());
|
||||
}
|
||||
|
||||
// ============ P2 跨空间 handoff(market→agent)放宽红线单测 ============
|
||||
|
||||
@Test
|
||||
void should_acceptMarketAgentSourceWhenHandoffTokenVerified() {
|
||||
// 正路:market 来源 + token 经 Market verify 通过 → 放宽 agentType(market 类型本会被拒)+ consume token + 落 source_owner=market
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentSlotPrecheckReqVO request = marketPrecheckRequest("cmd-mkt-1", 3, "handoff_ok");
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "writer"))
|
||||
.thenReturn(binding(9001L, "writer", 1001L, "1", 3, "active"));
|
||||
// market 类型 agent:非 system/非 user → 原 agentType 校验必拒,只有放宽才接纳
|
||||
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "市场智能体", "market", null, "active"));
|
||||
when(agentVersionMapper.selectByAgentIdAndVersion(1001L, "1"))
|
||||
.thenReturn(agentVersion(901L, 1001L, "1", "active"));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-mkt");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(marketHandoffTokenApi.verify(any())).thenReturn(validVerify());
|
||||
|
||||
AgentSlotPrecheckRespVO result = slotService.precheckAgentSlot(10001L, "1", 9001L, "writer", request);
|
||||
|
||||
// verify 走 agent/bind owner-action;consume 核销;落库 source_owner=market + handoff_hash 非空(明文不入库)
|
||||
verify(marketHandoffTokenApi).verify(argThat((HandoffVerifyReqDTO v) ->
|
||||
"handoff_ok".equals(v.handoffToken()) && "agent".equals(v.expectedTargetOwner())
|
||||
&& "bind".equals(v.expectedTargetAction())));
|
||||
verify(marketHandoffTokenApi).consume(any());
|
||||
verify(precheckMapper).insert(argThat((MuseAgentSlotPrecheckDO p) ->
|
||||
"market".equals(p.getSourceOwner()) && p.getHandoffHash() != null
|
||||
&& p.getPrecheckId().equals(result.getAgentSlotPrecheckId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectMarketAgentSourceWhenHandoffTokenInvalid() {
|
||||
// 负路:market 来源 + token 被 Market verify 拒 → AI_MARKET_HANDOFF_UNAVAILABLE,不 consume、不落库、不放宽
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentSlotPrecheckReqVO request = marketPrecheckRequest("cmd-mkt-2", 3, "handoff_forged");
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "writer"))
|
||||
.thenReturn(binding(9001L, "writer", 1001L, "1", 3, "active"));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-mkt");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(marketHandoffTokenApi.verify(any())).thenReturn(invalidVerify());
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L, "writer", request));
|
||||
|
||||
assertEquals(AI_MARKET_HANDOFF_UNAVAILABLE.getCode(), exception.getCode());
|
||||
verify(marketHandoffTokenApi, never()).consume(any());
|
||||
verify(precheckMapper, never()).insert(any(MuseAgentSlotPrecheckDO.class));
|
||||
verify(agentMapper, never()).selectById(any()); // verify 拒后不进 agent 可见性校验
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectMarketAgentSourceWhenHandoffTokenMissing() {
|
||||
// 负路:market 来源但缺 handoffToken → validateMarketHandoff 拒,不调 verify、不落库
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentSlotPrecheckReqVO request = marketPrecheckRequest("cmd-mkt-3", 3, null);
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "writer"))
|
||||
.thenReturn(binding(9001L, "writer", 1001L, "1", 3, "active"));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-mkt");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L, "writer", request));
|
||||
|
||||
assertEquals(AI_MARKET_HANDOFF_UNAVAILABLE.getCode(), exception.getCode());
|
||||
verify(marketHandoffTokenApi, never()).verify(any());
|
||||
verify(precheckMapper, never()).insert(any(MuseAgentSlotPrecheckDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectMarketTypeAgentWhenNotHandoffSource() {
|
||||
// 关键:非 handoff 来源(无 sourceType=market_agent)绑 market 类型 agent → 维持原校验 AI_AGENT_SCOPE_FORBIDDEN;
|
||||
// 证放宽只开"合法 handoff"一道、不普遍放开 market 类型 agent
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentSlotPrecheckReqVO request = precheckRequest("cmd-plain", 3); // 无 sourceType → 非 handoff
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlot(9001L, "writer"))
|
||||
.thenReturn(binding(9001L, "writer", 1001L, "1", 3, "active"));
|
||||
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "市场智能体", "market", null, "active"));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-plain");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> slotService.precheckAgentSlot(10001L, "1", 9001L, "writer", request));
|
||||
|
||||
assertEquals(AI_AGENT_SCOPE_FORBIDDEN.getCode(), exception.getCode());
|
||||
verify(marketHandoffTokenApi, never()).verify(any()); // 非 handoff 不核验
|
||||
verify(precheckMapper, never()).insert(any(MuseAgentSlotPrecheckDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_acceptMarketAgentAtBindViaPrecheckSourceOwner() {
|
||||
// bind 阶段:precheck.source_owner=market → 放宽接纳 market 类型 agent(token 已在 precheck 核销,bind 不重复 verify、凭 precheckId 信任)
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AgentSlotBindReqVO request = bindRequest("cmd-bind-mkt", "precheck-mkt", 3);
|
||||
MuseAgentSlotPrecheckDO precheck = precheck("precheck-mkt", "pending", LocalDateTime.now().plusMinutes(5));
|
||||
precheck.setSourceOwner("market");
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash-bind");
|
||||
when(commandService.reserveCommand(any())).thenReturn(null);
|
||||
when(precheckMapper.selectPendingForUpdate(100L, "precheck-mkt")).thenReturn(precheck);
|
||||
when(agentMapper.selectById(1001L)).thenReturn(agent(1001L, "市场智能体", "market", null, "active"));
|
||||
when(agentVersionMapper.selectByAgentIdAndVersion(1001L, "1"))
|
||||
.thenReturn(agentVersion(901L, 1001L, "1", "active"));
|
||||
when(slotBindingMapper.selectActiveByWorkAndSlotForUpdate(100L, 9001L, "writer"))
|
||||
.thenReturn(binding(9001L, "writer", 1001L, "1", 3, "active"));
|
||||
|
||||
AgentSlotBindRespVO result = slotService.bindAgentSlot(10001L, 9001L, "writer", request);
|
||||
|
||||
assertEquals(4, result.getSlotRevision());
|
||||
verify(marketHandoffTokenApi, never()).verify(any()); // bind 不重复核验
|
||||
verify(slotBindingMapper).updateById(any(MuseAgentSlotBindingDO.class));
|
||||
}
|
||||
|
||||
private static AgentSlotPrecheckReqVO marketPrecheckRequest(String commandId, Integer expectedRevision,
|
||||
String handoffToken) {
|
||||
AgentSlotPrecheckReqVO request = precheckRequest(commandId, expectedRevision);
|
||||
request.setSourceType("market_agent");
|
||||
request.setHandoffToken(handoffToken);
|
||||
request.setSourceId("8001");
|
||||
request.setSourceVersion(1);
|
||||
request.setAuthorizationSummaryId("8001");
|
||||
return request;
|
||||
}
|
||||
|
||||
private static HandoffVerifyRespDTO validVerify() {
|
||||
return new HandoffVerifyRespDTO(true, "pending", "agent", "bind", "8001", 9001L, 8001L,
|
||||
LocalDateTime.now().plusMinutes(10), null);
|
||||
}
|
||||
|
||||
private static HandoffVerifyRespDTO invalidVerify() {
|
||||
return new HandoffVerifyRespDTO(false, null, null, null, null, null, null, null, "token_not_found");
|
||||
}
|
||||
|
||||
private static AgentSlotPrecheckReqVO precheckRequest(String commandId, Integer expectedRevision) {
|
||||
AgentSlotPrecheckReqVO request = new AgentSlotPrecheckReqVO();
|
||||
request.setCommandId(commandId);
|
||||
|
||||
11
muse-cloud/sql/muse/V27__extend_ai_agent_handoff_source.sql
Normal file
11
muse-cloud/sql/muse/V27__extend_ai_agent_handoff_source.sql
Normal file
@ -0,0 +1,11 @@
|
||||
-- P2 跨空间 handoff(market→agent):为 agent 槽位预检补 handoff 来源标记,使 bind 阶段凭 source_owner 识别 market 放宽路径;
|
||||
-- 为 muse_agent 补 market 来源溯源列。均为可空列、向后兼容(旧行 source_owner=null 走同空间原校验路径)。
|
||||
|
||||
-- 1) agent 槽位预检:source_owner 标记兑现来源(market=跨空间 handoff;bind 凭此放宽 agentType 校验);
|
||||
-- handoff_hash 存 token sha256(审计;token 明文不入库,与 knowledge handoff_hash 同口径)。
|
||||
ALTER TABLE muse_ai_agent_slot_precheck ADD COLUMN source_owner VARCHAR(32);
|
||||
ALTER TABLE muse_ai_agent_slot_precheck ADD COLUMN handoff_hash CHAR(64);
|
||||
|
||||
-- 2) muse_agent:market 上架 agent 物化时的来源资产溯源(独立 market 类型 agent;
|
||||
-- install 自动物化为独立后续主线,本列先就位、对齐 knowledge muse_knowledge_base.source_market_asset_id)。
|
||||
ALTER TABLE muse_agent ADD COLUMN source_market_asset_id BIGINT;
|
||||
Loading…
x
Reference in New Issue
Block a user