fix(market): 修 install 用户可见假绿——isAcquired/isInstalled/canInstall 按真实授权安装态 enrich

三路盘点发现 market install 头号用户可见假绿:install 后端真实已验(P1rMarketLicenseInstallCompletedApprovalIT 批18 绿),但 MarketAssetQueryServiceImpl 把 isAcquired/isInstalled 裸硬编 false(toCard:181-182 列表 + userActions:386-387 详情 + canInstall:383),致 studio MarketBrowse(assetType!=work && isAcquired && !isInstalled)安装按钮在真后端永不渲染,mock 伪造状态位才现身;批17 IT 把 isAcquired=false 断言为正确→假绿固化。

后端 enrich(读模型、未碰 install 写逻辑/授权语义):仿 isFavorite 范式加 isAcquired(active 授权)/isInstalled(installation)/isInstallable(类型集),与 install 写端口 MarketInstallServiceImpl 严格同源(selectActiveByOwnerAndAsset + INSTALLABLE_ASSET_TYPES={agent,knowledge_base}),避免"显示可装但装不了"新假绿;canInstall=listed && isInstallable && acquired && !installed(对齐 studio assetType!=work 门);批量加载(整页 2 次 IN vs 3N)避免放大 isFavorite 既有 N+1;mapper 加 selectActiveByOwnerAndAssetIds/existsByUserAndAsset/selectByUserAndAssetIds;批17 IT 改三态断言+订正裸写注释。

前端 MarketBrowse 补 onError(install/acquire 失败内联可见提示,对齐 AgentCreateForm/SlotBindingPanel,不再静默吞 ApiError);e2e 新增 market-install.spec(MSW-off:purchase 前置→断安装按钮真渲染→install→psql 验,连跑 2 次)+ global-setup #16 market 表复位。

活体真验(muse_slice_live,重启新 jar 10:08):purchase→isAcquired=T/canInstall=T(修前恒 false)→install→isInstalled=T/canInstall=F;独立 psql+GET 复核 asset1/4(owner1)isAcquired/isInstalled=true。批17 IT 5/5 三态绿(首跑遇 .m2 stale jar 假红、mvn install 刷新转真绿)、market 单测 9/9+11/11+7/7、UI e2e 连跑 2 次 passed。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-25 10:28:33 -07:00
parent 5c78a2adc4
commit 37e7a8dc27
9 changed files with 538 additions and 13 deletions

View File

@ -16,8 +16,10 @@ import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketGovernanceAct
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketGovernanceImpactDO;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketHandoffEventDO;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketInstallationDO;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAuthorizationSnapshotDO;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetMapper;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetVersionMapper;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSnapshotMapper;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSummaryMapper;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketFavoriteMapper;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketGovernanceActionMapper;
@ -52,11 +54,14 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
private static final String FALLBACK_POPULAR = "fallback_popular";
private static final String FALLBACK_NEWEST = "fallback_newest";
// 可安装的资产类型与 MarketInstallServiceImpl 写端口一致;作品(work)不走安装流程
private static final Set<String> INSTALLABLE_ASSET_TYPES = Set.of("agent", "knowledge_base");
private final MuseMarketAssetMapper assetMapper;
private final MuseMarketAssetVersionMapper assetVersionMapper;
private final MuseMarketFavoriteMapper favoriteMapper;
private final MuseMarketAuthorizationSummaryMapper authorizationSummaryMapper;
private final MuseMarketAuthorizationSnapshotMapper authorizationSnapshotMapper;
private final MuseMarketInstallationMapper installationMapper;
private final MuseMarketHandoffEventMapper handoffEventMapper;
private final MuseMarketGovernanceActionMapper governanceActionMapper;
@ -71,8 +76,10 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
PageResult<MuseMarketAssetDO> page = assetMapper.selectVisiblePage(pageParam, tenantId, loginUserId,
defaultSort(sortBy), assetType, category == null ? null : String.valueOf(category),
keyword, licenseType, status);
// 列表卡片需回填当前用户的获取/安装态一次性批量查全页授权与安装避免逐卡片放大 N+1
UserAssetState userState = loadUserAssetState(tenantId, loginUserId, page.getList());
List<MarketAssetCardRespVO> list = page.getList().stream()
.map(asset -> toCard(asset, currentVersion(asset), loginUserId, null))
.map(asset -> toCard(asset, currentVersion(asset), loginUserId, null, userState))
.toList();
log.info("[listAssets][Market 资产列表查询完成tenantId={}, loginUserId={}, total={}, sortBy={}]",
tenantId, loginUserId, page.getTotal(), defaultSort(sortBy));
@ -103,8 +110,10 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
List<MuseMarketAssetDO> assets = assetMapper.selectRecommendations(tenantId, loginUserId, fallbackSort, limit);
log.info("[listRecommendations][无独立推荐数据,使用 fallback 排序tenantId={}, loginUserId={}, context={}, reason={}]",
tenantId, loginUserId, recommendationContext, reason);
// 推荐卡片同样批量回填获取/安装态复用列表的批量加载逻辑
UserAssetState userState = loadUserAssetState(tenantId, loginUserId, assets);
return assets.stream()
.map(asset -> toCard(asset, currentVersion(asset), loginUserId, reason))
.map(asset -> toCard(asset, currentVersion(asset), loginUserId, reason, userState))
.toList();
}
@ -168,7 +177,7 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
}
private MarketAssetCardRespVO toCard(MuseMarketAssetDO asset, MuseMarketAssetVersionDO version,
Long loginUserId, String recommendationReason) {
Long loginUserId, String recommendationReason, UserAssetState userState) {
MarketAssetCardRespVO vo = new MarketAssetCardRespVO();
vo.setAssetId(String.valueOf(asset.getId()));
vo.setName(asset.getName());
@ -178,8 +187,9 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
vo.setSummary(summary(asset));
vo.setLicenseSummary(asset.getLicenseType());
vo.setListingStatus(normalizeListingStatus(asset.getListingStatus()));
vo.setAcquired(false);
vo.setInstalled(false);
// 获取/安装态来自批量加载的当前用户真实事实(active 授权 / 安装记录)不能再裸写 false
vo.setAcquired(userState.acquired(asset.getId()));
vo.setInstalled(userState.installed(asset.getId()));
vo.setFavorite(isFavorite(loginUserId, asset.getId()));
vo.setRecommendationReason(recommendationReason);
vo.setGovernanceAlert(governanceAlert(asset));
@ -378,13 +388,17 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
private MarketAssetDetailRespVO.AssetUserActions userActions(MuseMarketAssetDO asset, Long loginUserId) {
boolean listed = isListed(asset);
// 详情页是单资产直接按当前用户真实事实回填获取/安装态(active 授权 / 安装记录)
boolean acquired = isAcquired(loginUserId, asset.getId());
boolean installed = isInstalled(loginUserId, asset.getId());
MarketAssetDetailRespVO.AssetUserActions actions = new MarketAssetDetailRespVO.AssetUserActions();
actions.setCanAcquire(listed);
actions.setCanInstall(false);
// 安装门与写端口前置条件一致:资产 listed + 类型可安装(agent/knowledge_base) + 已获取 active 授权 + 尚未安装
actions.setCanInstall(listed && isInstallable(asset) && acquired && !installed);
actions.setCanBind(false);
actions.setCanFavorite(loginUserId != null && listed);
actions.setAcquired(false);
actions.setInstalled(false);
actions.setAcquired(acquired);
actions.setInstalled(installed);
actions.setFavorite(isFavorite(loginUserId, asset.getId()));
actions.setUnavailableReason(unavailableReason(asset, loginUserId));
MarketAssetDetailRespVO.AssetActionPolicy policy = new MarketAssetDetailRespVO.AssetActionPolicy();
@ -427,6 +441,69 @@ public class MarketAssetQueryServiceImpl implements MarketAssetQueryService {
return loginUserId != null && favoriteMapper.existsActive(TenantContextHolder.getRequiredTenantId(), loginUserId, assetId);
}
/**
* 当前用户是否对该资产持有 active 授权(已获取)
*
* <p>active 判定(owner_user_id + asset_id + status=active)与安装写端口
* {@code MarketInstallServiceImpl} 完全一致保证读模型与可安装前置条件不漂移</p>
*/
private boolean isAcquired(Long loginUserId, Long assetId) {
return loginUserId != null && authorizationSnapshotMapper
.selectActiveByOwnerAndAsset(TenantContextHolder.getRequiredTenantId(), loginUserId, assetId) != null;
}
/**
* 当前用户是否已安装该资产(不限版本)
*/
private boolean isInstalled(Long loginUserId, Long assetId) {
return loginUserId != null && installationMapper
.existsByUserAndAsset(TenantContextHolder.getRequiredTenantId(), loginUserId, assetId);
}
private boolean isInstallable(MuseMarketAssetDO asset) {
return INSTALLABLE_ASSET_TYPES.contains(asset.getAssetType());
}
/**
* 批量加载列表/推荐页中当前用户的获取(active 授权)与安装态
*
* <p>列表卡片在循环内回填这两个布尔位逐条单查会放大 N+1此处对整页 assetId 各发一次
* IN 查询得到两个 Set再交给 {@code toCard} 命中未登录或空列表直接返回空状态</p>
*/
private UserAssetState loadUserAssetState(Long tenantId, Long loginUserId, List<MuseMarketAssetDO> assets) {
if (loginUserId == null || assets.isEmpty()) {
return UserAssetState.empty();
}
List<Long> assetIds = assets.stream().map(MuseMarketAssetDO::getId).toList();
Set<Long> acquiredAssetIds = authorizationSnapshotMapper
.selectActiveByOwnerAndAssetIds(tenantId, loginUserId, assetIds).stream()
.map(MuseMarketAuthorizationSnapshotDO::getAssetId)
.collect(Collectors.toSet());
Set<Long> installedAssetIds = installationMapper
.selectByUserAndAssetIds(tenantId, loginUserId, assetIds).stream()
.map(MuseMarketInstallationDO::getAssetId)
.collect(Collectors.toSet());
return new UserAssetState(acquiredAssetIds, installedAssetIds);
}
/**
* 整页当前用户的获取/安装资产 id 集合供卡片回填命中避免逐卡片单查
*/
private record UserAssetState(Set<Long> acquiredAssetIds, Set<Long> installedAssetIds) {
private static UserAssetState empty() {
return new UserAssetState(Set.of(), Set.of());
}
private boolean acquired(Long assetId) {
return acquiredAssetIds.contains(assetId);
}
private boolean installed(Long assetId) {
return installedAssetIds.contains(assetId);
}
}
private String versionName(MuseMarketAssetVersionDO version) {
return version == null || !StringUtils.hasText(version.getVersion()) ? "unknown" : version.getVersion();
}

View File

@ -6,6 +6,8 @@ import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAuthorization
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* Market 授权快照 Mapper
*/
@ -27,6 +29,21 @@ public interface MuseMarketAuthorizationSnapshotMapper extends BaseMapperX<MuseM
.last("ORDER BY id DESC LIMIT 1"));
}
/**
* 批量查询当前用户对一批资产中持有 active 授权的那些资产
*
* <p>用于列表卡片回填 isAcquired避免每张卡片单独查授权放大 N+1active 判定与
* {@link #selectActiveByOwnerAndAsset} / 安装服务一致(owner_user_id + asset_id + status=active)</p>
*/
default List<MuseMarketAuthorizationSnapshotDO> selectActiveByOwnerAndAssetIds(Long tenantId, Long ownerUserId,
List<Long> assetIds) {
return selectList(new LambdaQueryWrapperX<MuseMarketAuthorizationSnapshotDO>()
.eq(MuseMarketAuthorizationSnapshotDO::getTenantId, tenantId)
.eq(MuseMarketAuthorizationSnapshotDO::getOwnerUserId, ownerUserId)
.in(MuseMarketAuthorizationSnapshotDO::getAssetId, assetIds)
.eq(MuseMarketAuthorizationSnapshotDO::getStatus, "active"));
}
/**
* 授权快照按业务授权键幂等写入
*

View File

@ -30,6 +30,29 @@ public interface MuseMarketInstallationMapper extends BaseMapperX<MuseMarketInst
.orderByDesc(MuseMarketInstallationDO::getId));
}
/**
* 判断当前用户对某资产是否存在安装事实(不限版本)用于详情页回填 isInstalled
*/
default boolean existsByUserAndAsset(Long tenantId, Long userId, Long assetId) {
return selectOne(new LambdaQueryWrapperX<MuseMarketInstallationDO>()
.eq(MuseMarketInstallationDO::getTenantId, tenantId)
.eq(MuseMarketInstallationDO::getUserId, userId)
.eq(MuseMarketInstallationDO::getAssetId, assetId)
.last("LIMIT 1")) != null;
}
/**
* 批量查询当前用户在一批资产中已安装的那些资产
*
* <p>用于列表卡片回填 isInstalled避免逐张卡片单独查安装事实放大 N+1</p>
*/
default List<MuseMarketInstallationDO> selectByUserAndAssetIds(Long tenantId, Long userId, List<Long> assetIds) {
return selectList(new LambdaQueryWrapperX<MuseMarketInstallationDO>()
.eq(MuseMarketInstallationDO::getTenantId, tenantId)
.eq(MuseMarketInstallationDO::getUserId, userId)
.in(MuseMarketInstallationDO::getAssetId, assetIds));
}
/**
* 同一用户同一资产版本只保留一条安装事实重复 command 通过 command envelope 回放
*/

View File

@ -9,8 +9,11 @@ import cn.iocoder.muse.module.market.controller.app.muse.vo.MarketAssetDetailRes
import cn.iocoder.muse.module.market.controller.app.muse.vo.MarketCategoriesRespVO;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAssetDO;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAssetVersionDO;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketAuthorizationSnapshotDO;
import cn.iocoder.muse.module.market.dal.dataobject.muse.MuseMarketInstallationDO;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetMapper;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAssetVersionMapper;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSnapshotMapper;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketAuthorizationSummaryMapper;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketFavoriteMapper;
import cn.iocoder.muse.module.market.dal.mysql.muse.MuseMarketGovernanceActionMapper;
@ -31,6 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
/**
@ -49,6 +53,8 @@ class MarketAssetQueryServiceTest extends BaseMockitoUnitTest {
@Mock
private MuseMarketAuthorizationSummaryMapper authorizationSummaryMapper;
@Mock
private MuseMarketAuthorizationSnapshotMapper authorizationSnapshotMapper;
@Mock
private MuseMarketInstallationMapper installationMapper;
@Mock
private MuseMarketHandoffEventMapper handoffEventMapper;
@ -150,12 +156,83 @@ class MarketAssetQueryServiceTest extends BaseMockitoUnitTest {
when(assetMapper.selectRecommendations(100L, 9001L, "newest", 2))
.thenReturn(List.of(asset(1001L, "listed", 2001L, "10")));
when(assetVersionMapper.selectById(3001L)).thenReturn(version());
// 登录用户的推荐列表会批量回填获取/安装态;无授权/安装事实时返回空,卡片应为未获取/未安装
when(authorizationSnapshotMapper.selectActiveByOwnerAndAssetIds(eq(100L), eq(9001L), any()))
.thenReturn(List.of());
when(installationMapper.selectByUserAndAssetIds(eq(100L), eq(9001L), any()))
.thenReturn(List.of());
List<MarketAssetCardRespVO> result = queryService.listRecommendations(9001L, "detail_related",
1001L, null, 2);
assertEquals(1, result.size());
assertEquals("fallback_newest", result.getFirst().getRecommendationReason());
assertEquals(Boolean.FALSE, result.getFirst().getAcquired());
assertEquals(Boolean.FALSE, result.getFirst().getInstalled());
}
@Test
void should_backfillAcquiredAndInstalledFromBatchStateForLoginUser() {
TenantContextHolder.setTenantId(100L);
// 同一页两条 listed agent:1001 已获取+已安装,1002 无任何事实(两态对照,反裸写 false)
PageResult<MuseMarketAssetDO> page = new PageResult<>(
List.of(asset(1001L, "listed", 2001L, "10"), asset(1002L, "listed", 2002L, "10")), 2L);
when(assetMapper.selectVisiblePage(any(), eq(100L), eq(9001L), eq("popular"), any(),
any(), any(), any(), any())).thenReturn(page);
when(assetVersionMapper.selectById(3001L)).thenReturn(version());
// 批量授权只返回 1001;批量安装只返回 1001 1001 两态 true,1002 两态 false
when(authorizationSnapshotMapper.selectActiveByOwnerAndAssetIds(eq(100L), eq(9001L), any()))
.thenReturn(List.of(authorizationSnapshot(1001L, 9001L)));
when(installationMapper.selectByUserAndAssetIds(eq(100L), eq(9001L), any()))
.thenReturn(List.of(installation(1001L, 9001L)));
PageResult<MarketAssetCardRespVO> result = queryService.listAssets(9001L, 1, 20, "popular",
null, null, null, null, null);
MarketAssetCardRespVO acquiredCard = result.getList().stream()
.filter(card -> "1001".equals(card.getAssetId())).findFirst().orElseThrow();
MarketAssetCardRespVO freshCard = result.getList().stream()
.filter(card -> "1002".equals(card.getAssetId())).findFirst().orElseThrow();
assertEquals(Boolean.TRUE, acquiredCard.getAcquired());
assertEquals(Boolean.TRUE, acquiredCard.getInstalled());
assertEquals(Boolean.FALSE, freshCard.getAcquired());
assertEquals(Boolean.FALSE, freshCard.getInstalled());
}
@Test
void should_openInstallGateOnlyWhenAcquiredAndNotInstalled() {
TenantContextHolder.setTenantId(100L);
// 已获取但尚未安装的 listed agent canInstall=true(studio 安装按钮渲染条件)
when(assetMapper.selectVisibleById(100L, 1001L, 9001L)).thenReturn(asset(1001L, "listed", 2001L, "10"));
when(assetVersionMapper.selectById(3001L)).thenReturn(version());
when(authorizationSnapshotMapper.selectActiveByOwnerAndAsset(100L, 9001L, 1001L))
.thenReturn(authorizationSnapshot(1001L, 9001L));
when(installationMapper.existsByUserAndAsset(100L, 9001L, 1001L)).thenReturn(false);
MarketAssetDetailRespVO.AssetUserActions actions =
queryService.getAsset(9001L, 1001L).getUserActions();
assertEquals(Boolean.TRUE, actions.getAcquired());
assertEquals(Boolean.FALSE, actions.getInstalled());
assertEquals(Boolean.TRUE, actions.getCanInstall());
}
@Test
void should_closeInstallGateWhenAlreadyInstalled() {
TenantContextHolder.setTenantId(100L);
// 已获取且已安装 canInstall=false(安装门:已获取且未安装)
when(assetMapper.selectVisibleById(100L, 1001L, 9001L)).thenReturn(asset(1001L, "listed", 2001L, "10"));
when(assetVersionMapper.selectById(3001L)).thenReturn(version());
when(authorizationSnapshotMapper.selectActiveByOwnerAndAsset(100L, 9001L, 1001L))
.thenReturn(authorizationSnapshot(1001L, 9001L));
when(installationMapper.existsByUserAndAsset(100L, 9001L, 1001L)).thenReturn(true);
MarketAssetDetailRespVO.AssetUserActions actions =
queryService.getAsset(9001L, 1001L).getUserActions();
assertEquals(Boolean.TRUE, actions.getAcquired());
assertEquals(Boolean.TRUE, actions.getInstalled());
assertEquals(Boolean.FALSE, actions.getCanInstall());
}
private static MuseMarketAssetDO asset(Long id, String listingStatus, Long publisherId, String category) {
@ -187,4 +264,22 @@ class MarketAssetQueryServiceTest extends BaseMockitoUnitTest {
version.setTenantId(100L);
return version;
}
private static MuseMarketAuthorizationSnapshotDO authorizationSnapshot(Long assetId, Long ownerUserId) {
MuseMarketAuthorizationSnapshotDO snapshot = new MuseMarketAuthorizationSnapshotDO();
snapshot.setAssetId(assetId);
snapshot.setOwnerUserId(ownerUserId);
snapshot.setStatus("active");
snapshot.setTenantId(100L);
return snapshot;
}
private static MuseMarketInstallationDO installation(Long assetId, Long userId) {
MuseMarketInstallationDO installation = new MuseMarketInstallationDO();
installation.setAssetId(assetId);
installation.setUserId(userId);
installation.setStatus("installed");
installation.setTenantId(100L);
return installation;
}
}

View File

@ -206,7 +206,9 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(2)));
// assetType=work listed work(草稿 work 对买家隐藏),卡片字段回填且未收藏/未获取/未安装
// assetType=work listed work(草稿 work 对买家隐藏)买家对该 work 无收藏/ active 授权/无安装,
// isFavorite/isAcquired/isInstalled 均为真实 false( enrich 按真实事实回填,非裸写);
// 已获取/已安装的 true 态由 should_reflectAcquiredAndInstalledStateForBuyer 两态对照验证
assertNoMarketMutationDuring("listMarketplaceAssets assetType 过滤 + 可见性隔离",
() -> mockMvc.perform(get("/app-api/muse/marketplace/assets")
.header("X-API-Version", API_VERSION)
@ -236,6 +238,60 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
.andExpect(jsonPath("$.data[0].recommendationReason").isNotEmpty()));
}
@Test
void should_reflectAcquiredAndInstalledStateForBuyer() throws Exception {
// 两个可安装的 listed agent:一个买家已获取+已安装,一个买家无任何授权(两态对照,"裸写 false"假绿)
long acquiredAgent = seedAsset(TENANT_ID, "已获取智能体", "agent", PUBLISHER_ID, "listed");
long freshAgent = seedAsset(TENANT_ID, "未获取智能体", "agent", PUBLISHER_ID, "listed");
seedActiveAuthorization(TENANT_ID, acquiredAgent, BUYER_USER_ID);
seedInstallation(TENANT_ID, acquiredAgent, BUYER_USER_ID);
// 列表卡片: assetId 定位,已获取 agent isAcquired/isInstalled=true,未获取 agent 两者=false
assertNoMarketMutationDuring("listMarketplaceAssets 获取/安装态回填仍是纯读 operation",
() -> mockMvc.perform(get("/app-api/muse/marketplace/assets")
.header("X-API-Version", API_VERSION)
.param("assetType", "agent"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(2))
.andExpect(jsonPath("$.data.list[?(@.assetId=='" + acquiredAgent + "')].isAcquired").value(true))
.andExpect(jsonPath("$.data.list[?(@.assetId=='" + acquiredAgent + "')].isInstalled").value(true))
.andExpect(jsonPath("$.data.list[?(@.assetId=='" + freshAgent + "')].isAcquired").value(false))
.andExpect(jsonPath("$.data.list[?(@.assetId=='" + freshAgent + "')].isInstalled").value(false)));
// 已获取+已安装 agent 详情:isAcquired/isInstalled=true;已安装 canInstall=false(安装门:已获取且未安装)
assertNoMarketMutationDuring("getMarketplaceAsset 已安装详情仍是纯读 operation",
() -> mockMvc.perform(get("/app-api/muse/marketplace/assets/{assetId}", acquiredAgent)
.header("X-API-Version", API_VERSION))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userActions.isAcquired").value(true))
.andExpect(jsonPath("$.data.userActions.isInstalled").value(true))
.andExpect(jsonPath("$.data.userActions.canInstall").value(false)));
// 未获取 agent 详情:isAcquired/isInstalled=false;尚未获取 canInstall=false
assertNoMarketMutationDuring("getMarketplaceAsset 未获取详情仍是纯读 operation",
() -> mockMvc.perform(get("/app-api/muse/marketplace/assets/{assetId}", freshAgent)
.header("X-API-Version", API_VERSION))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userActions.isAcquired").value(false))
.andExpect(jsonPath("$.data.userActions.isInstalled").value(false))
.andExpect(jsonPath("$.data.userActions.canInstall").value(false)));
// 已获取但尚未安装的 agent canInstall=true(安装门真实开启,这正是 studio 安装按钮渲染条件)
long readyToInstallAgent = seedAsset(TENANT_ID, "可安装智能体", "agent", PUBLISHER_ID, "listed");
seedActiveAuthorization(TENANT_ID, readyToInstallAgent, BUYER_USER_ID);
assertNoMarketMutationDuring("getMarketplaceAsset 可安装详情仍是纯读 operation",
() -> mockMvc.perform(get("/app-api/muse/marketplace/assets/{assetId}", readyToInstallAgent)
.header("X-API-Version", API_VERSION))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userActions.isAcquired").value(true))
.andExpect(jsonPath("$.data.userActions.isInstalled").value(false))
.andExpect(jsonPath("$.data.userActions.canInstall").value(true)));
}
@Test
void should_getGovernanceImpactForPublisherAndForbidNonOwnerProbe() throws Exception {
long asset = seedAsset(TENANT_ID, "治理影响作品", "work", PUBLISHER_ID, "listed");
@ -321,7 +377,9 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
muse_market_favorite,
muse_market_command,
muse_market_governance_action,
muse_market_governance_impact
muse_market_governance_impact,
muse_market_authorization_snapshot,
muse_market_installation
RESTART IDENTITY CASCADE
""");
} catch (SQLException exception) {
@ -351,6 +409,53 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
}
}
/**
* 为指定用户在指定资产上 seed 一条 active 来源授权快照模拟"已获取"事实
*
* <p>列只填 NOT NULL 必填项 + status=activeowner_user_id isAcquired 回填的判定键</p>
*/
private void seedActiveAuthorization(Long tenantId, Long assetId, Long ownerUserId) {
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement("""
INSERT INTO muse_market_authorization_snapshot(authorization_snapshot_id, asset_id, license_id,
purchaser_user_id, owner_user_id, source_owner,
source_type, source_id, source_revision, status,
tenant_id)
VALUES (?, ?, ?, ?, ?, 'muse-module-market', 'asset', ?, '1', 'active', ?)
""")) {
statement.setString(1, "auth-snap-" + assetId + "-" + ownerUserId);
statement.setLong(2, assetId);
statement.setString(3, "license-" + assetId + "-" + ownerUserId);
statement.setLong(4, ownerUserId);
statement.setLong(5, ownerUserId);
statement.setString(6, String.valueOf(assetId));
statement.setLong(7, tenantId);
statement.executeUpdate();
} catch (SQLException exception) {
throw new AssertionError("seed muse_market_authorization_snapshot 失败", exception);
}
}
/**
* 为指定用户在指定资产上 seed 一条安装事实模拟"已安装"状态
*/
private void seedInstallation(Long tenantId, Long assetId, Long userId) {
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement("""
INSERT INTO muse_market_installation(asset_id, asset_version_id, user_id, status, tenant_id)
VALUES (?, ?, ?, 'installed', ?)
""")) {
statement.setLong(1, assetId);
// 安装事实唯一键含 asset_version_id这里给定具体版本号即可isInstalled 回填不限版本
statement.setLong(2, 1L);
statement.setLong(3, userId);
statement.setLong(4, tenantId);
statement.executeUpdate();
} catch (SQLException exception) {
throw new AssertionError("seed muse_market_installation 失败", exception);
}
}
// ==================== 无突变断言(反假绿) ====================
private void assertNoMarketMutationDuring(String message, CheckedOperation operation) throws Exception {
@ -365,7 +470,9 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
tableSnapshot("muse_market_favorite", "tenant_id, id"),
tableSnapshot("muse_market_command", "tenant_id, id"),
tableSnapshot("muse_market_governance_action", "tenant_id, id"),
tableSnapshot("muse_market_governance_impact", "tenant_id, id")
tableSnapshot("muse_market_governance_impact", "tenant_id, id"),
tableSnapshot("muse_market_authorization_snapshot", "tenant_id, id"),
tableSnapshot("muse_market_installation", "tenant_id, id")
);
}
@ -633,7 +740,9 @@ class P1rMarketplaceDiscoveryReadsCompletedApprovalIT {
List<String> favorites,
List<String> commands,
List<String> governanceActions,
List<String> governanceImpacts) {
List<String> governanceImpacts,
List<String> authorizationSnapshots,
List<String> installations) {
}
private record CompletedApprovalSettings(String jdbcUrl,

View File

@ -324,7 +324,52 @@ async function globalSetup(): Promise<void> {
VALUES (1,'writing.continuation',1,'1','active',1,'user',1)`
);
console.log(`[e2e globalSetup] 已复位每轮 fixture(graph/confirm-draft/accept/security-ack/appeal/binding/meta-schema/block/kb-status/installed-kb/handoff-bind/handoff-agent/handoff-content/agent-slot-bind);清理 e2e 累积 agent ${agentCleanup.rowCount}`);
// 16) market-install(UI e2e):seed 一个固定 marker 的 listed agent 资产,并把当前用户(user1)对它的
// 授权/安装/命令复位为「未获取、未安装」干净起点。WHY:market-install.spec 走 purchase→install 真链,
// UI 每次用随机 commandId(crypto.randomUUID)→install 每跑落一条新 installation;不复位则二次跑资产已安装,
// 安装按钮的渲染条件(isAcquired && !isInstalled)不再成立 → 断言必红。故每轮删净 user1 在该资产上的
// authz/installation/command,保证 UI 起点恒为「可获取、未安装」。资产本身幂等保留(只删用户态)。
const MARKET_INSTALL_ASSET = '活体市场资产·安装e2e';
let installE2eAssetId: number;
const existed = await client.query(
"SELECT id, current_version_id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false",
[MARKET_INSTALL_ASSET]
);
if (existed.rowCount && existed.rows[0]) {
installE2eAssetId = existed.rows[0].id as number;
} else {
// 资产首建:listed agent + 一条 published 版本,并回填 current_version_id(详情/安装需可见且有版本)。
const a = await client.query(
`INSERT INTO muse_market_asset
(name, description, asset_type, category, publisher_id, listing_status, license_type, status, creator, updater, tenant_id)
VALUES ($1,'安装 e2e 专用资产','agent','general',2,'listed','standard','active','2','2',1) RETURNING id`,
[MARKET_INSTALL_ASSET]
);
installE2eAssetId = a.rows[0].id as number;
const v = await client.query(
`INSERT INTO muse_market_asset_version (asset_id, version, change_note, status, creator, updater, tenant_id)
VALUES ($1,'1.0.0','首发','published','2','2',1) RETURNING id`,
[installE2eAssetId]
);
await client.query('UPDATE muse_market_asset SET current_version_id=$1 WHERE id=$2', [
v.rows[0].id,
installE2eAssetId,
]);
}
// 复位 user1 在该资产上的获取/安装/命令到干净起点(未获取、未安装),令安装按钮渲染条件每轮可复现。
await client.query('DELETE FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1', [
installE2eAssetId,
]);
await client.query(
'DELETE FROM muse_market_authorization_snapshot WHERE tenant_id=1 AND owner_user_id=1 AND asset_id=$1',
[installE2eAssetId]
);
// UI 随机 commandId 的 purchase/install 命令记录按 target_id 清(target_id 存资产 id 字符串),避免幂等回放。
await client.query("DELETE FROM muse_market_command WHERE tenant_id=1 AND target_id=$1", [
String(installE2eAssetId),
]);
console.log(`[e2e globalSetup] 已复位每轮 fixture(graph/confirm-draft/accept/security-ack/appeal/binding/meta-schema/block/kb-status/installed-kb/handoff-bind/handoff-agent/handoff-content/agent-slot-bind/market-install assetId=${installE2eAssetId});清理 e2e 累积 agent ${agentCleanup.rowCount}`);
} finally {
await client.end();
}

View File

@ -0,0 +1,125 @@
import { expect, test, type Page } from '@playwright/test';
import { Client } from 'pg';
/**
* UI e2e(,MSW off,绿)
*
* 绿全链:后端 enrich isAcquired/canInstall( false, MarketBrowse )
* UI muse_market_installation UI
*
* 绿(绿):
* 1) DB 起点:user1 installation(global-setup #16 )
* 2) purchase(page.request /purchase),****
* ( isAcquired false canInstall=asset.isAcquired&& false )
* 3) install installed
* 4) DB 终点:muse_market_installation user+asset installed ;UI
*
* 前置:vite VITE_API_MOCK=false muse-server 48080muse_slice_live;
* global-setup #16 seed marker listed agent user1 //
* PG (MUSE_POSTGRES_*, ~/.config/muse-repo/infra.env;):
* `set -a; . ~/.config/muse-repo/infra.env; set +a`
*/
const TOKEN = 'test1';
const API = 'http://localhost:48080/app-api/muse';
const AUTH = { Authorization: 'Bearer test1', 'tenant-id': '1', 'X-API-Version': '1' };
/** global-setup #16 seed 的固定 marker 资产名(本 spec 与 global-setup 必须一致)。 */
const ASSET_NAME = '活体市场资产·安装e2e';
function pgClient(): Client {
return new Client({
host: process.env.MUSE_POSTGRES_HOST,
port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'),
database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live',
user: process.env.MUSE_POSTGRES_USERNAME ?? 'root',
password: process.env.MUSE_POSTGRES_PASSWORD,
ssl: false,
});
}
/** 读 global-setup seed 的安装 e2e 资产 id(按 marker 名定位)。 */
async function readAssetId(): Promise<number> {
const c = pgClient();
await c.connect();
try {
const r = await c.query(
'SELECT id FROM muse_market_asset WHERE tenant_id=1 AND name=$1 AND deleted=false',
[ASSET_NAME]
);
expect(r.rowCount, 'global-setup #16 应已 seed 安装 e2e 资产').toBeGreaterThan(0);
return Number(r.rows[0].id);
} finally {
await c.end();
}
}
/** 读 user1 对指定资产的 installed 安装行数(DB 核验:0=未安装,>=1=已安装)。 */
async function countInstallation(assetId: number): Promise<number> {
const c = pgClient();
await c.connect();
try {
const r = await c.query(
"SELECT count(*)::int AS n FROM muse_market_installation WHERE tenant_id=1 AND user_id=1 AND asset_id=$1 AND status='installed'",
[assetId]
);
return Number(r.rows[0].n);
} finally {
await c.end();
}
}
async function seedToken(page: Page): Promise<void> {
await page.addInitScript((t) => {
window.localStorage.setItem('accessToken', t);
window.localStorage.setItem('tenantId', '1');
}, TOKEN);
}
test('用户可经真实浏览器 UI 安装已获取的市场资产(真实后端,enrich 驱动安装按钮渲染)', async ({ page, request }) => {
const assetId = await readAssetId();
// 1. DB 起点:global-setup #16 已复位为未安装(反假绿基准,确保终点新增可归因于本次 UI 安装)。
expect(await countInstallation(assetId), 'bind 前 user1 对该资产应无 installed 安装行(global-setup 复位)').toBe(0);
// 2. 前置 purchase(真打后端造 active 授权)→ 使资产 isAcquired=true,这是安装按钮渲染的前置条件。
const purchaseResp = await request.post(`${API}/marketplace/assets/${assetId}/purchase`, {
headers: AUTH,
data: { commandId: `e2e-acq-${Date.now()}` },
});
expect(purchaseResp.status(), 'purchase HTTP').toBe(200);
expect((await purchaseResp.json()).code, 'purchase code').toBe(0);
// 3. 进市场页 → 筛选 agent(seed 资产是 agent 类型)。
await seedToken(page);
await page.goto('/market');
await page.getByRole('button', { name: '智能体', exact: true }).click();
// 4. 定位该资产卡片(按资产名),断言**安装按钮真实渲染出来**——修前 enrich 裸写 false 时此按钮永不出现。
const card = page.locator('article').filter({ hasText: ASSET_NAME });
await expect(card, '已获取资产的卡片应可见').toBeVisible({ timeout: 15_000 });
const installButton = card.getByRole('button', { name: '安装', exact: true });
await expect(installButton, '已获取未安装资产应渲染「安装」按钮(enrich isAcquired=true 驱动)').toBeVisible({
timeout: 15_000,
});
// 5. 点击安装 → 等真后端 install 返回 installed(非 mock 截获)。
const [installResp] = await Promise.all([
page.waitForResponse(
(r) => new RegExp(`/marketplace/assets/${assetId}/install$`).test(r.url()) && r.request().method() === 'POST',
{ timeout: 20_000 }
),
installButton.click(),
]);
expect(installResp.status(), 'install HTTP').toBe(200);
const installBody = await installResp.json();
expect(installBody.code, `install 返回: ${JSON.stringify(installBody)}`).toBe(0);
expect(installBody.data?.status, 'install status').toBe('installed');
// 6a. UI 态翻转:安装成功后卡片状态文案变「已安装」(useInstallMarketAsset onSuccess 失效列表 → 重查 enrich isInstalled=true)。
await expect(card.getByText('已安装', { exact: true }), '安装后卡片状态应翻转为「已安装」').toBeVisible({
timeout: 15_000,
});
// 6b. DB 终点核验:user1 对该资产新增 installed 行(证 UI 操作真落库,非 mock 假绿)。
expect(await countInstallation(assetId), '安装后 user1 应有 installed 安装行').toBeGreaterThanOrEqual(1);
});

View File

@ -42,6 +42,14 @@ export default function MarketBrowse() {
installAsset.mutate(asset.assetId);
};
// 获取/安装失败时给用户可见提示,不再静默吞 ApiError(error.message 即后端 msg)。
// 对齐 studio 既有内联错误范式(AgentCreateForm / SlotBindingPanel:rose-50 内联提示)。
const actionError = installAsset.isError
? `安装失败:${installAsset.error.message}`
: acquireAsset.isError
? `获取授权失败:${acquireAsset.error.message}`
: null;
return (
<div className="min-h-screen bg-slate-50/60 px-6 py-8">
<div className="mx-auto max-w-7xl">
@ -81,6 +89,16 @@ export default function MarketBrowse() {
))}
</div>
{/* 获取/安装失败的可见提示(此前 mutate 后静默吞错,用户无任何反馈)。 */}
{actionError && (
<p
role="alert"
className="mt-4 rounded-lg bg-rose-50 px-3 py-2 text-xs font-semibold text-rose-700"
>
{actionError}
</p>
)}
{categories?.categories && (
<div className="mt-6 grid grid-cols-1 gap-3 md:grid-cols-3">
{categories.categories.map((category) => (

View File

@ -83,6 +83,22 @@ describe('useMarket API Hooks 单元测试', () => {
expect(installHook.current.data?.canBindTarget).toBe(true);
});
it('安装失败时 hook 暴露 isError + 后端错误消息(供 UI 可见提示,反静默吞错)', async () => {
// 后端拒安装(如未获取授权 → MARKET_LICENSE_NOT_EXISTS):CommonResult.code 非 0,
// client 抛 ApiError(message=后端 msg)。MarketBrowse 据此渲染「安装失败:{message}」。
server.use(
http.post('/app-api/muse/marketplace/assets/:assetId/install', () =>
HttpResponse.json({ code: 1_041_000_001, msg: 'Market 授权不存在', data: null })
)
);
const { result } = renderHook(() => useInstallMarketAsset(), { wrapper });
result.current.mutate('20000000-0000-4000-8000-000000000002');
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('Market 授权不存在');
});
it('读资产详情(含许可)并向正确端点收藏', async () => {
const requestedUrls: string[] = [];
server.use(