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) tags).get(0)); + } + + private Map resolvedEventSnapshot(String commandId) throws SQLException { + String payload = queryString(""" + SELECT event_snapshot::text + FROM muse_market_appeal_event + WHERE tenant_id = ? + AND command_id = ? + AND event_type = 'resolved' + """, TENANT_ID, commandId); + Map snapshot = JsonUtils.parseObject(payload, Map.class); + assertNotNull(snapshot, "Market resolved event_snapshot 必须是 JSON 对象"); + return snapshot; + } + + private int queryInt(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "计数查询必须返回一行"); + return resultSet.getInt(1); + } + } + } + + private Long queryLong(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "Long 查询必须返回一行"); + long value = resultSet.getLong(1); + assertFalse(resultSet.wasNull(), "Long 查询结果不能为 NULL"); + return value; + } + } + } + + private String queryString(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "事实查询必须返回一行"); + return resultSet.getString(1); + } + } + } + + private void bind(PreparedStatement statement, Object... args) throws SQLException { + for (int i = 0; i < args.length; i++) { + Object value = args[i]; + if (value instanceof Long longValue) { + statement.setLong(i + 1, longValue); + } else if (value instanceof Integer intValue) { + statement.setInt(i + 1, intValue); + } else { + statement.setString(i + 1, String.valueOf(value)); + } + } + } + + private String fixedHash(String seed) { + String normalized = seed == null ? "seed" : seed.replaceAll("[^a-zA-Z0-9]", ""); + String repeated = (normalized + "0".repeat(64)).toLowerCase(Locale.ROOT); + return repeated.substring(0, 64); + } + + 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 requiredPasswordEnvironment() { + String password = firstNonBlankEnvironment("P1R_MARKET_ADMIN_APPEAL_COMPLETED_PASSWORD", + "P1R_MARKET_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, + "缺少必需环境变量: P1R_MARKET_ADMIN_APPEAL_COMPLETED_PASSWORD、P1R_MARKET_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(P1rMarketAdminAppealCompletedApprovalIT::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(".market.") + || 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 Market admin appeal 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(Long primaryAssetId, + Long secondaryAssetId, + Long otherTenantAssetId, + Long pendingAppealId, + Long reviewingAppealId, + Long otherTenantAppealId) { + } + + private record MarketFactSnapshot(List assets, + List appeals, + List appealEvents, + List appealMaterials, + List commands, + List governanceActions) { + } + + @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 = requiredProperty("p1r.flyway.url"); + String user = requiredProperty("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 TestSecurityFrameworkService implements SecurityFrameworkService { + + private final AtomicBoolean permissionAllowed = new AtomicBoolean(true); + private final AtomicReference lastPermission = new AtomicReference<>(); + + void allowPermissions() { + permissionAllowed.set(true); + lastPermission.set(null); + } + + void denyPermissions() { + permissionAllowed.set(false); + lastPermission.set(null); + } + + String lastPermission() { + return lastPermission.get(); + } + + @Override + public boolean hasPermission(String permission) { + lastPermission.set(permission); + return permissionAllowed.get(); + } + + @Override + public boolean hasAnyPermissions(String... permissions) { + lastPermission.set(permissions == null || permissions.length == 0 ? null : permissions[0]); + return permissionAllowed.get(); + } + + @Override + public boolean hasRole(String role) { + return false; + } + + @Override + public boolean hasAnyRoles(String... roles) { + return false; + } + + @Override + public boolean hasScope(String scope) { + return false; + } + + @Override + public boolean hasAnyScopes(String... scope) { + return false; + } + } + + @SpringBootConfiguration + @EnableMethodSecurity(securedEnabled = true) + @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({ + AdminMuseMarketAppealController.class, + MarketAppealServiceImpl.class, + MarketCommandServiceImpl.class, + SpringUtil.class + }) + static class AdminAppealCompletedApprovalConfiguration { + + @Bean("ss") + TestSecurityFrameworkService securityFrameworkService() { + return new TestSecurityFrameworkService(); + } + + @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/P1rMarketPublishProducerCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketPublishProducerCompletedApprovalIT.java new file mode 100644 index 00000000..ed5d8311 --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketPublishProducerCompletedApprovalIT.java @@ -0,0 +1,852 @@ +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.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.MarketAccountProjectionOutboxServiceImpl; +import cn.iocoder.muse.module.market.application.muse.MarketAccountProjectionProvider; +import cn.iocoder.muse.module.market.application.muse.MarketCommandServiceImpl; +import cn.iocoder.muse.module.market.application.muse.MarketPublishServiceImpl; +import cn.iocoder.muse.module.market.controller.app.muse.AppMuseMarketPublishController; +import cn.iocoder.muse.module.member.api.account.MuseAccountRecordProjectionApi; +import cn.iocoder.muse.module.member.api.account.dto.MuseAccountRecordProjectionSaveReqDTO; +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.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.MvcResult; +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 static cn.iocoder.muse.module.market.enums.ErrorCodeConstants.MARKET_PUBLISH_CHECK_NOT_EXISTS; +import static org.hamcrest.Matchers.hasItem; +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:发布生产侧 HTTP + 真实 PostgreSQL 证据。 + * + *

本测试覆盖发布草稿、发布检查、提交申请、撤回和我的发布记录。测试只连接显式传入的 + * PostgreSQL {@code _test} 库,并通过 {@code /app-api/muse/**} 真实入口验证 Market 自有事实、 + * command 审计、Account 投影 outbox 与错误路径,避免用 controller mock 伪造 completed 证据。

+ */ +@SpringBootTest( + classes = P1rMarketPublishProducerCompletedApprovalIT.CompletedApprovalConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class P1rMarketPublishProducerCompletedApprovalIT { + + private static final Long TENANT_ID = 100L; + private static final Long LOGIN_USER_ID = 9001L; + private static final Long SOURCE_ID = 7001L; + 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 RecordingAccountProjectionApi accountProjectionApi; + + private MockMvc mockMvc; + + @DynamicPropertySource + static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) { + CompletedApprovalSettings settings = settings(); + redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser()); + registry.add("spring.application.name", () -> "p1r-market-publish-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 publish completed approval 必须迁移到包含 V22/V23 命令索引修复的 latest schema,实际: " + + result.migrationsExecuted); + } + + @BeforeEach + void setUp() throws Exception { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + resetMarketTables(); + accountProjectionApi.clear(); + setRuntimeContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @AfterAll + void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } + + @Test + void should_rejectPasswordSystemProperty() { + System.setProperty("p1r.market.publish.password", "must-not-be-used"); + try { + AssertionError error = assertThrows(AssertionError.class, + P1rMarketPublishProducerCompletedApprovalIT::assertNoPasswordSystemProperties); + assertTrue(error.getMessage().contains("p1r.market.publish.password"), + "拒绝 JVM password system property 时必须指出属性名"); + } finally { + System.clearProperty("p1r.market.publish.password"); + } + } + + @Test + void should_saveCheckSubmitWithdrawAndListPublishRecordsFromRealPostgresql() throws Exception { + MvcResult draftResponse = mockMvc.perform(post("/app-api/muse/marketplace/publish-drafts") + .header("X-API-Version", "1") + .contentType("application/json") + .content(validDraftBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("draft")) + .andExpect(jsonPath("$.data.missingItems").isEmpty()) + .andReturn(); + Long draftId = longData(draftResponse, "draftId"); + + mockMvc.perform(post("/app-api/muse/marketplace/publish-drafts") + .header("X-API-Version", "1") + .contentType("application/json") + .content(validDraftBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.draftId").value(draftId.intValue())) + .andExpect(jsonPath("$.data.status").value("draft")); + + assertEquals(1, publishDraftCount(), "重复保存相同发布草稿只能回放,不能写第二条 draft fact"); + assertEquals("draft", draftStatus(draftId), "发布草稿落库状态必须是 draft"); + assertEquals(1, commandCountByOperation("savePublishDraft"), "保存草稿命令只能 completed 一次"); + assertEquals(1, accountOutboxCount("muse_market_publish_draft", String.valueOf(draftId)), + "保存草稿必须写 Market Account publish outbox"); + assertEquals(1, accountProjectionApi.count(), "保存草稿回放不能重复调用 Account 写端口"); + + MvcResult checkResponse = mockMvc.perform(post("/app-api/muse/marketplace/publish-drafts/{draftId}/checks", draftId) + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("passed")) + .andExpect(jsonPath("$.data.canSubmit").value(true)) + .andExpect(jsonPath("$.data.blockers").isEmpty()) + .andExpect(jsonPath("$.data.warnings").isEmpty()) + .andReturn(); + Long checkId = longData(checkResponse, "marketPublishCheckId"); + assertEquals("passed", checkStatus(checkId), "发布检查通过时 DB 状态必须是 passed"); + assertEquals(1, commandCountByOperation("runPublishCheck"), "发布检查命令只能写一条 completed fact"); + + String submitCommandId = "p1r-market-publish-submit"; + MvcResult submitResponse = mockMvc.perform(post("/app-api/muse/marketplace/publish-requests") + .header("X-API-Version", "1") + .contentType("application/json") + .content(""" + {"draftId":%d,"marketPublishCheckId":%d,"commandId":"%s"} + """.formatted(draftId, checkId, submitCommandId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("submitted")) + .andReturn(); + Long requestId = Long.valueOf(stringData(submitResponse, "requestId")); + + mockMvc.perform(post("/app-api/muse/marketplace/publish-requests") + .header("X-API-Version", "1") + .contentType("application/json") + .content(""" + {"draftId":%d,"marketPublishCheckId":%d,"commandId":"%s"} + """.formatted(draftId, checkId, submitCommandId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.requestId").value(String.valueOf(requestId))) + .andExpect(jsonPath("$.data.status").value("submitted")); + + assertEquals("consumed", checkStatus(checkId), "提交发布申请后检查结果必须被消费"); + assertEquals("submitted", requestStatus(requestId), "发布申请落库状态必须是 submitted"); + assertEquals(1, reviewEventCount(requestId), "提交发布申请必须写 submitted review event"); + assertEquals(1, commandCount(submitCommandId), "重复提交同一 commandId 只能回放"); + assertEquals(1, accountOutboxCount("muse_market_publish_request", String.valueOf(requestId)), + "提交发布申请必须写 request 维度 Account publish outbox"); + assertEquals("submitted", outboxReviewStatus("muse_market_publish_request", String.valueOf(requestId)), + "提交后的 Account outbox 必须表达 submitted"); + + mockMvc.perform(get("/app-api/muse/marketplace/my-publish-records") + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(2)) + .andExpect(jsonPath("$.data.list[*].status", hasItem("draft"))) + .andExpect(jsonPath("$.data.list[*].status", hasItem("submitted"))); + + String withdrawCommandId = "p1r-market-publish-withdraw"; + mockMvc.perform(post("/app-api/muse/marketplace/publish-requests/{requestId}/withdraw", requestId) + .header("X-API-Version", "1") + .contentType("application/json") + .content(""" + {"commandId":"%s","expectedStatus":"submitted","reason":"P1R 撤回验证"} + """.formatted(withdrawCommandId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(true)); + + assertEquals("withdrawn", requestStatus(requestId), "撤回后发布申请 DB 状态必须是 withdrawn"); + assertEquals(1, commandCount(withdrawCommandId), "撤回必须写一条 command completed fact"); + assertEquals("draft", outboxReviewStatus("muse_market_publish_request", String.valueOf(requestId)), + "撤回投给 Account 的对外状态必须收敛成 draft"); + assertEquals(3, accountProjectionApi.count(), + "保存草稿、提交申请、撤回申请各调用一次 Account 写端口"); + + mockMvc.perform(get("/app-api/muse/marketplace/my-publish-records") + .header("X-API-Version", "1") + .param("status", "withdrawn")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.list[0].assetId").value(String.valueOf(requestId))) + .andExpect(jsonPath("$.data.list[0].status").value("withdrawn")); + } + + @Test + void should_rejectFailedPublishCheckWithoutRequestFact() throws Exception { + MvcResult draftResponse = mockMvc.perform(post("/app-api/muse/marketplace/publish-drafts") + .header("X-API-Version", "1") + .contentType("application/json") + .content(draftWithoutRightsBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("draft")) + .andExpect(jsonPath("$.data.missingItems", hasItem("rightsDeclaration"))) + .andReturn(); + Long draftId = longData(draftResponse, "draftId"); + + MvcResult checkResponse = mockMvc.perform(post("/app-api/muse/marketplace/publish-drafts/{draftId}/checks", draftId) + .header("X-API-Version", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("failed")) + .andExpect(jsonPath("$.data.canSubmit").value(false)) + .andExpect(jsonPath("$.data.blockers[0].category").value("rights")) + .andReturn(); + Long checkId = longData(checkResponse, "marketPublishCheckId"); + + String submitCommandId = "p1r-market-publish-failed-check"; + mockMvc.perform(post("/app-api/muse/marketplace/publish-requests") + .header("X-API-Version", "1") + .contentType("application/json") + .content(""" + {"draftId":%d,"marketPublishCheckId":%d,"commandId":"%s"} + """.formatted(draftId, checkId, submitCommandId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(MARKET_PUBLISH_CHECK_NOT_EXISTS.getCode())) + .andExpect(jsonPath("$.msg").exists()); + + assertEquals("failed", checkStatus(checkId), "失败检查不能被提交路径消费"); + assertEquals(0, publishRequestCount(), "失败检查不能创建发布申请 fact"); + assertEquals(0, commandCount(submitCommandId), "提交失败时 command 预占必须随事务回滚"); + assertEquals(0, reviewEventCount(), "失败检查不能写 review event"); + } + + private void setRuntimeContext() { + TenantContextHolder.setTenantId(TENANT_ID); + LoginUser loginUser = new LoginUser(); + loginUser.setId(LOGIN_USER_ID); + loginUser.setUserType(UserTypeEnum.MEMBER.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()) { + // 每个用例重置发布链路相关表,保证 HTTP 请求写出的事实可被精确计数。 + statement.execute(""" + TRUNCATE TABLE + muse_market_command, + muse_market_account_projection, + muse_market_review_event, + muse_market_publish_request, + muse_market_publish_check, + muse_market_publish_draft, + muse_market_appeal, + muse_market_asset_version, + muse_market_asset + RESTART IDENTITY CASCADE + """); + } + } + + private String validDraftBody() { + return """ + { + "assetType":"work", + "sourceId":%d, + "name":"P1R Market Publish Work", + "summary":"P1R 发布侧真实 PG 验证", + "description":"用于验证发布草稿、检查、提交、撤回和我的发布记录", + "categoryId":10, + "licenseType":"standard", + "allowedUses":["read"], + "prohibitedUses":["redistribution"], + "coverImageUrl":"https://example.test/cover.png", + "tags":["p1r","market"], + "rightsDeclaration":"作者确认拥有该作品的发布授权", + "externalOrderRef":"ORDER-P1R-PUBLISH", + "workAssetMode":"read_only_favorite_license" + } + """.formatted(SOURCE_ID); + } + + private String draftWithoutRightsBody() { + return """ + { + "assetType":"work", + "sourceId":%d, + "name":"P1R Market Publish Missing Rights", + "summary":"P1R 发布检查失败验证", + "description":"缺少权利声明时只能保存草稿,不能提交发布申请", + "categoryId":10, + "licenseType":"standard", + "allowedUses":["read"], + "prohibitedUses":["redistribution"], + "coverImageUrl":"https://example.test/cover.png", + "tags":["p1r","blocked"], + "externalOrderRef":"ORDER-P1R-PUBLISH-BLOCKED", + "workAssetMode":"read_only_favorite_license" + } + """.formatted(SOURCE_ID + 1); + } + + private Long longData(MvcResult result, String fieldName) throws Exception { + Object value = responseData(result).get(fieldName); + assertNotNull(value, "响应 data 必须包含字段: " + fieldName); + return value instanceof Number number ? number.longValue() : Long.valueOf(String.valueOf(value)); + } + + private String stringData(MvcResult result, String fieldName) throws Exception { + Object value = responseData(result).get(fieldName); + assertNotNull(value, "响应 data 必须包含字段: " + fieldName); + return String.valueOf(value); + } + + @SuppressWarnings("unchecked") + private Map responseData(MvcResult result) throws Exception { + Map response = JsonUtils.parseObject(result.getResponse().getContentAsString(), Map.class); + assertNotNull(response, "HTTP 响应必须是 JSON 对象"); + Object data = response.get("data"); + assertTrue(data instanceof Map, "HTTP 响应 data 必须是 JSON 对象"); + return (Map) data; + } + + private int publishDraftCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_market_publish_draft WHERE tenant_id = ?", TENANT_ID); + } + + private int publishRequestCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_market_publish_request WHERE tenant_id = ?", TENANT_ID); + } + + private String draftStatus(Long draftId) throws SQLException { + return queryString("SELECT status FROM muse_market_publish_draft WHERE tenant_id = ? AND id = ?", + TENANT_ID, draftId); + } + + private String checkStatus(Long checkId) throws SQLException { + return queryString("SELECT status FROM muse_market_publish_check WHERE tenant_id = ? AND id = ?", + TENANT_ID, checkId); + } + + private String requestStatus(Long requestId) throws SQLException { + return queryString("SELECT status FROM muse_market_publish_request WHERE tenant_id = ? AND id = ?", + TENANT_ID, requestId); + } + + private int reviewEventCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_market_review_event WHERE tenant_id = ?", TENANT_ID); + } + + private int reviewEventCount(Long requestId) throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_market_review_event WHERE tenant_id = ? AND request_id = ?", + TENANT_ID, requestId); + } + + 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 int accountOutboxCount(String sourceTable, String sourceId) throws SQLException { + return queryInt(""" + SELECT COUNT(*) + FROM muse_market_account_projection + WHERE tenant_id = ? + AND source_table = ? + AND source_id = ? + AND projection_status = 'synced' + AND outbox_status = 'synced' + """, TENANT_ID, sourceTable, sourceId); + } + + private String outboxReviewStatus(String sourceTable, String sourceId) throws SQLException { + String payload = queryString(""" + SELECT payload_summary::text + FROM muse_market_account_projection + WHERE tenant_id = ? + AND source_table = ? + AND source_id = ? + """, TENANT_ID, sourceTable, sourceId); + Map summary = JsonUtils.parseObject(payload, Map.class); + assertNotNull(summary, "Market Account outbox payload_summary 必须是 JSON 对象"); + return String.valueOf(summary.get("reviewStatus")); + } + + private int queryInt(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "计数查询必须返回一行"); + return resultSet.getInt(1); + } + } + } + + private String queryString(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "事实查询必须返回一行"); + return resultSet.getString(1); + } + } + } + + private void bind(PreparedStatement statement, Object... args) throws SQLException { + for (int i = 0; i < args.length; i++) { + Object value = args[i]; + if (value instanceof Long longValue) { + statement.setLong(i + 1, longValue); + } else if (value instanceof Integer intValue) { + statement.setInt(i + 1, intValue); + } else { + statement.setString(i + 1, String.valueOf(value)); + } + } + } + + 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 requiredPasswordEnvironment() { + String password = firstNonBlankEnvironment("P1R_MARKET_COMPLETED_PASSWORD", + "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, + "缺少必需环境变量: P1R_MARKET_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(P1rMarketPublishProducerCompletedApprovalIT::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(".market.") + || 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 Market publish 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 CompletedApprovalSettings(String jdbcUrl, + String jdbcUser, + String jdbcPassword, + String flywayLocations) { + + static CompletedApprovalSettings fromPropertiesAndEnvironment() { + assertNoPasswordSystemProperties(); + String url = requiredProperty("p1r.flyway.url"); + String user = requiredProperty("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 RecordingAccountProjectionApi implements MuseAccountRecordProjectionApi { + + private final List projections = new ArrayList<>(); + + @Override + public void upsertRecordProjection(MuseAccountRecordProjectionSaveReqDTO projection) { + assertEquals("publish", projection.getRecordType(), "Market 发布链路只能写 publish Account 投影"); + assertNotNull(projection.getRecordId(), "Account publish 投影必须带 recordId"); + assertNotNull(projection.getProjectionSnapshot(), "Account publish 投影必须带 snapshot"); + projections.add(projection); + } + + void clear() { + projections.clear(); + } + + int count() { + return projections.size(); + } + } + + @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({ + AppMuseMarketPublishController.class, + MarketPublishServiceImpl.class, + MarketCommandServiceImpl.class, + MarketAccountProjectionProvider.class, + MarketAccountProjectionOutboxServiceImpl.class, + SpringUtil.class + }) + static class CompletedApprovalConfiguration { + + @Bean + RecordingAccountProjectionApi accountRecordProjectionApi() { + return new RecordingAccountProjectionApi(); + } + + @Bean + ApiErrorLogCommonApi apiErrorLogCommonApi() { + return new ApiErrorLogCommonApi() { + @Override + public CommonResult createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) { + return CommonResult.success(true); + } + }; + } + } +}