From 37e7a8dc275e6ca53fc140e830a0f9fbbbbbcd80 Mon Sep 17 00:00:00 2001 From: lili Date: Thu, 25 Jun 2026 10:28:33 -0700 Subject: [PATCH] =?UTF-8?q?fix(market):=20=E4=BF=AE=20install=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=8F=AF=E8=A7=81=E5=81=87=E7=BB=BF=E2=80=94=E2=80=94?= =?UTF-8?q?isAcquired/isInstalled/canInstall=20=E6=8C=89=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E5=AE=89=E8=A3=85=E6=80=81=20enrich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三路盘点发现 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) --- .../muse/MarketAssetQueryServiceImpl.java | 93 +++++++++++-- ...MuseMarketAuthorizationSnapshotMapper.java | 17 +++ .../muse/MuseMarketInstallationMapper.java | 23 ++++ .../muse/MarketAssetQueryServiceTest.java | 95 +++++++++++++ ...laceDiscoveryReadsCompletedApprovalIT.java | 117 +++++++++++++++- muse-studio/e2e/global-setup.ts | 47 ++++++- muse-studio/e2e/market-install.spec.ts | 125 ++++++++++++++++++ .../market/components/MarketBrowse.tsx | 18 +++ .../features/market/hooks/useMarket.test.tsx | 16 +++ 9 files changed, 538 insertions(+), 13 deletions(-) create mode 100644 muse-studio/e2e/market-install.spec.ts diff --git a/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/MarketAssetQueryServiceImpl.java b/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/MarketAssetQueryServiceImpl.java index ce99eae1..49e4f014 100644 --- a/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/MarketAssetQueryServiceImpl.java +++ b/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/MarketAssetQueryServiceImpl.java @@ -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 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 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 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 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 授权(已获取)。 + * + *

active 判定(owner_user_id + asset_id + status=active)与安装写端口 + * {@code MarketInstallServiceImpl} 完全一致,保证读模型与可安装前置条件不漂移。

+ */ + 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 授权)与安装态。 + * + *

列表卡片在循环内回填这两个布尔位,逐条单查会放大 N+1;此处对整页 assetId 各发一次 + * IN 查询得到两个 Set,再交给 {@code toCard} 命中。未登录或空列表直接返回空状态。

+ */ + private UserAssetState loadUserAssetState(Long tenantId, Long loginUserId, List assets) { + if (loginUserId == null || assets.isEmpty()) { + return UserAssetState.empty(); + } + List assetIds = assets.stream().map(MuseMarketAssetDO::getId).toList(); + Set acquiredAssetIds = authorizationSnapshotMapper + .selectActiveByOwnerAndAssetIds(tenantId, loginUserId, assetIds).stream() + .map(MuseMarketAuthorizationSnapshotDO::getAssetId) + .collect(Collectors.toSet()); + Set installedAssetIds = installationMapper + .selectByUserAndAssetIds(tenantId, loginUserId, assetIds).stream() + .map(MuseMarketInstallationDO::getAssetId) + .collect(Collectors.toSet()); + return new UserAssetState(acquiredAssetIds, installedAssetIds); + } + + /** + * 整页当前用户的获取/安装资产 id 集合,供卡片回填命中,避免逐卡片单查。 + */ + private record UserAssetState(Set acquiredAssetIds, Set 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(); } diff --git a/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/dal/mysql/muse/MuseMarketAuthorizationSnapshotMapper.java b/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/dal/mysql/muse/MuseMarketAuthorizationSnapshotMapper.java index fdb4384d..b130cfb3 100644 --- a/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/dal/mysql/muse/MuseMarketAuthorizationSnapshotMapper.java +++ b/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/dal/mysql/muse/MuseMarketAuthorizationSnapshotMapper.java @@ -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用于列表卡片回填 isAcquired,避免每张卡片单独查授权放大 N+1。active 判定与 + * {@link #selectActiveByOwnerAndAsset} / 安装服务一致(owner_user_id + asset_id + status=active)。

+ */ + default List selectActiveByOwnerAndAssetIds(Long tenantId, Long ownerUserId, + List assetIds) { + return selectList(new LambdaQueryWrapperX() + .eq(MuseMarketAuthorizationSnapshotDO::getTenantId, tenantId) + .eq(MuseMarketAuthorizationSnapshotDO::getOwnerUserId, ownerUserId) + .in(MuseMarketAuthorizationSnapshotDO::getAssetId, assetIds) + .eq(MuseMarketAuthorizationSnapshotDO::getStatus, "active")); + } + /** * 授权快照按业务授权键幂等写入。 * diff --git a/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/dal/mysql/muse/MuseMarketInstallationMapper.java b/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/dal/mysql/muse/MuseMarketInstallationMapper.java index b7af9593..8d7e7a3f 100644 --- a/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/dal/mysql/muse/MuseMarketInstallationMapper.java +++ b/muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/dal/mysql/muse/MuseMarketInstallationMapper.java @@ -30,6 +30,29 @@ public interface MuseMarketInstallationMapper extends BaseMapperX() + .eq(MuseMarketInstallationDO::getTenantId, tenantId) + .eq(MuseMarketInstallationDO::getUserId, userId) + .eq(MuseMarketInstallationDO::getAssetId, assetId) + .last("LIMIT 1")) != null; + } + + /** + * 批量查询当前用户在一批资产中已安装的那些资产。 + * + *

用于列表卡片回填 isInstalled,避免逐张卡片单独查安装事实放大 N+1。

+ */ + default List selectByUserAndAssetIds(Long tenantId, Long userId, List assetIds) { + return selectList(new LambdaQueryWrapperX() + .eq(MuseMarketInstallationDO::getTenantId, tenantId) + .eq(MuseMarketInstallationDO::getUserId, userId) + .in(MuseMarketInstallationDO::getAssetId, assetIds)); + } + /** * 同一用户同一资产版本只保留一条安装事实;重复 command 通过 command envelope 回放。 */ diff --git a/muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/MarketAssetQueryServiceTest.java b/muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/MarketAssetQueryServiceTest.java index fec43054..dcdd13cf 100644 --- a/muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/MarketAssetQueryServiceTest.java +++ b/muse-cloud/muse-module-market/muse-module-market-server/src/test/java/cn/iocoder/muse/module/market/application/muse/MarketAssetQueryServiceTest.java @@ -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 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 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 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; + } } diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketplaceDiscoveryReadsCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketplaceDiscoveryReadsCompletedApprovalIT.java index 15b35a0f..cf380fe2 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketplaceDiscoveryReadsCompletedApprovalIT.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketplaceDiscoveryReadsCompletedApprovalIT.java @@ -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 来源授权快照,模拟"已获取"事实。 + * + *

列只填 NOT NULL 必填项 + status=active;owner_user_id 是 isAcquired 回填的判定键。

+ */ + 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 favorites, List commands, List governanceActions, - List governanceImpacts) { + List governanceImpacts, + List authorizationSnapshots, + List installations) { } private record CompletedApprovalSettings(String jdbcUrl, diff --git a/muse-studio/e2e/global-setup.ts b/muse-studio/e2e/global-setup.ts index 47e18ecc..867b41d0 100644 --- a/muse-studio/e2e/global-setup.ts +++ b/muse-studio/e2e/global-setup.ts @@ -324,7 +324,52 @@ async function globalSetup(): Promise { 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(); } diff --git a/muse-studio/e2e/market-install.spec.ts b/muse-studio/e2e/market-install.spec.ts new file mode 100644 index 00000000..ca157d93 --- /dev/null +++ b/muse-studio/e2e/market-install.spec.ts @@ -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 48080、muse_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 { + 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 { + 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 { + 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); +}); diff --git a/muse-studio/src/features/market/components/MarketBrowse.tsx b/muse-studio/src/features/market/components/MarketBrowse.tsx index fde6f9b3..bde99f45 100644 --- a/muse-studio/src/features/market/components/MarketBrowse.tsx +++ b/muse-studio/src/features/market/components/MarketBrowse.tsx @@ -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 (
@@ -81,6 +89,16 @@ export default function MarketBrowse() { ))}
+ {/* 获取/安装失败的可见提示(此前 mutate 后静默吞错,用户无任何反馈)。 */} + {actionError && ( +

+ {actionError} +

+ )} + {categories?.categories && (
{categories.categories.map((category) => ( diff --git a/muse-studio/src/features/market/hooks/useMarket.test.tsx b/muse-studio/src/features/market/hooks/useMarket.test.tsx index f7ba3997..40753aa9 100644 --- a/muse-studio/src/features/market/hooks/useMarket.test.tsx +++ b/muse-studio/src/features/market/hooks/useMarket.test.tsx @@ -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(