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:
lili 2026-06-22 12:30:50 -07:00
parent d42e2b8be3
commit 65f949c997
9 changed files with 272 additions and 19 deletions

View File

@ -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 凭据不可用");
}

View File

@ -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>
<!-- 业务组件 -->

View File

@ -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);
/**

View File

@ -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())

View File

@ -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")

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);

View 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;