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(