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);
+ }
+ };
+ }
+ }
+}