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:
parent
cb57b88866
commit
25db3fb4c2
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user