test(p1r): 验收债批2/3/4 真实 PG IT(market发布5 + account记录3 + market申诉3,12/12绿)

承批1(content 11 op),续推三批 needs_verification 真实 PG completed-approval IT:
- 批2 P1rMarketPublishProducerCompletedApprovalIT(3/3):savePublishDraft/runPublishCheck/
  submitPublishRequest/withdrawPublishRequest/listMyPublishRecords;断言 HTTP +
  publish_draft/check/request/review_event/market_command/market_account_projection
  落库 + Account 投影端口调用 + 失败检查不可提交。
- 批3 P1rAccountMarketRecordsCompletedApprovalIT(3/3):appListPurchases/appListLicenses/
  appListPublishRecords;种真实 muse_account_record_projection + member_user,经
  /app-api/muse/account/* 真实读取,断言租户/owner 隔离、状态归一、外部订单脱敏、
  非法输入 fail-closed、读端 no-write。
- 批4 P1rMarketAdminAppealCompletedApprovalIT(6/6):adminListAppeals/adminGetAppeal/
  adminResolveAppeal;经 /admin-api/muse/market/appeals/** + Spring method security,
  断言列表/详情 no-write、租户隔离、RBAC/API version fail-closed、resolve 写状态 +
  resolved event + completed command、幂等回放、expectedStatus 冲突事务回滚。

真实 PG 实跑(muse_p1r_acceptance_verify_test,共享 _test 库,reuseForks=false 顺序 fork):
Tests run 12, Failures 0, Errors 0, Skipped 0,BUILD SUCCESS。
各 IT 保留 _test 库守卫 / 密码仅 env / 拒凭据 query 守卫,缺库自动创建。
证据就绪,completed 仍待人工批(加入 APPROVED_COMPLETED_OPERATIONS 由人工=反假绿)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-16 17:32:23 -07:00
parent cb57b88866
commit 25db3fb4c2
3 changed files with 2819 additions and 0 deletions

View File

@ -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 证据
*
* <p>本测试只连接显式传入的 PostgreSQL {@code _test} 使用真实 {@code /app-api/muse/**}
* 入口读取 {@code muse_account_record_projection}Market 投影可用性由单体内真实
* {@link MarketAccountProjectionProvider} 判定读端本身仍只读 Account 自有投影表</p>
*/
@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<String> 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<String> 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<String> 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 '<user-redacted>'");
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(), "<user-redacted>");
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<String> 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 ? "" : "?<query-redacted>";
}
private static String maskJdbcHost(String urlPart) {
return urlPart.replaceAll("//([^:/?#]+)", "//<host>");
}
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() ? "<user-redacted>" : "<user-redacted>");
}
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<String> memberUsers,
List<String> accountRecordProjections,
List<String> commands,
List<String> audits,
List<String> accountOutboxes,
List<String> 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<Boolean> createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) {
return CommonResult.success(true);
}
};
}
}
}

View File

@ -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 证据
*
* <p>本测试覆盖发布草稿发布检查提交申请撤回和我的发布记录测试只连接显式传入的
* PostgreSQL {@code _test} 并通过 {@code /app-api/muse/**} 真实入口验证 Market 自有事实
* command 审计Account 投影 outbox 与错误路径避免用 controller mock 伪造 completed 证据</p>
*/
@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<String> 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<String, Object> responseData(MvcResult result) throws Exception {
Map<String, Object> 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<String, Object>) 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 '<user-redacted>'");
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(), "<user-redacted>");
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<String> 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 ? "" : "?<query-redacted>";
}
private static String maskJdbcHost(String urlPart) {
return urlPart.replaceAll("//([^:/?#]+)", "//<host>");
}
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() ? "<user-redacted>" : "<user-redacted>");
}
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<MuseAccountRecordProjectionSaveReqDTO> 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<Boolean> createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) {
return CommonResult.success(true);
}
};
}
}
}