diff --git a/docs/mvp/进度总账.md b/docs/mvp/进度总账.md index 4d7bfcc6..ed2507c7 100644 --- a/docs/mvp/进度总账.md +++ b/docs/mvp/进度总账.md @@ -122,7 +122,8 @@ - **批4 ✅ 已交付(market 管理端申诉 3 op,2026-06-16)**:`P1rMarketAdminAppealCompletedApprovalIT`(真实 PG `muse_p1r_market_admin_appeal_test`,6/6 绿)覆盖 adminListAppeals/adminGetAppeal/adminResolveAppeal;经真实 `/admin-api/muse/market/appeals/**` + Spring method security,断言列表/详情 no-write、租户隔离、RBAC/API version fail-closed、resolve 写 appeal 状态 + resolved event + completed command、幂等回放、expectedStatus 冲突事务回滚。测试内使用 latest schema(V23+),保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。 - **批5 ✅ 已交付(market 管理端发布审核 3 op,2026-06-16)**:`P1rMarketAdminPublishReviewCompletedApprovalIT`(真实 PG `muse_p1r_market_admin_review_test`,6/6 绿)覆盖 adminListPublishRequests/adminApprovePublishRequest/adminRejectPublishRequest;经真实 `/admin-api/muse/market/publish-requests/**` + Spring method security,断言列表 no-write、租户隔离、RBAC/API version fail-closed、approve 真实消费 submitted/check 证据并物化/上架 Market asset+version、reject 写 needs_supplement 事件与投影、completed command 幂等回放、expectedStatus/validationResult 冲突事务回滚。测试内使用 latest schema(V23+),保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。 - **批6 ✅ 已交付(market 生产者申诉写路 3 op,2026-06-16)**:`P1rMarketProducerAppealCompletedApprovalIT`(真实 PG `muse_p1r_market_producer_appeal_test`,7/7 绿,独立复跑两次均绿)覆盖 submitAppeal/supplementAppeal/withdrawAppeal;经真实 `/app-api/muse/marketplace/appeals/**`(app 侧无 method security,鉴权靠登录态 + 服务层 owner 隔离),断言:submit 走 review_rejection 关联(自有资产 + 7 天内被拒发布申请)写 appeal(pending,rev1)+ submitted event(snapshot 含 appealType/originalResult=rejected/source)+ completed command;supplement 脱敏只存摘要(密钥 `sk-*`/`token=*`→`[REDACTED]`、不留明文)+ supplementing→reviewing 状态 CAS(rev→2)+ 材料落库 + 幂等回放;withdraw →closed(rev→2)+ resolution 记录撤回理由 + withdrawn event(expectedStatus/previousStatus);三写均命令回放幂等;fail-closed(非属主→`无权访问`/无关联→`关联事实不存在`/超期→`期限已过`/资产不存在/越权他人→`申诉不存在`/未确认隐私→VO 拦截)全程 0 写、0 command 预占;状态冲突 + expectedStatus 冲突均**事务回滚**(reserveCommand 预占=0、rev 不动);API version + 无登录守卫。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫;迁移到 latest schema(V23+)。**证据就绪,completed 待人工批**。 - - 剩余:content 26(约 15 为 FileService/New-API/SSE fail-closed → 只能验"失败关闭"真实证据)+ market 14 + account 18,按批续推。 + - **批7 ✅ 已交付(account 配额请求 3 op,2026-06-16)**:`P1rAccountQuotaRequestCompletedApprovalIT`(真实 PG `muse_p1r_account_quota_request_test`,5/5 绿,独立复跑两次均绿)覆盖 appCreateQuotaRequest/appGetQuotaRequest/adminCreateQuotaRequest;经真实 `/app-api/muse/account/quota-requests` 与 `/admin-api/muse/account/users/{userId}/quota-requests`(member 模块租户行拦截器 + member_user fixture + admin `@PreAuthorize` method security),断言:create 入队写 quota_request(queued)+ pending_attribution 外部调用占位 + completed command(app:actor=owner=登录用户、amount=0;admin:actor=操作者、owner=目标用户、amount=targetQuota.limit)、命令幂等回放(idempotent_hit 不写第二条)、相同 commandId 不同请求 hash→`ACCOUNT_COMMAND_ID_CONFLICT` 事务回滚;get 按 owner+tenant 隔离返回归一化状态、纯读 no-write;fail-closed(类型白名单 BAD_REQUEST/API version/缺用户 `ACCOUNT_USER_NOT_EXISTS`/越权 owner/not-found/RBAC deny)全程 0 写、0 command 预占;配额请求链路不写 Account 审计(audit/outbox 桩抛错反假绿)。10 条负路全部经真实 GlobalExceptionHandler 实测命中(9 serviceException + 1 accessDenied)。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。 + - 剩余:content 26(约 15 为 FileService/New-API/SSE fail-closed → 只能验"失败关闭"真实证据)+ market 14 + account 15,按批续推。 - ✅ **P1r*IT 真实 PG 基线(2026-06-15,mini-infra PG)= 23/23 IT 类全绿**(99 用例 0F/0E;2 例 external-acceptance 因未设 `MUSE_P1R_EXTERNAL_ACCEPTANCE` 跳过)。`P1rKnowledgeFlywayMigrationIT` 版本断言由硬编码(V14→V21→V23 复发两次)改为 `MigrateResult` 动态自适应(commit 4d46d7a,Codex+Opus 双代理合并)。**完整跑法见 [.agents/knowledge §四](../../.agents/knowledge/external-deps-and-gotchas.md)**:`_test` 后缀隔离库 + `source infra.env`(密码仅 env)+ argLine(SOCKS 清 + p1r.flyway.url/user/locations)+ `-DreuseForks=false`(批量必加,避 Market IT 属性脱敏污染复用 fork)。 - 🔧 ai/平台预存红测试整改(CI 接电将暴露):`MuseAiTaskServiceTest` 桩缺失 11 例 NPE **✅ 已整改(补 eventPublishOutboxService/candidateReviewService 桩 + review lenient,40/40,2026-06-15)**;`QiniuSmsClientTest` 时区硬编码 **✅ 已整改(5/5,机器无关)**;`MuseAiEventPublishOutboxMapperTest` **✅ 真实 PG 实跑 5/5(mini-infra PG,harness=infra.env+argLine SOCKS 清;隔离 schema 自建自清)**。**ai/平台预存红三项全清。** diff --git a/muse-cloud/muse-module-member/.agent b/muse-cloud/muse-module-member/.agent index c5123dbc..09f6f727 100644 --- a/muse-cloud/muse-module-member/.agent +++ b/muse-cloud/muse-module-member/.agent @@ -5,5 +5,5 @@ - **目标(owner 职责)**:Account/Usage/Audit BC——Profile / Entitlement-Quota / Usage Record / Personal Center Summary / 记录总览 / Security Event / New-API Binding / Audit Log / Download Credential。**member = Account BC 物理承载,不新增 account 模块**。 - **边界(不可违反)**:是用户可见权益的聚合**读模型**与跳转入口,**不是消费账本事实源,不能反写**作品/知识/智能体/市场事实;高危审计 append-only + WORM/哈希链;Entitlement/Quota/Usage/Security/Binding 唯一写入 authority;守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。 - **out-of-scope**:他域事实写入。 -- **现状**:只读评估 68%。**2026-06-15 活体订正**:单体内 `MarketAccountProjectionFacade` 由 market 的 `MarketAccountProjectionProvider` 真实提供,purchases/licenses/publish-records 三端可读投影,不是 `*_UNAVAILABLE` 缺口。**2026-06-16 补真实 PG 验收证据**:`P1rAccountMarketRecordsCompletedApprovalIT` 覆盖 appListPurchases/appListLicenses/appListPublishRecords,真实 PG 3/3 绿;证据就绪,completed 仍待人工批准。 -- **关键风险 / TODO**:剩余 account 验收债 18 op:exports/downloads/new-api recheck 属对象存储/New-API 外部依赖未配置的正确 fail-closed;quota/usage/admin 等需按批补真实 PG 或失败关闭证据。前端仍有深页未全覆盖真实后端。 +- **现状**:只读评估 68%。**2026-06-15 活体订正**:单体内 `MarketAccountProjectionFacade` 由 market 的 `MarketAccountProjectionProvider` 真实提供,purchases/licenses/publish-records 三端可读投影,不是 `*_UNAVAILABLE` 缺口。**2026-06-16 补真实 PG 验收证据**:`P1rAccountMarketRecordsCompletedApprovalIT` 覆盖 appListPurchases/appListLicenses/appListPublishRecords,真实 PG 3/3 绿;`P1rAccountQuotaRequestCompletedApprovalIT` 覆盖 appCreateQuotaRequest/appGetQuotaRequest/adminCreateQuotaRequest(命令幂等+commandId 冲突回滚+owner/tenant 隔离+RBAC+类型白名单),真实 PG 5/5 绿(独立复跑);证据就绪,completed 仍待人工批准。 +- **关键风险 / TODO**:剩余 account 验收债 15 op(quota-request 3 op 已批7 补证):exports/downloads/new-api recheck 属对象存储/New-API 外部依赖未配置的正确 fail-closed;usage/integration-call/new-api-binding/admin 记录等需按批补真实 PG 或失败关闭证据。前端仍有深页未全覆盖真实后端。 diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountQuotaRequestCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountQuotaRequestCompletedApprovalIT.java new file mode 100644 index 00000000..fb47f0b0 --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountQuotaRequestCompletedApprovalIT.java @@ -0,0 +1,1144 @@ +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.mybatis.core.util.MyBatisUtils; +import cn.iocoder.muse.framework.security.core.LoginUser; +import cn.iocoder.muse.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.muse.framework.tenant.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.member.application.account.AccountAuditService; +import cn.iocoder.muse.module.member.application.account.AccountCommandServiceImpl; +import cn.iocoder.muse.module.member.application.account.AccountEventPublishOutboxService; +import cn.iocoder.muse.module.member.application.account.AccountNewApiBindingService; +import cn.iocoder.muse.module.member.application.account.AccountQuotaServiceImpl; +import cn.iocoder.muse.module.member.application.account.AccountUsageService; +import cn.iocoder.muse.module.member.controller.admin.account.AdminAccountNewApiController; +import cn.iocoder.muse.module.member.controller.app.account.AppAccountNewApiController; +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.mockito.Mockito; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.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 java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; +import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.NOT_FOUND; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_API_VERSION_UNSUPPORTED; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_COMMAND_ID_CONFLICT; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_RESOURCE_FORBIDDEN; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_USER_NOT_EXISTS; +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 Account 配额请求 completed approval:3 个 quota-request operation 的 HTTP + 真实 PostgreSQL 证据。 + * + *

本测试只连接显式传入的 PostgreSQL {@code _test} 隔离库,经真实 {@code /app-api/muse/account/quota-requests} + * 与 {@code /admin-api/muse/account/users/{userId}/quota-requests} 入口进入 Controller,覆盖 appCreateQuotaRequest、 + * appGetQuotaRequest、adminCreateQuotaRequest。每个 operation 断言 HTTP + 真实 muse_account_quota_request/ + * integration_call/command 持久化事实、命令幂等回放(idempotent_hit)、commandId 冲突事务回滚、owner/tenant 隔离、 + * 类型白名单、RBAC、API version fail-closed,以及配额请求链路不触达 audit。配额请求只入队(status=queued), + * 不触发 New-API 外部调用、配额调整或 entitlement outbox。

+ */ +@SpringBootTest( + classes = P1rAccountQuotaRequestCompletedApprovalIT.QuotaRequestCompletedApprovalConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class P1rAccountQuotaRequestCompletedApprovalIT { + + private static final Long TENANT_ID = 100L; + // app 申请人(= 登录用户),也是 admin 申请的目标账户用户。 + private static final Long ACCOUNT_USER_ID = 1001L; + // 同租户的另一用户,用于 owner 隔离 fail-closed 断言。 + private static final Long OTHER_USER_ID = 2002L; + // 管理端操作者(申请的 actor)。 + private static final Long ADMIN_OPERATOR_ID = 9001L; + // 无 member_user 事实的用户,用于 ACCOUNT_USER_NOT_EXISTS。 + private static final Long MISSING_USER_ID = 9099L; + private static final String API_VERSION = "1"; + private static final String MANAGE_PERMISSION = "muse:account:new-api:manage"; + private static final String SEED_HASH_64 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final Set CREDENTIAL_QUERY_KEYS = Set.of( + "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password", + "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token"); + + private static volatile CompletedApprovalSettings cachedSettings; + private static volatile boolean originalFlywayPropertiesCaptured; + private static volatile String originalFlywayUrlSystemProperty; + private static volatile String originalFlywayUserSystemProperty; + + @Autowired + private DataSource dataSource; + @Autowired + private WebApplicationContext webApplicationContext; + @Autowired + private TestSecurityFrameworkService securityFrameworkService; + + private MockMvc mockMvc; + + @DynamicPropertySource + static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) { + CompletedApprovalSettings settings = settings(); + redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser()); + registry.add("spring.application.name", () -> "p1r-account-quota-request-completed-approval-it"); + registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module.member"); + 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); + // 配额请求事实表(command/integration_call/quota_request)均在 V11 引入;迁移到 latest 以贴近现网 schema。 + assertTrue(result.migrationsExecuted >= 11, + "Account 配额请求 completed approval 至少要迁移到含 V11 配额请求 schema,实际: " + + result.migrationsExecuted); + createMemberUserFixtureTable(); + } + + @BeforeEach + void setUp() throws Exception { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + resetAccountTables(); + setRuntimeContext(ACCOUNT_USER_ID, UserTypeEnum.MEMBER.getValue()); + securityFrameworkService.allowPermissions(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @AfterAll + void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } + + @Test + void should_rejectUnsafeDatabaseConfigurationInputs() { + System.setProperty("p1r.account.quota-request.password", "must-not-be-used"); + try { + AssertionError error = assertThrows(AssertionError.class, + P1rAccountQuotaRequestCompletedApprovalIT::assertNoPasswordSystemProperties); + assertTrue(error.getMessage().contains("p1r.account.quota-request.password"), + "拒绝 JVM password system property 时必须指出属性名"); + } finally { + System.clearProperty("p1r.account.quota-request.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_appCreateQuotaRequestWithCommandIntegrationCallReplayAndConflict() throws Exception { + seedMemberUser(ACCOUNT_USER_ID); + + String commandId = "p1r-account-app-quota-create"; + MvcResult response = mockMvc.perform(post("/app-api/muse/account/quota-requests") + .header("X-API-Version", API_VERSION) + .contentType("application/json") + .content(appQuotaBody(commandId, "plan_sync"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("queued")) + .andExpect(jsonPath("$.data.requestId").isNotEmpty()) + .andExpect(jsonPath("$.data.correlationId").isNotEmpty()) + .andReturn(); + String requestId = stringData(response, "requestId"); + + assertEquals(1, quotaRequestCount(), "首次申请必须只写一条配额请求事实"); + assertEquals("queued", quotaRequestField(requestId, "status"), "配额请求落库状态必须是 queued"); + assertEquals("app", quotaRequestField(requestId, "side"), "app 申请 side 必须是 app"); + assertEquals("plan_sync", quotaRequestField(requestId, "quota_type"), "配额请求类型必须落库"); + assertEquals(String.valueOf(ACCOUNT_USER_ID), quotaRequestField(requestId, "account_user_id"), + "配额请求必须归属当前登录用户"); + assertEquals(String.valueOf(ACCOUNT_USER_ID), quotaRequestField(requestId, "requested_by"), + "app 申请的 requested_by 必须是当前登录用户"); + assertEquals("0", quotaRequestField(requestId, "requested_amount"), "app 申请请求额度恒为 0"); + assertEquals(commandId, quotaRequestField(requestId, "command_id"), "配额请求必须记录写命令幂等键"); + assertNotNull(quotaRequestField(requestId, "integration_call_id"), "配额请求必须挂上 pending 归因的外部调用占位"); + + assertEquals(1, integrationCallCount(), "首次申请必须写一条 pending_attribution 外部调用占位"); + assertEquals("new-api", integrationCallField(commandId, "provider"), "外部调用占位 provider 必须是 new-api"); + assertEquals("pending_attribution", integrationCallField(commandId, "status"), + "外部调用占位状态必须是 pending_attribution"); + assertEquals("appCreateQuotaRequest", integrationCallField(commandId, "operation_id"), + "外部调用占位必须记录发起 operation"); + + assertEquals(1, commandCountByOperation("appCreateQuotaRequest"), + "appCreateQuotaRequest 必须记录 completed command 事实"); + assertEquals("queued", statusFromCommandResult(commandId), "command result_snapshot 必须记录入队状态"); + assertEquals(0, auditCount(), "配额请求链路不写 Account 审计表"); + + // 相同 commandId + 相同请求重放必须命中幂等回放,返回 idempotent_hit,不写第二条事实。 + mockMvc.perform(post("/app-api/muse/account/quota-requests") + .header("X-API-Version", API_VERSION) + .contentType("application/json") + .content(appQuotaBody(commandId, "plan_sync"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("idempotent_hit")) + .andExpect(jsonPath("$.data.requestId").value(requestId)); + assertEquals(1, quotaRequestCount(), "幂等回放不能写第二条配额请求"); + assertEquals(1, integrationCallCount(), "幂等回放不能写第二条外部调用占位"); + + // 相同 commandId + 不同请求(请求 hash 变化)必须冲突,且事务回滚不留下任何新事实。 + assertNoAccountMutationDuring("commandId 冲突必须事务回滚,不得写新事实", + () -> mockMvc.perform(post("/app-api/muse/account/quota-requests") + .header("X-API-Version", API_VERSION) + .contentType("application/json") + .content(appQuotaBody(commandId, "balance_sync"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_COMMAND_ID_CONFLICT.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + assertEquals(1, quotaRequestCount(), "commandId 冲突不能写第二条配额请求"); + assertEquals("plan_sync", quotaRequestField(requestId, "quota_type"), "冲突请求不能覆盖原配额请求类型"); + } + + @Test + void should_failClosedWhenAppCreateQuotaRequestInvalid() throws Exception { + seedMemberUser(ACCOUNT_USER_ID); + + // 管理端专属类型在 app 入口必须按参数非法拒绝,不写任何事实。 + assertNoAccountMutationDuring("app 不允许的请求类型必须拒绝且不写库", + () -> mockMvc.perform(post("/app-api/muse/account/quota-requests") + .header("X-API-Version", API_VERSION) + .contentType("application/json") + .content(appQuotaBody("p1r-app-quota-bad-type", "plan_config"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // API version 非法必须在 controller 层拒绝,不写库。 + assertNoAccountMutationDuring("非法 API version 必须拒绝且不写库", + () -> mockMvc.perform(post("/app-api/muse/account/quota-requests") + .header("X-API-Version", "0") + .contentType("application/json") + .content(appQuotaBody("p1r-app-quota-bad-version", "plan_sync"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // 缺失登录用户事实必须 fail-closed,不写库。 + setRuntimeContext(MISSING_USER_ID, UserTypeEnum.MEMBER.getValue()); + assertNoAccountMutationDuring("缺失登录用户事实必须拒绝且不写库", + () -> mockMvc.perform(post("/app-api/muse/account/quota-requests") + .header("X-API-Version", API_VERSION) + .contentType("application/json") + .content(appQuotaBody("p1r-app-quota-missing-user", "plan_sync"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_USER_NOT_EXISTS.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + setRuntimeContext(ACCOUNT_USER_ID, UserTypeEnum.MEMBER.getValue()); + + assertEquals(0, quotaRequestCount(), "所有 fail-closed app 申请都不能写配额请求事实"); + assertEquals(0, commandCountAll(), "所有 fail-closed app 申请都不能留下 command 预占"); + } + + @Test + void should_appGetQuotaRequestWithOwnerTenantGuardAndNoWrite() throws Exception { + seedMemberUser(ACCOUNT_USER_ID); + seedMemberUser(OTHER_USER_ID); + try (Connection connection = dataSource.getConnection()) { + insertQuotaRequest(connection, TENANT_ID, "qr-owned", ACCOUNT_USER_ID, ACCOUNT_USER_ID, "app", + "plan_sync", 0L, "queued", "seed-cmd-owned", "corr-owned"); + insertQuotaRequest(connection, TENANT_ID, "qr-foreign", OTHER_USER_ID, OTHER_USER_ID, "app", + "balance_sync", 0L, "queued", "seed-cmd-foreign", "corr-foreign"); + } + + // 属主读自己的配额请求 → 返回归一化状态,且纯读不写库。 + assertNoAccountMutationDuring("appGetQuotaRequest happy path 是纯读 operation", + () -> mockMvc.perform(get("/app-api/muse/account/quota-requests/{requestId}", "qr-owned") + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.requestId").value("qr-owned")) + .andExpect(jsonPath("$.data.status").value("queued")) + .andExpect(jsonPath("$.data.correlationId").value("corr-owned")) + .andExpect(jsonPath("$.data.createdAt").isNotEmpty()) + .andExpect(jsonPath("$.data.completedAt").doesNotExist())); + + // 读他人配额请求 → owner 守卫拒绝,不写库,不泄露。 + assertNoAccountMutationDuring("appGetQuotaRequest 越权读他人请求必须 owner fail-closed", + () -> mockMvc.perform(get("/app-api/muse/account/quota-requests/{requestId}", "qr-foreign") + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_RESOURCE_FORBIDDEN.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // 不存在的请求 → not found,不写库。 + assertNoAccountMutationDuring("appGetQuotaRequest 不存在请求必须 not found", + () -> mockMvc.perform(get("/app-api/muse/account/quota-requests/{requestId}", "qr-missing") + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(NOT_FOUND.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // API version 非法 → 拒绝,不写库。 + assertNoAccountMutationDuring("appGetQuotaRequest 非法 API version 必须拒绝", + () -> mockMvc.perform(get("/app-api/muse/account/quota-requests/{requestId}", "qr-owned") + .header("X-API-Version", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + } + + @Test + void should_adminCreateQuotaRequestWithRbacReplayAndUserGuard() throws Exception { + seedMemberUser(ACCOUNT_USER_ID); + setRuntimeContext(ADMIN_OPERATOR_ID, UserTypeEnum.ADMIN.getValue()); + + String commandId = "p1r-account-admin-quota-create"; + MvcResult response = mockMvc.perform(post("/admin-api/muse/account/users/{userId}/quota-requests", + ACCOUNT_USER_ID) + .header("X-API-Version", API_VERSION) + .contentType("application/json") + .content(adminQuotaBody(commandId, "plan_config", 500))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("queued")) + .andExpect(jsonPath("$.data.requestId").isNotEmpty()) + .andReturn(); + String requestId = stringData(response, "requestId"); + + assertEquals(MANAGE_PERMISSION, securityFrameworkService.lastPermission(), + "adminCreateQuotaRequest 必须经 method security 校验 manage 权限"); + assertEquals(1, quotaRequestCount(), "管理端申请必须只写一条配额请求事实"); + assertEquals("admin", quotaRequestField(requestId, "side"), "管理端申请 side 必须是 admin"); + assertEquals("plan_config", quotaRequestField(requestId, "quota_type"), "配额请求类型必须落库"); + assertEquals(String.valueOf(ACCOUNT_USER_ID), quotaRequestField(requestId, "account_user_id"), + "管理端申请必须归属路径目标用户"); + assertEquals(String.valueOf(ADMIN_OPERATOR_ID), quotaRequestField(requestId, "requested_by"), + "管理端申请的 requested_by 必须是操作者"); + assertEquals("500", quotaRequestField(requestId, "requested_amount"), + "管理端申请额度必须取 targetQuota.limit"); + assertEquals(1, commandCountByOperation("adminCreateQuotaRequest"), + "adminCreateQuotaRequest 必须记录 completed command 事实"); + assertEquals(String.valueOf(ADMIN_OPERATOR_ID), commandField(commandId, "actor_user_id"), + "命令 actor 必须是操作者"); + assertEquals(String.valueOf(ACCOUNT_USER_ID), commandField(commandId, "owner_user_id"), + "命令 owner 必须是目标账户用户"); + assertEquals(0, auditCount(), "配额请求链路不写 Account 审计表"); + + // 幂等回放。 + mockMvc.perform(post("/admin-api/muse/account/users/{userId}/quota-requests", ACCOUNT_USER_ID) + .header("X-API-Version", API_VERSION) + .contentType("application/json") + .content(adminQuotaBody(commandId, "plan_config", 500))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("idempotent_hit")) + .andExpect(jsonPath("$.data.requestId").value(requestId)); + assertEquals(1, quotaRequestCount(), "管理端幂等回放不能写第二条配额请求"); + + // RBAC 拒绝 → forbidden,不写库。 + securityFrameworkService.denyPermissions(); + assertNoAccountMutationDuring("管理端无 manage 权限必须 RBAC fail-closed", + () -> mockMvc.perform(post("/admin-api/muse/account/users/{userId}/quota-requests", ACCOUNT_USER_ID) + .header("X-API-Version", API_VERSION) + .contentType("application/json") + .content(adminQuotaBody("p1r-admin-quota-rbac-deny", "plan_config", 100))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(FORBIDDEN.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + securityFrameworkService.allowPermissions(); + + // 目标用户不存在 → fail-closed,不写库。 + assertNoAccountMutationDuring("管理端为不存在用户申请必须 fail-closed", + () -> mockMvc.perform(post("/admin-api/muse/account/users/{userId}/quota-requests", MISSING_USER_ID) + .header("X-API-Version", API_VERSION) + .contentType("application/json") + .content(adminQuotaBody("p1r-admin-quota-missing-user", "plan_config", 100))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_USER_NOT_EXISTS.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + // app 专属类型在管理端入口必须按参数非法拒绝,不写库。 + assertNoAccountMutationDuring("管理端不允许的请求类型必须拒绝且不写库", + () -> mockMvc.perform(post("/admin-api/muse/account/users/{userId}/quota-requests", ACCOUNT_USER_ID) + .header("X-API-Version", API_VERSION) + .contentType("application/json") + .content(adminQuotaBody("p1r-admin-quota-bad-type", "plan_sync", 100))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + assertEquals(1, quotaRequestCount(), "所有 fail-closed 管理端申请都不能写新的配额请求事实"); + } + + // ==================== 请求体构造 ==================== + + private String appQuotaBody(String commandId, String requestType) { + return """ + {"commandId":"%s","requestType":"%s"} + """.formatted(commandId, requestType); + } + + private String adminQuotaBody(String commandId, String requestType, int limit) { + return """ + { + "commandId":"%s", + "requestType":"%s", + "reason":"P1R 管理端配额请求验收", + "targetQuota":{"resourceType":"tokens","limit":%d} + } + """.formatted(commandId, requestType, limit); + } + + // ==================== 运行期上下文 ==================== + + private void setRuntimeContext(Long userId, Integer userType) { + TenantContextHolder.setTenantId(TENANT_ID); + LoginUser loginUser = new LoginUser(); + loginUser.setId(userId); + loginUser.setUserType(userType); + 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()) { + // 每个用例重置配额请求写路相关 fact + member_user 种子,保证读写断言能精确追踪本批 operation。 + statement.execute(""" + TRUNCATE TABLE + muse_account_audit, + muse_account_command, + muse_account_integration_call, + muse_account_quota_request, + member_user + RESTART IDENTITY CASCADE + """); + } + } + + private void seedMemberUser(Long userId) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + insertMemberUser(connection, TENANT_ID, userId, "Muse Account " + userId); + } + } + + 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 insertQuotaRequest(Connection connection, Long tenantId, String requestId, Long accountUserId, + Long requestedBy, String side, String quotaType, Long requestedAmount, + String status, String commandId, String correlationId) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_account_quota_request(request_id, account_user_id, requested_by, side, quota_type, + requested_amount, status, command_id, request_hash, + correlation_id, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """)) { + statement.setString(1, requestId); + statement.setLong(2, accountUserId); + statement.setLong(3, requestedBy); + statement.setString(4, side); + statement.setString(5, quotaType); + statement.setLong(6, requestedAmount); + statement.setString(7, status); + statement.setString(8, commandId); + statement.setString(9, SEED_HASH_64); + statement.setString(10, correlationId); + statement.setLong(11, tenantId); + assertEquals(1, statement.executeUpdate(), "seed 配额请求必须插入一行"); + } + } + + // ==================== 无突变断言(反假绿) ==================== + + private void assertNoAccountMutationDuring(String message, CheckedOperation operation) throws Exception { + AccountFactSnapshot before = accountFactSnapshot(); + operation.run(); + assertEquals(before, accountFactSnapshot(), message + ":不能新增、删除或更新任何 Account fact"); + } + + private AccountFactSnapshot accountFactSnapshot() throws SQLException { + return new AccountFactSnapshot( + tableSnapshot("member_user", "tenant_id, id"), + tableSnapshot("muse_account_quota_request", "tenant_id, id"), + tableSnapshot("muse_account_integration_call", "tenant_id, id"), + tableSnapshot("muse_account_command", "tenant_id, id"), + tableSnapshot("muse_account_audit", "tenant_id, id") + ); + } + + private List tableSnapshot(String tableName, String orderBy) throws SQLException { + String sql = """ + SELECT row_to_json(t)::text + FROM %s t + ORDER BY %s + """.formatted(tableName, orderBy); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + List rows = new ArrayList<>(); + while (resultSet.next()) { + rows.add(resultSet.getString(1)); + } + return rows; + } + } + + // ==================== 事实查询 ==================== + + private int quotaRequestCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_account_quota_request WHERE tenant_id = ?", TENANT_ID); + } + + private int integrationCallCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_account_integration_call WHERE tenant_id = ?", TENANT_ID); + } + + private int auditCount() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_account_audit WHERE tenant_id = ?", TENANT_ID); + } + + private int commandCountAll() throws SQLException { + return queryInt("SELECT COUNT(*) FROM muse_account_command WHERE tenant_id = ?", TENANT_ID); + } + + private int commandCountByOperation(String operationId) throws SQLException { + // result_snapshot 非空代表命令已通过 recordSucceeded 收尾(completed)。 + return queryInt(""" + SELECT COUNT(*) + FROM muse_account_command + WHERE tenant_id = ? + AND operation_id = ? + AND result_snapshot IS NOT NULL + """, TENANT_ID, operationId); + } + + private String quotaRequestField(String requestId, String column) throws SQLException { + return queryString("SELECT " + column + "::text FROM muse_account_quota_request " + + "WHERE tenant_id = ? AND request_id = ?", TENANT_ID, requestId); + } + + private String integrationCallField(String commandId, String column) throws SQLException { + return queryString("SELECT " + column + "::text FROM muse_account_integration_call " + + "WHERE tenant_id = ? AND command_id = ?", TENANT_ID, commandId); + } + + private String commandField(String commandId, String column) throws SQLException { + return queryString("SELECT " + column + "::text FROM muse_account_command " + + "WHERE tenant_id = ? AND command_id = ?", TENANT_ID, commandId); + } + + private String statusFromCommandResult(String commandId) throws SQLException { + String payload = queryString(""" + SELECT result_snapshot::text + FROM muse_account_command + WHERE tenant_id = ? + AND command_id = ? + """, TENANT_ID, commandId); + Map result = JsonUtils.parseObject(payload, Map.class); + assertNotNull(result, "Account command result_snapshot 必须是 JSON 对象"); + return String.valueOf(result.get("status")); + } + + 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 String stringData(MvcResult result, String fieldName) throws Exception { + Map response = JsonUtils.parseObject(result.getResponse().getContentAsString(), Map.class); + assertNotNull(response, "HTTP 响应必须是 JSON 对象"); + Object data = response.get("data"); + assertTrue(data instanceof Map, "HTTP 响应 data 必须是 JSON 对象"); + Object value = ((Map) data).get(fieldName); + assertNotNull(value, "响应 data 必须包含字段: " + fieldName); + return String.valueOf(value); + } + + // ==================== member_user fixture ==================== + + private void createMemberUserFixtureTable() { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + // member_user 是 yudao 基座表,不在 muse Flyway 迁移内;隔离库需自建 fixture 供 requireUser 校验。 + 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); + } + } + + // ==================== 连接配置(密码仅 env、URL 脱敏、_test 守卫) ==================== + + private static CompletedApprovalSettings settings() { + if (cachedSettings == null) { + cachedSettings = CompletedApprovalSettings.fromPropertiesAndEnvironment(); + } + return cachedSettings; + } + + private static void cleanSchema(Flyway flyway, CompletedApprovalSettings settings) { + try { + flyway.clean(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception); + } + } + + private static MigrateResult migrateSchema(Flyway flyway, CompletedApprovalSettings settings) { + try { + return flyway.migrate(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception); + } + } + + private static void ensureTestDatabaseExists(CompletedApprovalSettings settings) { + String databaseName = jdbcDatabaseName(settings.jdbcUrl()); + try (Connection connection = DriverManager.getConnection( + maintenanceJdbcUrl(settings.jdbcUrl()), settings.jdbcUser(), settings.jdbcPassword()); + Statement statement = connection.createStatement()) { + connection.setAutoCommit(true); + // 专属 _test 库不存在时自动创建,避免验收环境缺库导致真实 PG 证据中断。 + statement.execute("CREATE DATABASE " + quotedIdentifier(databaseName)); + } catch (SQLException exception) { + if ("42P04".equals(exception.getSQLState())) { + return; + } + throw sanitizedSqlFailure("创建 PostgreSQL _test 数据库失败", settings, exception); + } + } + + private static AssertionError sanitizedFlywayFailure(String action, CompletedApprovalSettings settings, + RuntimeException exception) { + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl())) + .replace("for user '" + settings.jdbcUser() + "'", "for user ''"); + return new AssertionError(action + ": " + sanitizedMessage); + } + + private static AssertionError sanitizedSqlFailure(String action, CompletedApprovalSettings settings, + SQLException exception) { + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl())) + .replace(settings.jdbcUser(), ""); + return new AssertionError(action + ": " + sanitizedMessage); + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name); + return value; + } + + private static String requiredPasswordEnvironment() { + String password = firstNonBlankEnvironment("P1R_ACCOUNT_QUOTA_REQUEST_COMPLETED_PASSWORD", + "P1R_ACCOUNT_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, + "缺少必需环境变量: P1R_ACCOUNT_QUOTA_REQUEST_COMPLETED_PASSWORD、P1R_ACCOUNT_COMPLETED_PASSWORD、" + + "P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD"); + return password; + } + + private static String firstNonBlankEnvironment(String... names) { + // 数据库密码只能来自环境变量,避免 Surefire XML 或 JVM 参数泄露。 + for (String name : names) { + String value = System.getenv(name); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static void assertNoPasswordSystemProperties() { + Properties properties = System.getProperties(); + List passwordProperties = properties.stringPropertyNames().stream() + .filter(P1rAccountQuotaRequestCompletedApprovalIT::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 配额请求 completed approval IT 要求显式使用 filesystem:sql/muse"); + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + String relativeLocation = requestedLocations.substring("filesystem:".length()); + for (Path cursor = current; cursor != null; cursor = cursor.getParent()) { + Path candidate = cursor.resolve(relativeLocation); + if (Files.isDirectory(candidate)) { + return "filesystem:" + candidate; + } + } + throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current); + } + + private static String jdbcDatabaseName(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1, + "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url)); + return urlWithoutQuery.substring(databaseStart + 1); + } + + private static String jdbcUrlWithoutQuery(String url) { + int queryStart = url.indexOf('?'); + return queryStart < 0 ? url : url.substring(0, queryStart); + } + + private static String maintenanceJdbcUrl(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1, + "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url)); + String querySuffix = url.indexOf('?') < 0 ? "" : url.substring(url.indexOf('?')); + return urlWithoutQuery.substring(0, databaseStart + 1) + "postgres" + querySuffix; + } + + private static String quotedIdentifier(String identifier) { + assertTrue(identifier.matches("[A-Za-z0-9_]+"), + "测试数据库名只能包含字母、数字和下划线: " + identifier); + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + private static String maskedUrl(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + if (databaseStart < 0) { + return maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url); + } + String prefix = urlWithoutQuery.substring(0, databaseStart + 1); + String database = urlWithoutQuery.substring(databaseStart + 1); + return maskJdbcHost(prefix) + database + maskedQuerySuffix(url); + } + + private static String maskedQuerySuffix(String url) { + return url.indexOf('?') < 0 ? "" : "?"; + } + + private static String maskJdbcHost(String urlPart) { + return urlPart.replaceAll("//([^:/?#]+)", "//"); + } + + private static void redactFlywaySystemProperties(String url, String user) { + captureOriginalFlywaySystemProperties(); + System.setProperty("p1r.flyway.url", maskedUrl(url)); + System.setProperty("p1r.flyway.user", user == null || user.isBlank() ? "" : ""); + } + + private static void captureOriginalFlywaySystemProperties() { + if (originalFlywayPropertiesCaptured) { + return; + } + // 该 IT 会为日志脱敏 p1r.flyway.*;先保存原值,避免同一 Surefire JVM 中污染后续 Flyway IT。 + originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url"); + originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user"); + originalFlywayPropertiesCaptured = true; + } + + private static void restoreOriginalFlywaySystemProperties() { + if (!originalFlywayPropertiesCaptured) { + return; + } + // 恢复用户传入的原始连接属性,让组合运行的 Flyway 验收测试继续读取真实 _test 库地址。 + restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty); + restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty); + } + + private static void restoreSystemProperty(String name, String value) { + if (value == null) { + System.clearProperty(name); + return; + } + System.setProperty(name, value); + } + + private static void silenceFlywayInfoLogs() { + try { + Object flywayLogger = LoggerFactory.getLogger("org.flywaydb"); + Class levelClass = Class.forName("ch.qos.logback.classic.Level"); + Object warnLevel = levelClass.getField("WARN").get(null); + flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel); + } catch (ReflectiveOperationException | LinkageError ignored) { + // 日志实现不是 logback 时不影响迁移验收;测试自身仍只输出脱敏 URL。 + } + } + + @FunctionalInterface + private interface CheckedOperation { + + void run() throws Exception; + } + + private record AccountFactSnapshot(List memberUsers, + List quotaRequests, + List integrationCalls, + List commands, + List audits) { + } + + private record CompletedApprovalSettings(String jdbcUrl, + String jdbcUser, + String jdbcPassword, + String flywayLocations) { + + static CompletedApprovalSettings fromPropertiesAndEnvironment() { + assertNoPasswordSystemProperties(); + String url = requiredProperty("p1r.flyway.url"); + String user = requiredProperty("p1r.flyway.user"); + String password = requiredPasswordEnvironment(); + String locations = requiredProperty("p1r.flyway.locations"); + assertNoCredentialQuery(url); + assertTestDatabaseUrl(url); + return new CompletedApprovalSettings(url, user, password, locations); + } + } + + static class TestSecurityFrameworkService implements SecurityFrameworkService { + + private final AtomicBoolean permissionAllowed = new AtomicBoolean(true); + private final AtomicReference lastPermission = new AtomicReference<>(); + + void allowPermissions() { + permissionAllowed.set(true); + lastPermission.set(null); + } + + void denyPermissions() { + permissionAllowed.set(false); + lastPermission.set(null); + } + + String lastPermission() { + return lastPermission.get(); + } + + @Override + public boolean hasPermission(String permission) { + lastPermission.set(permission); + return permissionAllowed.get(); + } + + @Override + public boolean hasAnyPermissions(String... permissions) { + lastPermission.set(permissions == null || permissions.length == 0 ? null : permissions[0]); + return permissionAllowed.get(); + } + + @Override + public boolean hasRole(String role) { + return false; + } + + @Override + public boolean hasAnyRoles(String... roles) { + return false; + } + + @Override + public boolean hasScope(String scope) { + return false; + } + + @Override + public boolean hasAnyScopes(String... scope) { + return false; + } + } + + @SpringBootConfiguration + @EnableMethodSecurity(securedEnabled = true) + @ImportAutoConfiguration({ + JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + TransactionAutoConfiguration.class, + RestTemplateAutoConfiguration.class, + WebMvcAutoConfiguration.class, + MuseDataSourceAutoConfiguration.class, + MuseMybatisAutoConfiguration.class, + MybatisPlusAutoConfiguration.class, + MybatisPlusJoinAutoConfiguration.class, + MuseWebAutoConfiguration.class + }) + @Import({ + AppAccountNewApiController.class, + AdminAccountNewApiController.class, + AccountQuotaServiceImpl.class, + AccountCommandServiceImpl.class, + SpringUtil.class + }) + static class QuotaRequestCompletedApprovalConfiguration { + + @Bean("ss") + TestSecurityFrameworkService securityFrameworkService() { + return new TestSecurityFrameworkService(); + } + + @Bean + AccountAuditService accountAuditService() { + // 配额请求链路不得写审计;任何调用都暴露为假绿。 + return req -> { + throw new AssertionError("配额请求 operation 不允许调用 AccountAuditService.record"); + }; + } + + @Bean + AccountEventPublishOutboxService accountEventPublishOutboxService() { + // 配额请求(非配额调整)不得写 entitlement outbox。 + return (log, sourceCommandId, requestHash) -> { + throw new AssertionError("配额请求 operation 不允许调用 AccountEventPublishOutboxService"); + }; + } + + @Bean + AccountNewApiBindingService accountNewApiBindingService() { + // 控制器附带依赖,配额请求用例不触达;任何调用都暴露为越界。 + return Mockito.mock(AccountNewApiBindingService.class, invocation -> { + throw new AssertionError("配额请求 IT 不允许调用 AccountNewApiBindingService." + + invocation.getMethod().getName()); + }); + } + + @Bean + AccountUsageService accountUsageService() { + return Mockito.mock(AccountUsageService.class, invocation -> { + throw new AssertionError("配额请求 IT 不允许调用 AccountUsageService." + + invocation.getMethod().getName()); + }); + } + + @Bean + TenantLineInnerInterceptor tenantLineInnerInterceptor(MybatisPlusInterceptor interceptor) { + // member 模块依赖租户行拦截器:写入自动注入 tenant_id、查询自动按 tenant 过滤。 + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor( + new TenantDatabaseInterceptor(new TenantProperties())); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return inner; + } + + @Bean + ApiErrorLogCommonApi apiErrorLogCommonApi() { + return new ApiErrorLogCommonApi() { + @Override + public CommonResult createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) { + return CommonResult.success(true); + } + }; + } + } +}