diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountMarketRecordsCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountMarketRecordsCompletedApprovalIT.java
new file mode 100644
index 00000000..cf89ad98
--- /dev/null
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountMarketRecordsCompletedApprovalIT.java
@@ -0,0 +1,891 @@
+package cn.iocoder.muse.server.framework.api;
+
+import cn.hutool.extra.spring.SpringUtil;
+import cn.iocoder.muse.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
+import cn.iocoder.muse.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO;
+import cn.iocoder.muse.framework.common.enums.UserTypeEnum;
+import cn.iocoder.muse.framework.common.pojo.CommonResult;
+import cn.iocoder.muse.framework.datasource.config.MuseDataSourceAutoConfiguration;
+import cn.iocoder.muse.framework.mybatis.config.MuseMybatisAutoConfiguration;
+import cn.iocoder.muse.framework.mybatis.core.util.MyBatisUtils;
+import cn.iocoder.muse.framework.security.core.LoginUser;
+import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.muse.framework.tenant.config.TenantProperties;
+import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.muse.framework.tenant.core.db.TenantDatabaseInterceptor;
+import cn.iocoder.muse.framework.web.config.MuseWebAutoConfiguration;
+import cn.iocoder.muse.module.market.application.muse.MarketAccountProjectionOutboxServiceImpl;
+import cn.iocoder.muse.module.market.application.muse.MarketAccountProjectionProvider;
+import cn.iocoder.muse.module.member.api.account.MuseAccountRecordProjectionApi;
+import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO;
+import cn.iocoder.muse.module.member.application.account.AccountMarketRecordServiceImpl;
+import cn.iocoder.muse.module.member.controller.app.account.AppAccountMarketRecordController;
+import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
+import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration;
+import org.flywaydb.core.Flyway;
+import org.flywaydb.core.api.output.MigrateResult;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
+import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
+import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
+import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+import javax.sql.DataSource;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.Set;
+
+import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_API_VERSION_UNSUPPORTED;
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_USER_NOT_EXISTS;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.not;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * P1R Account 市场记录 completed approval:购买、授权、发布记录 3 个 App 读端的 HTTP + 真实 PostgreSQL 证据。
+ *
+ *
本测试只连接显式传入的 PostgreSQL {@code _test} 库,使用真实 {@code /app-api/muse/**}
+ * 入口读取 {@code muse_account_record_projection}。Market 投影可用性由单体内真实
+ * {@link MarketAccountProjectionProvider} 判定,读端本身仍只读 Account 自有投影表。
+ */
+@SpringBootTest(
+ classes = P1rAccountMarketRecordsCompletedApprovalIT.AccountMarketRecordsCompletedApprovalConfiguration.class,
+ webEnvironment = SpringBootTest.WebEnvironment.MOCK
+)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class P1rAccountMarketRecordsCompletedApprovalIT {
+
+ private static final Long TENANT_ID = 100L;
+ private static final Long OTHER_TENANT_ID = 200L;
+ private static final Long ACCOUNT_USER_ID = 1001L;
+ private static final Long OTHER_USER_ID = 2002L;
+ private static final Long MISSING_USER_ID = 9099L;
+ private static final String API_VERSION = "1";
+ private static final String RAW_EXTERNAL_ORDER_REF = "ORDER-P1R-PURCHASE-123456";
+ private static final Set CREDENTIAL_QUERY_KEYS = Set.of(
+ "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password",
+ "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token");
+
+ private static volatile CompletedApprovalSettings cachedSettings;
+ private static volatile boolean originalFlywayPropertiesCaptured;
+ private static volatile String originalFlywayUrlSystemProperty;
+ private static volatile String originalFlywayUserSystemProperty;
+
+ @Autowired
+ private DataSource dataSource;
+ @Autowired
+ private WebApplicationContext webApplicationContext;
+
+ private MockMvc mockMvc;
+ private SeedFacts seedFacts;
+
+ @DynamicPropertySource
+ static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) {
+ CompletedApprovalSettings settings = settings();
+ redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser());
+ registry.add("spring.application.name", () -> "p1r-account-market-records-completed-approval-it");
+ registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module");
+ registry.add("muse.web.admin-ui.url", () -> "http://localhost");
+ registry.add("spring.datasource.url", settings::jdbcUrl);
+ registry.add("spring.datasource.username", settings::jdbcUser);
+ registry.add("spring.datasource.password", settings::jdbcPassword);
+ registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
+ registry.add("spring.main.banner-mode", () -> "off");
+ registry.add("spring.main.lazy-initialization", () -> "true");
+ registry.add("mybatis-plus.global-config.db-config.id-type", () -> "AUTO");
+ }
+
+ @BeforeAll
+ void migrateAccountSchemaAndCreateMemberUserFixture() {
+ CompletedApprovalSettings settings = settings();
+ silenceFlywayInfoLogs();
+ ensureTestDatabaseExists(settings);
+ Flyway flyway = Flyway.configure()
+ .dataSource(settings.jdbcUrl(), settings.jdbcUser(), settings.jdbcPassword())
+ .locations(resolveMuseSqlLocation(settings.flywayLocations()))
+ .schemas("public")
+ .defaultSchema("public")
+ .cleanDisabled(false)
+ .load();
+ cleanSchema(flyway, settings);
+ MigrateResult result = migrateSchema(flyway, settings);
+ assertTrue(result.migrationsExecuted >= 23,
+ "Account Market Records completed approval 必须迁移到包含 Account V11 与 Market V23 的 latest schema,实际: "
+ + result.migrationsExecuted);
+ createMemberUserFixtureTable();
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
+ resetAccountTables();
+ this.seedFacts = seedAccountMarketRecordFacts();
+ setRuntimeContext(ACCOUNT_USER_ID);
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ TenantContextHolder.clear();
+ }
+
+ @AfterAll
+ void restoreFlywaySystemProperties() {
+ restoreOriginalFlywaySystemProperties();
+ }
+
+ @Test
+ void should_rejectUnsafeDatabaseConfigurationInputs() {
+ System.setProperty("p1r.account.market-records.completed-approval.password", "must-not-be-used");
+ try {
+ AssertionError error = assertThrows(AssertionError.class,
+ P1rAccountMarketRecordsCompletedApprovalIT::assertNoPasswordSystemProperties);
+ assertTrue(error.getMessage().contains("p1r.account.market-records.completed-approval.password"),
+ "拒绝 JVM password system property 时必须指出属性名");
+ } finally {
+ System.clearProperty("p1r.account.market-records.completed-approval.password");
+ }
+
+ AssertionError credentialQueryError = assertThrows(AssertionError.class,
+ () -> assertNoCredentialQuery("jdbc:postgresql://localhost:5432/muse_test?password=secret"));
+ assertTrue(credentialQueryError.getMessage().contains("p1r.flyway.url 不能携带凭据 query 参数"),
+ "拒绝 JDBC credential query 时必须说明连接串只能通过环境变量传密码");
+
+ AssertionError databaseNameError = assertThrows(AssertionError.class,
+ () -> assertTestDatabaseUrl("jdbc:postgresql://localhost:5432/muse_prod"));
+ assertTrue(databaseNameError.getMessage().contains("_test"),
+ "拒绝非 _test 数据库时必须指出隔离库后缀要求");
+ }
+
+ @Test
+ void should_listPurchasesLicensesAndPublishRecordsFromRealProjectionWithoutMutation() throws Exception {
+ assertNoAccountMutationDuring("appListPurchases 是纯读 operation,不能改写 Account/Market fact",
+ () -> mockMvc.perform(get("/app-api/muse/account/purchases")
+ .header("X-API-Version", API_VERSION)
+ .param("pageNo", "1")
+ .param("pageSize", "10")
+ .param("status", "completed")
+ .param("userId", String.valueOf(OTHER_USER_ID)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.list[0].recordId").value(seedFacts.purchaseRecordId()))
+ .andExpect(jsonPath("$.data.list[0].assetType").value("work"))
+ .andExpect(jsonPath("$.data.list[0].assetName").value("P1R Account Owned Purchase"))
+ .andExpect(jsonPath("$.data.list[0].purchaseType").value("one_time"))
+ .andExpect(jsonPath("$.data.list[0].amount").value(1299))
+ .andExpect(jsonPath("$.data.list[0].status").value("completed"))
+ .andExpect(jsonPath("$.data.list[0].externalOrderRef").value("ORDE***3456"))
+ .andExpect(jsonPath("$.data.list[0].authorizationResultSummary").value("授权已写入"))
+ .andExpect(content().string(not(containsString(RAW_EXTERNAL_ORDER_REF))))
+ .andExpect(content().string(not(containsString(seedFacts.otherOwnerPurchaseRecordId()))))
+ .andExpect(content().string(not(containsString(seedFacts.otherTenantPurchaseRecordId())))));
+
+ assertNoAccountMutationDuring("appListLicenses 是纯读 operation,不能改写 Account/Market fact",
+ () -> mockMvc.perform(get("/app-api/muse/account/licenses")
+ .header("X-API-Version", API_VERSION)
+ .param("pageNo", "1")
+ .param("pageSize", "10")
+ .param("assetType", "work")
+ .param("licenseStatus", "installed"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.list[0].licenseId").value(seedFacts.licenseRecordId()))
+ .andExpect(jsonPath("$.data.list[0].assetType").value("work"))
+ .andExpect(jsonPath("$.data.list[0].assetName").value("P1R Account Installed License"))
+ .andExpect(jsonPath("$.data.list[0].publisherName").value("Muse Publisher"))
+ .andExpect(jsonPath("$.data.list[0].version").value("1.2.0"))
+ .andExpect(jsonPath("$.data.list[0].licenseScope").value("read_only"))
+ .andExpect(jsonPath("$.data.list[0].licenseStatus").value("installed"))
+ .andExpect(jsonPath("$.data.list[0].actionPolicy.installPolicy").value("allowed"))
+ .andExpect(jsonPath("$.data.list[0].actionPolicy.bindPolicy").value("allowed"))
+ .andExpect(jsonPath("$.data.list[0].actionPolicy.recheckReasons", hasItem("none"))));
+
+ assertNoAccountMutationDuring("appListPublishRecords 是纯读 operation,不能改写 Account/Market fact",
+ () -> mockMvc.perform(get("/app-api/muse/account/publish-records")
+ .header("X-API-Version", API_VERSION)
+ .param("pageNo", "1")
+ .param("pageSize", "10")
+ .param("assetType", "work")
+ .param("reviewStatus", "under_review"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.list[0].recordId").value(seedFacts.publishRecordId()))
+ .andExpect(jsonPath("$.data.list[0].assetType").value("work"))
+ .andExpect(jsonPath("$.data.list[0].assetName").value("P1R Account Publish Record"))
+ .andExpect(jsonPath("$.data.list[0].version").value("0.9.0"))
+ .andExpect(jsonPath("$.data.list[0].reviewStatus").value("under_review"))
+ .andExpect(jsonPath("$.data.list[0].marketStatus").value("not_listed"))
+ .andExpect(jsonPath("$.data.list[0].blockingReason").value("等待审核")));
+ }
+
+ @Test
+ void should_filterPurchasePendingAliasAndRejectInvalidInputsWithoutMutation() throws Exception {
+ assertNoAccountMutationDuring("pending purchase 只能按 external_pending App 合同读取",
+ () -> mockMvc.perform(get("/app-api/muse/account/purchases")
+ .header("X-API-Version", API_VERSION)
+ .param("status", "external_pending"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.list[0].recordId").value(seedFacts.pendingPurchaseRecordId()))
+ .andExpect(jsonPath("$.data.list[0].status").value("external_pending")));
+
+ assertNoAccountMutationDuring("缺少 API version 必须在 requireUser 前拒绝且不能写库",
+ () -> mockMvc.perform(get("/app-api/muse/account/purchases"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode()))
+ .andExpect(jsonPath("$.data").doesNotExist()));
+
+ assertNoAccountMutationDuring("非法购买状态必须拒绝且不能写库",
+ () -> mockMvc.perform(get("/app-api/muse/account/purchases")
+ .header("X-API-Version", API_VERSION)
+ .param("status", "unknown"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
+ .andExpect(jsonPath("$.data").doesNotExist()));
+
+ setRuntimeContext(MISSING_USER_ID);
+ assertNoAccountMutationDuring("缺失登录用户事实必须拒绝且不能写库",
+ () -> mockMvc.perform(get("/app-api/muse/account/purchases")
+ .header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(ACCOUNT_USER_NOT_EXISTS.getCode()))
+ .andExpect(jsonPath("$.data").doesNotExist()));
+ setRuntimeContext(ACCOUNT_USER_ID);
+ }
+
+ private void setRuntimeContext(Long userId) {
+ TenantContextHolder.setTenantId(TENANT_ID);
+ LoginUser loginUser = new LoginUser();
+ loginUser.setId(userId);
+ loginUser.setUserType(UserTypeEnum.MEMBER.getValue());
+ loginUser.setTenantId(TENANT_ID);
+ loginUser.setVisitTenantId(TENANT_ID);
+ SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
+ }
+
+ private void resetAccountTables() throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ Statement statement = connection.createStatement()) {
+ // 每个用例重置读端触达的事实表,确保 no-write 快照可以捕捉读路径误写。
+ statement.execute("""
+ TRUNCATE TABLE
+ muse_market_account_projection,
+ muse_account_event_publish_outbox,
+ muse_account_audit,
+ muse_account_command,
+ muse_account_record_projection,
+ member_user
+ RESTART IDENTITY CASCADE
+ """);
+ }
+ }
+
+ private SeedFacts seedAccountMarketRecordFacts() throws SQLException {
+ LocalDateTime base = LocalDateTime.of(2026, 6, 16, 9, 0);
+ try (Connection connection = dataSource.getConnection()) {
+ insertMemberUser(connection, TENANT_ID, ACCOUNT_USER_ID, "Muse Owner");
+ insertMemberUser(connection, TENANT_ID, OTHER_USER_ID, "Other Owner");
+
+ String purchaseRecordId = "purchase-owned-completed";
+ String pendingPurchaseRecordId = "purchase-owned-pending";
+ String licenseRecordId = "license-owned-installed";
+ String publishRecordId = "publish-owned-under-review";
+ String otherOwnerPurchaseRecordId = "purchase-other-owner";
+ String otherTenantPurchaseRecordId = "purchase-other-tenant";
+
+ insertProjection(connection, TENANT_ID, ACCOUNT_USER_ID, "purchase", purchaseRecordId,
+ "muse_market_purchase", "11", "1", "completed", "P1R Account Owned Purchase",
+ 1299L, "corr-purchase-owned", base.plusMinutes(1), purchaseSnapshot());
+ insertProjection(connection, TENANT_ID, ACCOUNT_USER_ID, "purchase", pendingPurchaseRecordId,
+ "muse_market_purchase", "12", "1", "pending", "P1R Account Pending Purchase",
+ 299L, "corr-purchase-pending", base.plusMinutes(2), pendingPurchaseSnapshot());
+ insertProjection(connection, TENANT_ID, ACCOUNT_USER_ID, "license", licenseRecordId,
+ "muse_market_installation", "21", "1", "installed", "P1R Account Installed License",
+ null, "corr-license-owned", base.plusMinutes(3), licenseSnapshot());
+ insertProjection(connection, TENANT_ID, ACCOUNT_USER_ID, "publish", publishRecordId,
+ "muse_market_publish_request", "31", "1", "pending_review", "P1R Account Publish Record",
+ null, "corr-publish-owned", base.plusMinutes(4), publishSnapshot());
+ insertProjection(connection, TENANT_ID, OTHER_USER_ID, "purchase", otherOwnerPurchaseRecordId,
+ "muse_market_purchase", "41", "1", "completed", "Other Owner Purchase",
+ 9999L, "corr-other-owner", base.plusMinutes(5), purchaseSnapshot());
+ insertProjection(connection, OTHER_TENANT_ID, ACCOUNT_USER_ID, "purchase", otherTenantPurchaseRecordId,
+ "muse_market_purchase", "51", "1", "completed", "Other Tenant Purchase",
+ 8888L, "corr-other-tenant", base.plusMinutes(6), purchaseSnapshot());
+
+ return new SeedFacts(purchaseRecordId, pendingPurchaseRecordId, licenseRecordId, publishRecordId,
+ otherOwnerPurchaseRecordId, otherTenantPurchaseRecordId);
+ }
+ }
+
+ private void insertMemberUser(Connection connection, Long tenantId, Long userId, String nickname)
+ throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO member_user(id, mobile, password, status, register_ip, register_terminal,
+ login_ip, login_date, nickname, avatar, name, sex, birthday, area_id,
+ mark, point, tag_ids, level_id, experience, group_id, tenant_id)
+ VALUES (?, ?, 'encoded-password', 0, '127.0.0.1', 1,
+ '127.0.0.1', CURRENT_TIMESTAMP, ?, '', ?, 0, CURRENT_TIMESTAMP, 0,
+ '', 0, NULL, NULL, 0, NULL, ?)
+ """)) {
+ statement.setLong(1, userId);
+ statement.setString(2, "1380000" + userId);
+ statement.setString(3, nickname);
+ statement.setString(4, nickname);
+ statement.setLong(5, tenantId);
+ assertEquals(1, statement.executeUpdate(), "seed member_user fixture 必须插入一行");
+ }
+ }
+
+ private void insertProjection(Connection connection, Long tenantId, Long accountUserId, String recordType,
+ String recordId, String sourceTable, String sourceId, String sourceRevision,
+ String sourceStatus, String title, Long amount, String correlationId,
+ LocalDateTime occurredAt, String snapshot) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO muse_account_record_projection(account_user_id, record_type, record_id,
+ source_table, source_id, source_revision,
+ source_status, title, amount, occurred_at,
+ correlation_id, projection_snapshot, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?)
+ """)) {
+ statement.setLong(1, accountUserId);
+ statement.setString(2, recordType);
+ statement.setString(3, recordId);
+ statement.setString(4, sourceTable);
+ statement.setString(5, sourceId);
+ statement.setString(6, sourceRevision);
+ statement.setString(7, sourceStatus);
+ statement.setString(8, title);
+ if (amount == null) {
+ statement.setObject(9, null);
+ } else {
+ statement.setLong(9, amount);
+ }
+ statement.setTimestamp(10, Timestamp.valueOf(occurredAt));
+ statement.setString(11, correlationId);
+ statement.setString(12, snapshot);
+ statement.setLong(13, tenantId);
+ assertEquals(1, statement.executeUpdate(), "seed account projection 必须插入一行");
+ }
+ }
+
+ private String purchaseSnapshot() {
+ return """
+ {
+ "assetType": "work",
+ "purchaseType": "one_time",
+ "externalOrderRef": "%s",
+ "authorizationResultSummary": "授权已写入"
+ }
+ """.formatted(RAW_EXTERNAL_ORDER_REF);
+ }
+
+ private String pendingPurchaseSnapshot() {
+ return """
+ {
+ "assetType": "work",
+ "purchaseType": "subscription",
+ "externalOrderRef": "ORDER-PENDING-001122",
+ "authorizationResult": "等待外部确认"
+ }
+ """;
+ }
+
+ private String licenseSnapshot() {
+ return """
+ {
+ "assetType": "work",
+ "publisherName": "Muse Publisher",
+ "version": "1.2.0",
+ "licenseScope": "read_only",
+ "actionPolicy": {
+ "recheckReasons": ["none"],
+ "installPolicy": "allowed",
+ "bindPolicy": "allowed"
+ },
+ "installSummary": "已安装到工作台",
+ "bindingSummary": "已绑定当前作品"
+ }
+ """;
+ }
+
+ private String publishSnapshot() {
+ return """
+ {
+ "assetType": "work",
+ "version": "0.9.0",
+ "marketStatus": "not_listed",
+ "blockingReason": "等待审核"
+ }
+ """;
+ }
+
+ private void assertNoAccountMutationDuring(String message, CheckedOperation operation) throws Exception {
+ AccountFactSnapshot before = accountFactSnapshot();
+ operation.run();
+ assertEquals(before, accountFactSnapshot(), message);
+ }
+
+ private AccountFactSnapshot accountFactSnapshot() throws SQLException {
+ return new AccountFactSnapshot(
+ tableSnapshot("member_user", "tenant_id, id"),
+ tableSnapshot("muse_account_record_projection", "tenant_id, record_type, record_id"),
+ tableSnapshot("muse_account_command", "tenant_id, id"),
+ tableSnapshot("muse_account_audit", "tenant_id, id"),
+ tableSnapshot("muse_account_event_publish_outbox", "tenant_id, id"),
+ tableSnapshot("muse_market_account_projection", "tenant_id, id")
+ );
+ }
+
+ private List tableSnapshot(String tableName, String orderBy) throws SQLException {
+ String sql = """
+ SELECT row_to_json(t)::text
+ FROM %s t
+ ORDER BY %s
+ """.formatted(tableName, orderBy);
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql);
+ ResultSet resultSet = statement.executeQuery()) {
+ List rows = new ArrayList<>();
+ while (resultSet.next()) {
+ rows.add(resultSet.getString(1));
+ }
+ return rows;
+ }
+ }
+
+ private void createMemberUserFixtureTable() {
+ try (Connection connection = dataSource.getConnection();
+ Statement statement = connection.createStatement()) {
+ statement.execute("""
+ CREATE TABLE IF NOT EXISTS member_user (
+ id BIGINT PRIMARY KEY,
+ mobile VARCHAR(32),
+ password VARCHAR(255),
+ status INTEGER,
+ register_ip VARCHAR(64),
+ register_terminal INTEGER,
+ login_ip VARCHAR(64),
+ login_date TIMESTAMP,
+ nickname VARCHAR(64),
+ avatar VARCHAR(512),
+ name VARCHAR(64),
+ sex INTEGER,
+ birthday TIMESTAMP,
+ area_id INTEGER,
+ mark VARCHAR(255),
+ point INTEGER,
+ tag_ids VARCHAR(255),
+ level_id BIGINT,
+ experience INTEGER,
+ group_id BIGINT,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+ )
+ """);
+ } catch (SQLException exception) {
+ throw new AssertionError("创建 test-local member_user fixture 表失败", exception);
+ }
+ }
+
+ private static CompletedApprovalSettings settings() {
+ if (cachedSettings == null) {
+ cachedSettings = CompletedApprovalSettings.fromPropertiesAndEnvironment();
+ }
+ return cachedSettings;
+ }
+
+ private static void cleanSchema(Flyway flyway, CompletedApprovalSettings settings) {
+ try {
+ flyway.clean();
+ } catch (RuntimeException exception) {
+ throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception);
+ }
+ }
+
+ private static MigrateResult migrateSchema(Flyway flyway, CompletedApprovalSettings settings) {
+ try {
+ return flyway.migrate();
+ } catch (RuntimeException exception) {
+ throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception);
+ }
+ }
+
+ private static void ensureTestDatabaseExists(CompletedApprovalSettings settings) {
+ String databaseName = jdbcDatabaseName(settings.jdbcUrl());
+ try (Connection connection = DriverManager.getConnection(
+ maintenanceJdbcUrl(settings.jdbcUrl()), settings.jdbcUser(), settings.jdbcPassword());
+ Statement statement = connection.createStatement()) {
+ connection.setAutoCommit(true);
+ // 专属 _test 库缺失时自动创建,避免环境准备缺口吞掉真实 PG 验收证据。
+ statement.execute("CREATE DATABASE " + quotedIdentifier(databaseName));
+ } catch (SQLException exception) {
+ if ("42P04".equals(exception.getSQLState())) {
+ return;
+ }
+ throw sanitizedSqlFailure("创建 PostgreSQL _test 数据库失败", settings, exception);
+ }
+ }
+
+ private static AssertionError sanitizedFlywayFailure(String action, CompletedApprovalSettings settings,
+ RuntimeException exception) {
+ String sanitizedMessage = Objects.toString(exception.getMessage(), "")
+ .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl()))
+ .replace("for user '" + settings.jdbcUser() + "'", "for user ''");
+ return new AssertionError(action + ": " + sanitizedMessage);
+ }
+
+ private static AssertionError sanitizedSqlFailure(String action, CompletedApprovalSettings settings,
+ SQLException exception) {
+ String sanitizedMessage = Objects.toString(exception.getMessage(), "")
+ .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl()))
+ .replace(settings.jdbcUser(), "");
+ return new AssertionError(action + ": " + sanitizedMessage);
+ }
+
+ private static String requiredProperty(String name) {
+ String value = System.getProperty(name);
+ assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name);
+ return value;
+ }
+
+ private static String optionalProperty(String name) {
+ String value = System.getProperty(name);
+ return value == null || value.isBlank() ? null : value;
+ }
+
+ private static String firstNonBlankProperty(String primaryName, String fallbackName) {
+ String primary = optionalProperty(primaryName);
+ return primary == null ? requiredProperty(fallbackName) : primary;
+ }
+
+ private static String requiredPasswordEnvironment() {
+ String password = firstNonBlankEnvironment("P1R_ACCOUNT_MARKET_RECORDS_COMPLETED_PASSWORD",
+ "P1R_ACCOUNT_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD");
+ assertTrue(password != null,
+ "缺少必需环境变量: P1R_ACCOUNT_MARKET_RECORDS_COMPLETED_PASSWORD、P1R_ACCOUNT_COMPLETED_PASSWORD、"
+ + "P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD");
+ return password;
+ }
+
+ private static String firstNonBlankEnvironment(String... names) {
+ // 数据库密码只能来自环境变量,避免 Surefire XML 或 JVM 参数泄露。
+ for (String name : names) {
+ String value = System.getenv(name);
+ if (value != null && !value.isBlank()) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ private static void assertNoPasswordSystemProperties() {
+ Properties properties = System.getProperties();
+ List passwordProperties = properties.stringPropertyNames().stream()
+ .filter(P1rAccountMarketRecordsCompletedApprovalIT::isForbiddenPasswordSystemProperty)
+ .sorted()
+ .toList();
+ assertTrue(passwordProperties.isEmpty(),
+ "数据库密码不能通过 JVM system property 传入: " + passwordProperties);
+ }
+
+ private static boolean isForbiddenPasswordSystemProperty(String name) {
+ String normalized = name.toLowerCase(Locale.ROOT);
+ return normalized.contains("password")
+ && (normalized.startsWith("p1r.")
+ || normalized.startsWith("p1r_")
+ || normalized.contains(".flyway.")
+ || normalized.contains(".account.")
+ || normalized.contains(".datasource."));
+ }
+
+ private static void assertNoCredentialQuery(String url) {
+ int queryStart = url.indexOf('?');
+ if (queryStart < 0) {
+ return;
+ }
+ String query = url.substring(queryStart + 1);
+ for (String parameter : query.split("&")) {
+ String key = parameter;
+ int equalsStart = key.indexOf('=');
+ if (equalsStart >= 0) {
+ key = key.substring(0, equalsStart);
+ }
+ assertFalse(isCredentialQueryKey(key),
+ "p1r.flyway.url 不能携带凭据 query 参数;请通过用户名属性和密码环境变量传入");
+ }
+ }
+
+ private static boolean isCredentialQueryKey(String rawKey) {
+ String key = rawKey.trim().toLowerCase(Locale.ROOT).replace('-', '_');
+ return CREDENTIAL_QUERY_KEYS.contains(key)
+ || key.endsWith("_token")
+ || key.endsWith("_secret")
+ || key.endsWith("_password");
+ }
+
+ private static void assertTestDatabaseUrl(String url) {
+ String databaseName = jdbcDatabaseName(url);
+ assertTrue(databaseName.endsWith("_test"),
+ "p1r.flyway.url 必须指向 _test 后缀隔离库,避免清理非测试库: " + maskedUrl(url));
+ }
+
+ private static String resolveMuseSqlLocation(String requestedLocations) {
+ assertEquals("filesystem:sql/muse", requestedLocations,
+ "P1R Account Market Records completed approval IT 要求显式使用 filesystem:sql/muse");
+ Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath();
+ String relativeLocation = requestedLocations.substring("filesystem:".length());
+ for (Path cursor = current; cursor != null; cursor = cursor.getParent()) {
+ Path candidate = cursor.resolve(relativeLocation);
+ if (Files.isDirectory(candidate)) {
+ return "filesystem:" + candidate;
+ }
+ }
+ throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current);
+ }
+
+ private static String jdbcDatabaseName(String url) {
+ String urlWithoutQuery = jdbcUrlWithoutQuery(url);
+ int databaseStart = urlWithoutQuery.lastIndexOf('/');
+ assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1,
+ "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url));
+ return urlWithoutQuery.substring(databaseStart + 1);
+ }
+
+ private static String jdbcUrlWithoutQuery(String url) {
+ int queryStart = url.indexOf('?');
+ return queryStart < 0 ? url : url.substring(0, queryStart);
+ }
+
+ private static String maintenanceJdbcUrl(String url) {
+ String urlWithoutQuery = jdbcUrlWithoutQuery(url);
+ int databaseStart = urlWithoutQuery.lastIndexOf('/');
+ assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1,
+ "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url));
+ String querySuffix = url.indexOf('?') < 0 ? "" : url.substring(url.indexOf('?'));
+ return urlWithoutQuery.substring(0, databaseStart + 1) + "postgres" + querySuffix;
+ }
+
+ private static String quotedIdentifier(String identifier) {
+ assertTrue(identifier.matches("[A-Za-z0-9_]+"),
+ "测试数据库名只能包含字母、数字和下划线: " + identifier);
+ return "\"" + identifier.replace("\"", "\"\"") + "\"";
+ }
+
+ private static String maskedUrl(String url) {
+ String urlWithoutQuery = jdbcUrlWithoutQuery(url);
+ int databaseStart = urlWithoutQuery.lastIndexOf('/');
+ if (databaseStart < 0) {
+ return maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url);
+ }
+ String prefix = urlWithoutQuery.substring(0, databaseStart + 1);
+ String database = urlWithoutQuery.substring(databaseStart + 1);
+ return maskJdbcHost(prefix) + database + maskedQuerySuffix(url);
+ }
+
+ private static String maskedQuerySuffix(String url) {
+ return url.indexOf('?') < 0 ? "" : "?";
+ }
+
+ private static String maskJdbcHost(String urlPart) {
+ return urlPart.replaceAll("//([^:/?#]+)", "//");
+ }
+
+ private static void redactFlywaySystemProperties(String url, String user) {
+ captureOriginalFlywaySystemProperties();
+ System.setProperty("p1r.flyway.url", maskedUrl(url));
+ System.setProperty("p1r.flyway.user", user == null || user.isBlank() ? "" : "");
+ }
+
+ private static void captureOriginalFlywaySystemProperties() {
+ if (originalFlywayPropertiesCaptured) {
+ return;
+ }
+ // 该 IT 会为日志脱敏 p1r.flyway.*;先保存原值,避免同一 Surefire JVM 中污染后续 Flyway IT。
+ originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url");
+ originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user");
+ originalFlywayPropertiesCaptured = true;
+ }
+
+ private static void restoreOriginalFlywaySystemProperties() {
+ if (!originalFlywayPropertiesCaptured) {
+ return;
+ }
+ // 恢复用户传入的原始连接属性,让组合运行的 Flyway 验收测试继续读取真实 _test 库地址。
+ 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 silenceFlywayInfoLogs() {
+ try {
+ Object flywayLogger = LoggerFactory.getLogger("org.flywaydb");
+ Class> levelClass = Class.forName("ch.qos.logback.classic.Level");
+ Object warnLevel = levelClass.getField("WARN").get(null);
+ flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel);
+ } catch (ReflectiveOperationException | LinkageError ignored) {
+ // 日志实现不是 logback 时不影响迁移验收;测试自身仍只输出脱敏 URL。
+ }
+ }
+
+ private record SeedFacts(String purchaseRecordId,
+ String pendingPurchaseRecordId,
+ String licenseRecordId,
+ String publishRecordId,
+ String otherOwnerPurchaseRecordId,
+ String otherTenantPurchaseRecordId) {
+ }
+
+ private record AccountFactSnapshot(List memberUsers,
+ List accountRecordProjections,
+ List commands,
+ List audits,
+ List accountOutboxes,
+ List marketOutboxes) {
+ }
+
+ @FunctionalInterface
+ private interface CheckedOperation {
+
+ void run() throws Exception;
+ }
+
+ private record CompletedApprovalSettings(String jdbcUrl,
+ String jdbcUser,
+ String jdbcPassword,
+ String flywayLocations) {
+
+ static CompletedApprovalSettings fromPropertiesAndEnvironment() {
+ assertNoPasswordSystemProperties();
+ String url = firstNonBlankProperty("p1r.account.market-records.completed-approval.jdbc-url",
+ "p1r.flyway.url");
+ String user = firstNonBlankProperty("p1r.account.market-records.completed-approval.jdbc-user",
+ "p1r.flyway.user");
+ String password = requiredPasswordEnvironment();
+ String locations = requiredProperty("p1r.flyway.locations");
+ assertNoCredentialQuery(url);
+ assertTestDatabaseUrl(url);
+ return new CompletedApprovalSettings(url, user, password, locations);
+ }
+ }
+
+ static class NoWriteAccountProjectionApi implements MuseAccountRecordProjectionApi {
+
+ @Override
+ public void upsertRecordProjection(MuseAccountRecordProjectionSaveReqDTO projection) {
+ throw new AssertionError("Account Market Records read 不允许调用 Account 投影写端口");
+ }
+ }
+
+ @SpringBootConfiguration
+ @ImportAutoConfiguration({
+ JacksonAutoConfiguration.class,
+ HttpMessageConvertersAutoConfiguration.class,
+ DataSourceAutoConfiguration.class,
+ DataSourceTransactionManagerAutoConfiguration.class,
+ JdbcTemplateAutoConfiguration.class,
+ TransactionAutoConfiguration.class,
+ RestTemplateAutoConfiguration.class,
+ WebMvcAutoConfiguration.class,
+ MuseDataSourceAutoConfiguration.class,
+ MuseMybatisAutoConfiguration.class,
+ MybatisPlusAutoConfiguration.class,
+ MybatisPlusJoinAutoConfiguration.class,
+ MuseWebAutoConfiguration.class
+ })
+ @Import({
+ AppAccountMarketRecordController.class,
+ AccountMarketRecordServiceImpl.class,
+ MarketAccountProjectionProvider.class,
+ MarketAccountProjectionOutboxServiceImpl.class,
+ SpringUtil.class
+ })
+ static class AccountMarketRecordsCompletedApprovalConfiguration {
+
+ @Bean
+ MuseAccountRecordProjectionApi accountRecordProjectionApi() {
+ return new NoWriteAccountProjectionApi();
+ }
+
+ @Bean
+ TenantLineInnerInterceptor tenantLineInnerInterceptor(MybatisPlusInterceptor interceptor) {
+ TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(
+ new TenantDatabaseInterceptor(new TenantProperties()));
+ MyBatisUtils.addInterceptor(interceptor, inner, 0);
+ return inner;
+ }
+
+ @Bean
+ ApiErrorLogCommonApi apiErrorLogCommonApi() {
+ return new ApiErrorLogCommonApi() {
+ @Override
+ public CommonResult createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) {
+ return CommonResult.success(true);
+ }
+ };
+ }
+ }
+}
diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketAdminAppealCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketAdminAppealCompletedApprovalIT.java
new file mode 100644
index 00000000..68de5e72
--- /dev/null
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketAdminAppealCompletedApprovalIT.java
@@ -0,0 +1,1076 @@
+package cn.iocoder.muse.server.framework.api;
+
+import cn.hutool.extra.spring.SpringUtil;
+import cn.iocoder.muse.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
+import cn.iocoder.muse.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO;
+import cn.iocoder.muse.framework.common.enums.UserTypeEnum;
+import cn.iocoder.muse.framework.common.pojo.CommonResult;
+import cn.iocoder.muse.framework.common.util.json.JsonUtils;
+import cn.iocoder.muse.framework.datasource.config.MuseDataSourceAutoConfiguration;
+import cn.iocoder.muse.framework.mybatis.config.MuseMybatisAutoConfiguration;
+import cn.iocoder.muse.framework.security.core.LoginUser;
+import cn.iocoder.muse.framework.security.core.service.SecurityFrameworkService;
+import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.muse.framework.web.config.MuseWebAutoConfiguration;
+import cn.iocoder.muse.module.market.application.muse.MarketAppealServiceImpl;
+import cn.iocoder.muse.module.market.application.muse.MarketCommandServiceImpl;
+import cn.iocoder.muse.module.market.controller.admin.muse.AdminMuseMarketAppealController;
+import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
+import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration;
+import org.flywaydb.core.Flyway;
+import org.flywaydb.core.api.output.MigrateResult;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
+import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
+import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
+import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
+import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+import javax.sql.DataSource;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN;
+import static cn.iocoder.muse.module.market.enums.ErrorCodeConstants.MARKET_API_VERSION_UNSUPPORTED;
+import static cn.iocoder.muse.module.market.enums.ErrorCodeConstants.MARKET_APPEAL_STATUS_CONFLICT;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * P1R Market 管理端申诉 completed approval:3 个管理端申诉 operation 的 HTTP + 真实 PostgreSQL 证据。
+ *
+ * 本测试只连接显式传入的 PostgreSQL {@code _test} 隔离库,经真实 {@code /admin-api/muse/**}
+ * 前缀进入 Controller,并启用 Spring method security 校验 {@code @PreAuthorize("@ss.hasPermission(...)")}。
+ * 测试范围限定为 adminListAppeals、adminGetAppeal、adminResolveAppeal;非 restore 处理分支不触发治理预览、
+ * 资产恢复、外部服务或跨 BC 调用。
+ */
+@SpringBootTest(
+ classes = P1rMarketAdminAppealCompletedApprovalIT.AdminAppealCompletedApprovalConfiguration.class,
+ webEnvironment = SpringBootTest.WebEnvironment.MOCK
+)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class P1rMarketAdminAppealCompletedApprovalIT {
+
+ private static final Long TENANT_ID = 100L;
+ private static final Long OTHER_TENANT_ID = 200L;
+ private static final Long ADMIN_USER_ID = 9001L;
+ private static final Long APPELLANT_USER_ID = 9101L;
+ private static final Long OTHER_APPELLANT_USER_ID = 9102L;
+ private static final String API_VERSION = "1";
+ private static final String QUERY_PERMISSION = "muse:market:appeal:query";
+ private static final String RESOLVE_PERMISSION = "muse:market:appeal:resolve";
+ private static final Set CREDENTIAL_QUERY_KEYS = Set.of(
+ "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password",
+ "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token");
+
+ private static volatile CompletedApprovalSettings cachedSettings;
+ private static volatile boolean originalFlywayPropertiesCaptured;
+ private static volatile String originalFlywayUrlSystemProperty;
+ private static volatile String originalFlywayUserSystemProperty;
+
+ @Autowired
+ private DataSource dataSource;
+ @Autowired
+ private WebApplicationContext webApplicationContext;
+ @Autowired
+ private TestSecurityFrameworkService securityFrameworkService;
+
+ private MockMvc mockMvc;
+ private SeedFacts seedFacts;
+
+ @DynamicPropertySource
+ static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) {
+ CompletedApprovalSettings settings = settings();
+ redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser());
+ registry.add("spring.application.name", () -> "p1r-market-admin-appeal-completed-approval-it");
+ registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module.market");
+ registry.add("muse.web.admin-ui.url", () -> "http://localhost");
+ registry.add("spring.datasource.url", settings::jdbcUrl);
+ registry.add("spring.datasource.username", settings::jdbcUser);
+ registry.add("spring.datasource.password", settings::jdbcPassword);
+ registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
+ registry.add("spring.main.banner-mode", () -> "off");
+ registry.add("spring.main.lazy-initialization", () -> "true");
+ registry.add("mybatis-plus.global-config.db-config.id-type", () -> "AUTO");
+ }
+
+ @BeforeAll
+ void migrateMarketSchema() {
+ CompletedApprovalSettings settings = settings();
+ silenceFlywayInfoLogs();
+ ensureTestDatabaseExists(settings);
+ Flyway flyway = Flyway.configure()
+ .dataSource(settings.jdbcUrl(), settings.jdbcUser(), settings.jdbcPassword())
+ .locations(resolveMuseSqlLocation(settings.flywayLocations()))
+ .schemas("public")
+ .defaultSchema("public")
+ .cleanDisabled(false)
+ .load();
+ cleanSchema(flyway, settings);
+ MigrateResult result = migrateSchema(flyway, settings);
+ assertTrue(result.migrationsExecuted >= 23,
+ "Market admin appeal completed approval 必须迁移到包含 V23 命令唯一索引修复的 latest schema,实际: "
+ + result.migrationsExecuted);
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
+ resetMarketTables();
+ this.seedFacts = seedMarketFacts();
+ setRuntimeContext();
+ securityFrameworkService.allowPermissions();
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ TenantContextHolder.clear();
+ }
+
+ @AfterAll
+ void restoreFlywaySystemProperties() {
+ restoreOriginalFlywaySystemProperties();
+ }
+
+ @Test
+ void should_rejectUnsafeDatabaseConfigurationInputs() {
+ System.setProperty("p1r.market.admin-appeal.password", "must-not-be-used");
+ try {
+ AssertionError error = assertThrows(AssertionError.class,
+ P1rMarketAdminAppealCompletedApprovalIT::assertNoPasswordSystemProperties);
+ assertTrue(error.getMessage().contains("p1r.market.admin-appeal.password"),
+ "拒绝 JVM password system property 时必须指出属性名");
+ } finally {
+ System.clearProperty("p1r.market.admin-appeal.password");
+ }
+
+ AssertionError credentialQueryError = assertThrows(AssertionError.class,
+ () -> assertNoCredentialQuery("jdbc:postgresql://localhost:5432/muse_test?password=secret"));
+ assertTrue(credentialQueryError.getMessage().contains("p1r.flyway.url 不能携带凭据 query 参数"),
+ "拒绝 JDBC credential query 时必须说明连接串只能通过环境变量传密码");
+
+ AssertionError databaseNameError = assertThrows(AssertionError.class,
+ () -> assertTestDatabaseUrl("jdbc:postgresql://localhost:5432/muse_prod"));
+ assertTrue(databaseNameError.getMessage().contains("_test"),
+ "拒绝非 _test 数据库时必须指出隔离库后缀要求");
+ }
+
+ @Test
+ void should_listAppealsWithFiltersTenantIsolationAndNoWrite() throws Exception {
+ assertNoMarketMutationDuring("adminListAppeals 是纯读 operation,不能写 Market fact",
+ () -> mockMvc.perform(get("/admin-api/muse/market/appeals")
+ .header("X-API-Version", API_VERSION)
+ .param("pageNo", "1")
+ .param("pageSize", "10")
+ .param("appealType", "review_rejection")
+ .param("status", "pending"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.list[0].appealId").value(String.valueOf(seedFacts.pendingAppealId())))
+ .andExpect(jsonPath("$.data.list[0].appealType").value("review_rejection"))
+ .andExpect(jsonPath("$.data.list[0].status").value("pending"))
+ .andExpect(jsonPath("$.data.list[0].appellantId").value(String.valueOf(APPELLANT_USER_ID)))
+ .andExpect(jsonPath("$.data.list[0].appellantName").value("publisher-" + APPELLANT_USER_ID))
+ .andExpect(jsonPath("$.data.list[0].assetId").value(String.valueOf(seedFacts.primaryAssetId())))
+ .andExpect(jsonPath("$.data.list[0].assetName").value("P1R Appeal Market Work")));
+
+ assertNoMarketMutationDuring("adminListAppeals appellantId 过滤仍是纯读 operation",
+ () -> mockMvc.perform(get("/admin-api/muse/market/appeals")
+ .header("X-API-Version", API_VERSION)
+ .param("appellantId", String.valueOf(OTHER_APPELLANT_USER_ID)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.total").value(1))
+ .andExpect(jsonPath("$.data.list[0].appealId").value(String.valueOf(seedFacts.reviewingAppealId())))
+ .andExpect(jsonPath("$.data.list[0].status").value("reviewing")));
+
+ assertEquals(QUERY_PERMISSION, securityFrameworkService.lastPermission(),
+ "adminListAppeals 必须经 method security 校验 query 权限");
+ }
+
+ @Test
+ void should_getAppealDetailWithHistoryMaterialsAndNoWrite() throws Exception {
+ assertNoMarketMutationDuring("adminGetAppeal 是纯读 operation,不能写 Market fact",
+ () -> mockMvc.perform(get("/admin-api/muse/market/appeals/{appealId}", seedFacts.pendingAppealId())
+ .header("X-API-Version", API_VERSION))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.appealId").value(String.valueOf(seedFacts.pendingAppealId())))
+ .andExpect(jsonPath("$.data.assetName").value("P1R Appeal Market Work"))
+ .andExpect(jsonPath("$.data.reason").value("申诉发布审核被拒"))
+ .andExpect(jsonPath("$.data.evidenceMaterials", hasItem("evidence://draft-check")))
+ .andExpect(jsonPath("$.data.originalResult").value("rejected"))
+ .andExpect(jsonPath("$.data.currentGovernanceStatus").value("listed"))
+ .andExpect(jsonPath("$.data.processingHistory", hasSize(2)))
+ .andExpect(jsonPath("$.data.processingHistory[*].action", hasItem("submitted")))
+ .andExpect(jsonPath("$.data.processingHistory[*].action", hasItem("review_started")))
+ .andExpect(jsonPath("$.data.supplementRecords", hasSize(1)))
+ .andExpect(jsonPath("$.data.supplementRecords[0].attachmentIds", hasItem("attachment-1"))));
+
+ assertEquals(QUERY_PERMISSION, securityFrameworkService.lastPermission(),
+ "adminGetAppeal 必须经 method security 校验 query 权限");
+ }
+
+ @Test
+ void should_resolveAppealAsMaintainedWithCommandReplayAndAuditFacts() throws Exception {
+ String commandId = "p1r-market-admin-appeal-resolve";
+ String requestBody = """
+ {
+ "commandId":"%s",
+ "expectedStatus":"pending",
+ "resolution":"maintained",
+ "reason":"维持原处理决定",
+ "evidenceTags":["manual-review","policy-match"],
+ "requireSupplement":false
+ }
+ """.formatted(commandId);
+
+ mockMvc.perform(post("/admin-api/muse/market/appeals/{appealId}/resolve", seedFacts.pendingAppealId())
+ .header("X-API-Version", API_VERSION)
+ .contentType("application/json")
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data").value(true));
+
+ mockMvc.perform(post("/admin-api/muse/market/appeals/{appealId}/resolve", seedFacts.pendingAppealId())
+ .header("X-API-Version", API_VERSION)
+ .contentType("application/json")
+ .content(requestBody))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data").value(true));
+
+ assertEquals("maintained", appealStatus(seedFacts.pendingAppealId()), "处理后申诉状态必须落为 maintained");
+ assertEquals("维持原处理决定", appealResolution(seedFacts.pendingAppealId()), "非补充处理必须保存处理理由");
+ assertEquals(ADMIN_USER_ID, appealResolverId(seedFacts.pendingAppealId()), "处理人必须写入当前 adminUserId");
+ assertNotNull(appealResolvedAt(seedFacts.pendingAppealId()), "处理成功必须写 resolved_at");
+ assertEquals(2, appealRevision(seedFacts.pendingAppealId()), "处理成功必须推动申诉 revision");
+ assertEquals(1, commandCount(commandId), "重复 resolve 同一 commandId 只能保留一条 command fact");
+ assertEquals(1, resolvedEventCount(seedFacts.pendingAppealId(), commandId),
+ "重复 resolve 同一 commandId 只能写一条 resolved 事件");
+ assertEquals(1, commandCountByOperation("adminResolveAppeal"),
+ "adminResolveAppeal 必须记录 completed command fact");
+ assertEquals("completed", commandStatus(commandId), "resolve command 必须进入 completed 终态");
+ assertEquals("maintained", statusFromCommandResult(commandId), "command result_snapshot 必须记录处理后的状态");
+ assertEquals("maintained", statusFromResolvedEventSnapshot(commandId),
+ "resolved event_snapshot 必须记录 targetStatus");
+ assertEquals("manual-review", firstEvidenceTagFromResolvedEventSnapshot(commandId),
+ "resolved event_snapshot 必须记录证据标签");
+ assertEquals(0, governanceActionCount(), "maintained 分支不能消费治理预览或写恢复上架治理动作");
+ assertEquals(RESOLVE_PERMISSION, securityFrameworkService.lastPermission(),
+ "adminResolveAppeal 必须经 method security 校验 resolve 权限");
+ }
+
+ @Test
+ void should_rollbackCommandAndEventWhenExpectedStatusConflicts() throws Exception {
+ String commandId = "p1r-market-admin-appeal-conflict";
+ MarketFactSnapshot before = marketFactSnapshot();
+
+ mockMvc.perform(post("/admin-api/muse/market/appeals/{appealId}/resolve", seedFacts.pendingAppealId())
+ .header("X-API-Version", API_VERSION)
+ .contentType("application/json")
+ .content("""
+ {
+ "commandId":"%s",
+ "expectedStatus":"reviewing",
+ "resolution":"maintained",
+ "reason":"错误期望状态"
+ }
+ """.formatted(commandId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(MARKET_APPEAL_STATUS_CONFLICT.getCode()))
+ .andExpect(jsonPath("$.data").doesNotExist());
+
+ assertEquals(before, marketFactSnapshot(), "expectedStatus 冲突时必须事务回滚,不能留下 reserved command 或 resolved event");
+ assertEquals("pending", appealStatus(seedFacts.pendingAppealId()), "状态冲突不能改变申诉状态");
+ assertEquals(0, commandCount(commandId), "失败 resolve 不能留下 command 预占行");
+ }
+
+ @Test
+ void should_enforceApiVersionAndMethodSecurityThroughRealAdminHttpEntry() throws Exception {
+ assertNoMarketMutationDuring("API version deny 不能写 Market fact",
+ () -> mockMvc.perform(get("/admin-api/muse/market/appeals")
+ .header("X-API-Version", "0"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(MARKET_API_VERSION_UNSUPPORTED.getCode()))
+ .andExpect(jsonPath("$.data").doesNotExist()));
+
+ securityFrameworkService.denyPermissions();
+ assertNoMarketMutationDuring("RBAC deny 不能写 Market fact",
+ () -> mockMvc.perform(post("/admin-api/muse/market/appeals/{appealId}/resolve",
+ seedFacts.pendingAppealId())
+ .header("X-API-Version", API_VERSION)
+ .contentType("application/json")
+ .content("""
+ {
+ "commandId":"p1r-market-admin-appeal-rbac-deny",
+ "expectedStatus":"pending",
+ "resolution":"maintained",
+ "reason":"无权限路径"
+ }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(FORBIDDEN.getCode()))
+ .andExpect(jsonPath("$.data").doesNotExist()));
+ assertEquals(RESOLVE_PERMISSION, securityFrameworkService.lastPermission(),
+ "RBAC deny 必须在 method security 层命中 resolve 权限表达式");
+ }
+
+ private void setRuntimeContext() {
+ TenantContextHolder.setTenantId(TENANT_ID);
+ LoginUser loginUser = new LoginUser();
+ loginUser.setId(ADMIN_USER_ID);
+ loginUser.setUserType(UserTypeEnum.ADMIN.getValue());
+ loginUser.setTenantId(TENANT_ID);
+ loginUser.setVisitTenantId(TENANT_ID);
+ SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
+ }
+
+ private void resetMarketTables() throws SQLException {
+ try (Connection connection = dataSource.getConnection();
+ Statement statement = connection.createStatement()) {
+ // 每个用例只重置 Market 申诉相关 fact,保证读写断言能精确追踪本批 operation。
+ statement.execute("""
+ TRUNCATE TABLE
+ muse_market_command,
+ muse_market_appeal_material,
+ muse_market_appeal_event,
+ muse_market_appeal,
+ muse_market_governance_action,
+ muse_market_governance_preview,
+ muse_market_authorization_summary,
+ muse_market_installation,
+ muse_market_publish_request,
+ muse_market_asset_version,
+ muse_market_asset
+ RESTART IDENTITY CASCADE
+ """);
+ }
+ }
+
+ private SeedFacts seedMarketFacts() throws SQLException {
+ try (Connection connection = dataSource.getConnection()) {
+ Long primaryAssetId = insertAsset(connection, TENANT_ID, APPELLANT_USER_ID,
+ "P1R Appeal Market Work", "listed", "seed-asset-primary");
+ Long secondaryAssetId = insertAsset(connection, TENANT_ID, OTHER_APPELLANT_USER_ID,
+ "P1R Reviewing Appeal Work", "delisted", "seed-asset-secondary");
+ Long otherTenantAssetId = insertAsset(connection, OTHER_TENANT_ID, APPELLANT_USER_ID,
+ "P1R Other Tenant Appeal Work", "listed", "seed-asset-other-tenant");
+ Long pendingAppealId = insertAppeal(connection, TENANT_ID, primaryAssetId, APPELLANT_USER_ID,
+ "review_rejection", "申诉发布审核被拒", "pending", "seed-appeal-pending");
+ Long reviewingAppealId = insertAppeal(connection, TENANT_ID, secondaryAssetId, OTHER_APPELLANT_USER_ID,
+ "delist", "申诉下架治理动作", "reviewing", "seed-appeal-reviewing");
+ Long otherTenantAppealId = insertAppeal(connection, OTHER_TENANT_ID, otherTenantAssetId, APPELLANT_USER_ID,
+ "review_rejection", "跨租户申诉", "pending", "seed-appeal-other-tenant");
+ insertAppealEvent(connection, TENANT_ID, pendingAppealId, "submitted", APPELLANT_USER_ID,
+ APPELLANT_USER_ID, "seed-event-submitted", "pending", "用户提交申诉", """
+ {"evidenceMaterials":["evidence://draft-check"],"originalResult":"rejected"}
+ """);
+ insertAppealEvent(connection, TENANT_ID, pendingAppealId, "review_started", ADMIN_USER_ID,
+ APPELLANT_USER_ID, "seed-event-review-started", "reviewing", "进入人工复核", """
+ {"processor":"admin","source":"p1r-seed"}
+ """);
+ insertAppealMaterial(connection, TENANT_ID, pendingAppealId, APPELLANT_USER_ID,
+ "seed-material-pending", """
+ {"descriptionSummary":"用户补充了脱敏摘要","attachmentIds":["attachment-1"]}
+ """);
+ return new SeedFacts(primaryAssetId, secondaryAssetId, otherTenantAssetId,
+ pendingAppealId, reviewingAppealId, otherTenantAppealId);
+ }
+ }
+
+ private Long insertAsset(Connection connection, Long tenantId, Long publisherId, String name,
+ String listingStatus, String commandId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO muse_market_asset(name, description, asset_type, category, source_id, publisher_id,
+ listing_status, license_type, status, current_version_id, rating,
+ install_count, tags, command_id, revision, tenant_id)
+ VALUES (?, ?, 'work', '10', ?, ?, ?, 'standard', 'published', NULL, 0, 0,
+ '["p1r","appeal"]'::jsonb, ?, 1, ?)
+ RETURNING id
+ """)) {
+ statement.setString(1, name);
+ statement.setString(2, name + " description");
+ statement.setLong(3, publisherId + 1000L);
+ statement.setLong(4, publisherId);
+ statement.setString(5, listingStatus);
+ statement.setString(6, commandId);
+ statement.setLong(7, tenantId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "seed Market asset 必须返回 id");
+ return resultSet.getLong(1);
+ }
+ }
+ }
+
+ private Long insertAppeal(Connection connection, Long tenantId, Long assetId, Long userId, String appealType,
+ String reason, String status, String commandId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO muse_market_appeal(asset_id, user_id, appeal_type, reason, status, resolution,
+ resolver_id, resolved_at, command_id, revision, tenant_id)
+ VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, 1, ?)
+ RETURNING id
+ """)) {
+ statement.setLong(1, assetId);
+ statement.setLong(2, userId);
+ statement.setString(3, appealType);
+ statement.setString(4, reason);
+ statement.setString(5, status);
+ statement.setString(6, commandId);
+ statement.setLong(7, tenantId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertTrue(resultSet.next(), "seed Market appeal 必须返回 id");
+ return resultSet.getLong(1);
+ }
+ }
+ }
+
+ private void insertAppealEvent(Connection connection, Long tenantId, Long appealId, String eventType,
+ Long actorUserId, Long ownerUserId, String commandId, String status,
+ String reason, String snapshot) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO muse_market_appeal_event(appeal_event_id, appeal_id, event_type, actor_user_id,
+ owner_user_id, command_id, request_hash, status, revision,
+ event_snapshot, reason, tenant_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?::jsonb, ?, ?)
+ """)) {
+ statement.setString(1, "appeal-event-" + commandId);
+ statement.setLong(2, appealId);
+ statement.setString(3, eventType);
+ statement.setLong(4, actorUserId);
+ statement.setLong(5, ownerUserId);
+ statement.setString(6, commandId);
+ statement.setString(7, fixedHash(commandId));
+ statement.setString(8, status);
+ statement.setString(9, snapshot);
+ statement.setString(10, reason);
+ statement.setLong(11, tenantId);
+ assertEquals(1, statement.executeUpdate(), "seed Market appeal event 必须插入一行");
+ }
+ }
+
+ private void insertAppealMaterial(Connection connection, Long tenantId, Long appealId, Long ownerUserId,
+ String commandId, String snapshot) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO muse_market_appeal_material(material_id, appeal_id, owner_user_id, material_type,
+ command_id, request_hash, status, revision,
+ material_snapshot, tenant_id)
+ VALUES (?, ?, ?, 'supplement', ?, ?, 'active', 1, ?::jsonb, ?)
+ """)) {
+ statement.setString(1, "appeal-material-" + commandId);
+ statement.setLong(2, appealId);
+ statement.setLong(3, ownerUserId);
+ statement.setString(4, commandId);
+ statement.setString(5, fixedHash(commandId));
+ statement.setString(6, snapshot);
+ statement.setLong(7, tenantId);
+ assertEquals(1, statement.executeUpdate(), "seed Market appeal material 必须插入一行");
+ }
+ }
+
+ private void assertNoMarketMutationDuring(String message, CheckedOperation operation) throws Exception {
+ MarketFactSnapshot before = marketFactSnapshot();
+ operation.run();
+ assertEquals(before, marketFactSnapshot(), message);
+ }
+
+ private MarketFactSnapshot marketFactSnapshot() throws SQLException {
+ return new MarketFactSnapshot(
+ tableSnapshot("muse_market_asset", "tenant_id, id"),
+ tableSnapshot("muse_market_appeal", "tenant_id, id"),
+ tableSnapshot("muse_market_appeal_event", "tenant_id, id"),
+ tableSnapshot("muse_market_appeal_material", "tenant_id, id"),
+ tableSnapshot("muse_market_command", "tenant_id, id"),
+ tableSnapshot("muse_market_governance_action", "tenant_id, id")
+ );
+ }
+
+ private List tableSnapshot(String tableName, String orderBy) throws SQLException {
+ String sql = """
+ SELECT row_to_json(t)::text
+ FROM %s t
+ ORDER BY %s
+ """.formatted(tableName, orderBy);
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql);
+ ResultSet resultSet = statement.executeQuery()) {
+ List rows = new ArrayList<>();
+ while (resultSet.next()) {
+ rows.add(resultSet.getString(1));
+ }
+ return rows;
+ }
+ }
+
+ private String appealStatus(Long appealId) throws SQLException {
+ return queryString("SELECT status FROM muse_market_appeal WHERE tenant_id = ? AND id = ?",
+ TENANT_ID, appealId);
+ }
+
+ private String appealResolution(Long appealId) throws SQLException {
+ return queryString("SELECT resolution FROM muse_market_appeal WHERE tenant_id = ? AND id = ?",
+ TENANT_ID, appealId);
+ }
+
+ private Long appealResolverId(Long appealId) throws SQLException {
+ return queryLong("SELECT resolver_id FROM muse_market_appeal WHERE tenant_id = ? AND id = ?",
+ TENANT_ID, appealId);
+ }
+
+ private String appealResolvedAt(Long appealId) throws SQLException {
+ return queryString("SELECT resolved_at::text FROM muse_market_appeal WHERE tenant_id = ? AND id = ?",
+ TENANT_ID, appealId);
+ }
+
+ private int appealRevision(Long appealId) throws SQLException {
+ return queryInt("SELECT revision FROM muse_market_appeal WHERE tenant_id = ? AND id = ?",
+ TENANT_ID, appealId);
+ }
+
+ private int commandCount(String commandId) throws SQLException {
+ return queryInt("SELECT COUNT(*) FROM muse_market_command WHERE tenant_id = ? AND command_id = ?",
+ TENANT_ID, commandId);
+ }
+
+ private int commandCountByOperation(String operationId) throws SQLException {
+ return queryInt("""
+ SELECT COUNT(*)
+ FROM muse_market_command
+ WHERE tenant_id = ?
+ AND operation_id = ?
+ AND status = 'completed'
+ """, TENANT_ID, operationId);
+ }
+
+ private String commandStatus(String commandId) throws SQLException {
+ return queryString("SELECT status FROM muse_market_command WHERE tenant_id = ? AND command_id = ?",
+ TENANT_ID, commandId);
+ }
+
+ private int resolvedEventCount(Long appealId, String commandId) throws SQLException {
+ return queryInt("""
+ SELECT COUNT(*)
+ FROM muse_market_appeal_event
+ WHERE tenant_id = ?
+ AND appeal_id = ?
+ AND command_id = ?
+ AND event_type = 'resolved'
+ """, TENANT_ID, appealId, commandId);
+ }
+
+ private int governanceActionCount() throws SQLException {
+ return queryInt("SELECT COUNT(*) FROM muse_market_governance_action WHERE tenant_id = ?", TENANT_ID);
+ }
+
+ private String statusFromCommandResult(String commandId) throws SQLException {
+ String payload = queryString("""
+ SELECT result_snapshot::text
+ FROM muse_market_command
+ WHERE tenant_id = ?
+ AND command_id = ?
+ """, TENANT_ID, commandId);
+ Map, ?> result = JsonUtils.parseObject(payload, Map.class);
+ assertNotNull(result, "Market command result_snapshot 必须是 JSON 对象");
+ return String.valueOf(result.get("status"));
+ }
+
+ private String statusFromResolvedEventSnapshot(String commandId) throws SQLException {
+ Map, ?> snapshot = resolvedEventSnapshot(commandId);
+ return String.valueOf(snapshot.get("targetStatus"));
+ }
+
+ @SuppressWarnings("unchecked")
+ private String firstEvidenceTagFromResolvedEventSnapshot(String commandId) throws SQLException {
+ Map, ?> snapshot = resolvedEventSnapshot(commandId);
+ Object tags = snapshot.get("evidenceTags");
+ assertTrue(tags instanceof List>, "resolved event_snapshot.evidenceTags 必须是数组");
+ return String.valueOf(((List