") ? rememberedFlywayUser : value;
+ }
assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name);
return value;
}
@@ -307,10 +391,36 @@ class P1rMarketFlywayMigrationIT {
private static void redactFlywaySystemProperties(String url, String user) {
// WHY:Surefire XML 会记录 JVM system property;读取后立即脱敏,避免报告文件残留真实连接信息。
+ captureOriginalFlywaySystemProperties();
System.setProperty("p1r.flyway.url", maskedUrl(url));
System.setProperty("p1r.flyway.user", maskUser(user));
}
+ private static void captureOriginalFlywaySystemProperties() {
+ if (originalFlywayPropertiesCaptured) {
+ return;
+ }
+ originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url");
+ originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user");
+ originalFlywayPropertiesCaptured = true;
+ }
+
+ private static void restoreOriginalFlywaySystemProperties() {
+ if (!originalFlywayPropertiesCaptured) {
+ return;
+ }
+ restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty);
+ restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty);
+ }
+
+ private static void restoreSystemProperty(String name, String value) {
+ if (value == null) {
+ System.clearProperty(name);
+ return;
+ }
+ System.setProperty(name, value);
+ }
+
private static void cleanSchema(Flyway flyway, String url, String user) {
try {
flyway.clean();
@@ -432,6 +542,13 @@ class P1rMarketFlywayMigrationIT {
constraintColumns(connection, "uk_muse_market_install"),
"安装幂等唯一约束必须按 tenant/user/asset/version 分层");
+ assertColumnsExist(connection, "muse_market_favorite",
+ "asset_id", "user_id", "status", "command_id", "tenant_id");
+ assertEquals(List.of("tenant_id", "user_id", "asset_id"),
+ constraintColumns(connection, "uk_muse_market_favorite_user_asset"),
+ "收藏事实唯一约束必须按 tenant/user/asset 分层");
+ assertPartialCommandIndex(connection, "uk_muse_market_favorite_command");
+
assertColumnsExist(connection, "muse_market_purchase",
"asset_id", "asset_version_id", "user_id", "status", "purchase_payload", "command_id");
assertTrue(columnIsNotNullable(connection, "muse_market_purchase", "command_id"),
@@ -584,6 +701,52 @@ class P1rMarketFlywayMigrationIT {
}
}
+ private static void resetFavoriteProbeRows(Connection connection) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ DELETE FROM muse_market_favorite
+ WHERE tenant_id = 990001
+ """)) {
+ statement.executeUpdate();
+ }
+ }
+
+ private static void insertFavoriteProbe(Connection connection, String commandId) throws SQLException {
+ insertFavoriteProbe(connection, 990003L, 990002L, commandId);
+ }
+
+ private static void insertFavoriteProbe(Connection connection, Long assetId, Long userId,
+ String commandId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO muse_market_favorite(asset_id, user_id, status, command_id, tenant_id)
+ VALUES (?, ?, 'active', ?, 990001)
+ """)) {
+ statement.setLong(1, assetId);
+ statement.setLong(2, userId);
+ statement.setString(3, commandId);
+ statement.executeUpdate();
+ }
+ }
+
+ private static void assertFavoriteUpdateTriggerExecutes(Connection connection) throws SQLException {
+ insertFavoriteProbe(connection, 990006L, 990007L, "p1r-market-favorite-schema-cmd-3");
+ try (PreparedStatement statement = connection.prepareStatement("""
+ UPDATE muse_market_favorite
+ SET status = 'inactive',
+ update_time = TIMESTAMP '2000-01-01 00:00:00'
+ WHERE tenant_id = 990001
+ AND user_id = 990007
+ AND asset_id = 990006
+ RETURNING status, update_time > TIMESTAMP '2000-01-02 00:00:00'
+ """)) {
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "收藏状态更新必须返回被 trigger 处理后的行");
+ assertEquals("inactive", resultSet.getString(1), "收藏状态必须能更新为 inactive");
+ assertTrue(resultSet.getBoolean(2),
+ "收藏状态更新时 trigger 必须覆盖手工写入的旧 update_time");
+ }
+ }
+ }
+
private static boolean columnExists(Connection connection, String tableName, String columnName) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement("""
SELECT 1
diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java
index a929a4d7..880329e5 100644
--- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java
@@ -19,8 +19,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* P1R-6 Task 11 Market 真实 API 覆盖门禁。
*
- * 该测试只证明 Market 32 个 operation 已退出合同兜底并进入 dedicated / needs_verification;
- * 它不证明 Market 已经完成跨 owner 端到端验收,也不允许把 Market 提前标记为 completed。
+ * 该测试证明 Market 第一批 4 个 operation 只以 operation-level 方式推进到 completed;
+ * 它不证明 Market domain-level completed,也不允许把剩余 28 个 operation 提前标记为 completed。
*/
class P1rMarketRealApiGateTest {
@@ -71,6 +71,13 @@ class P1rMarketRealApiGateTest {
entry("adminResolveAppeal", "POST /admin-api/muse/market/appeals/{appealId}/resolve")
);
+ private static final Set APPROVED_MARKET_COMPLETED_OPERATIONS = Set.of(
+ "getMarketplaceAsset",
+ "listMarketplaceCategories",
+ "favoriteAsset",
+ "unfavoriteAsset"
+ );
+
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
@@ -97,22 +104,37 @@ class P1rMarketRealApiGateTest {
}
@Test
- void should_keep_all_market_operations_dedicated_and_needs_verification() throws IOException {
+ void should_promote_only_first_batch_market_operations_to_completed() throws IOException {
+ int completed = 0;
+ int needsVerification = 0;
for (JsonNode operation : readMarketOperations()) {
String operationId = operation.path("operationId").asText("");
assertEquals("dedicated", operation.path("implementationStatus").asText(),
operationId + " 当前 P1R-6 目标必须是 dedicated");
+ if (APPROVED_MARKET_COMPLETED_OPERATIONS.contains(operationId)) {
+ completed++;
+ assertEquals("completed", operation.path("completionStatus").asText(),
+ operationId + " 已获用户批准后必须标记 completed");
+ continue;
+ }
+ needsVerification++;
assertEquals("needs_verification", operation.path("completionStatus").asText(),
- operationId + " 当前 P1R-6 目标必须保持 needs_verification,不能提前宣称 completed");
+ operationId + " 未进入 Market 第一批审批,必须继续保持 needs_verification");
}
+ assertEquals(4, completed, "Market 第一批 operation-level completed approval 只能推进 4 个 operation");
+ assertEquals(28, needsVerification, "Market 剩余 28 个 operation 必须继续 needs_verification");
+ assertMarketOperationStatus("listMarketplaceAssets", "needs_verification", false);
+ assertMarketOperationStatus("listMarketplaceRecommendations", "needs_verification", false);
+ assertMarketOperationStatus("favoriteAsset", "completed", true);
+ assertMarketOperationStatus("unfavoriteAsset", "completed", true);
}
@Test
void should_keep_completion_summary_owned_by_approved_scopes_only() throws IOException {
JsonNode report = readReport();
- assertEquals(127, report.path("summary").path("completedOperations").asInt(),
- "completedOperations 必须仍只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10");
+ assertEquals(131, report.path("summary").path("completedOperations").asInt(),
+ "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4");
}
@Test
@@ -206,6 +228,21 @@ class P1rMarketRealApiGateTest {
"Meta operation-level approval 后不应再保留 FunctionChain / ProtectionNode needs_verification");
}
+ private void assertMarketOperationStatus(String operationId, String expectedCompletionStatus,
+ boolean expectedRequiresCommandId) throws IOException {
+ for (JsonNode operation : readMarketOperations()) {
+ if (!operationId.equals(operation.path("operationId").asText())) {
+ continue;
+ }
+ assertEquals(expectedCompletionStatus, operation.path("completionStatus").asText(),
+ "market/" + operationId + " completionStatus 不符合本轮审批边界");
+ assertEquals(expectedRequiresCommandId, operation.path("requiresCommandId").asBoolean(),
+ "market/" + operationId + " requiresCommandId 不符合 OpenAPI 合同");
+ return;
+ }
+ throw new AssertionError("market/" + operationId + " 必须存在于 coverage report");
+ }
+
private JsonNode readMarketOperations() throws IOException {
ArrayNode marketOperations = objectMapper.createArrayNode();
for (JsonNode operation : readOperations()) {
diff --git a/muse-cloud/scripts/p1r-audit-api-coverage.py b/muse-cloud/scripts/p1r-audit-api-coverage.py
index f748bda7..24f0b757 100755
--- a/muse-cloud/scripts/p1r-audit-api-coverage.py
+++ b/muse-cloud/scripts/p1r-audit-api-coverage.py
@@ -74,6 +74,11 @@ APPROVED_COMPLETED_OPERATIONS = {
"account:getAppBalanceSnapshots",
"account:adminCreateQuotaAdjustment",
"account:adminListQuotaAdjustments",
+ # P1R Market completed approval 第一批仍使用 operation-level 清单,避免整域 Market 被误升 completed。
+ "market:getMarketplaceAsset",
+ "market:listMarketplaceCategories",
+ "market:favoriteAsset",
+ "market:unfavoriteAsset",
}