test(p1r): 验收债批9 account New-API 绑定簇真实 PG IT(3 op,4/4 绿)

新增 P1rAccountNewApiBindingClusterCompletedApprovalIT,经真实 HTTP 入口 +
真实 PostgreSQL(muse_p1r_account_newapi_binding_test,独立复跑两次 4/4 绿)
覆盖 getAppNewApiBinding / adminListNewApiBindings / appRecheckNewApiBinding:

- getAppNewApiBinding:按 owner 读绑定摘要 + 存储态归一(active→bound、
  无绑定→unbound 且不可重验),纯读 no-write;
- adminListNewApiBindings:以账户用户为分页主表(无筛选按 id 倒序返
  bound/unbound/sync_failed 三态 + syncErrorMessage)、bindingStatus 走
  EXISTS/NOT EXISTS 筛选、非法状态 BAD_REQUEST、RBAC(muse:account:query)
  fail-closed,纯读 no-write;
- appRecheckNewApiBinding:单体 New-API facade(UnavailableNewApiAccountFacade)
  不可用时正确 fail-closed —— 业务 @Transactional 回滚命令预占(0 残留),
  失败外部调用经 REQUIRES_NEW 独立落库一条 unavailable(error_code 可排障),
  binding 不被改写,客户端收 ACCOUNT_NEW_API_UNAVAILABLE;binding 不存在/
  缺用户/API version 三守卫全程 0 写;
- 控制器/服务附带依赖(quota/usage/audit service)用抛错 mock 反假绿越界;
- 仅对专属 _test 库缺库自动创建,保留 _test/密码仅 env/凭据 query 守卫。

同步 §五B(剩余 account 12→9)+ muse-module-member/.agent。
证据就绪,completed 待人工批(不自批台账=反假绿)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-16 23:42:09 -07:00
parent fa9dae84ce
commit 91f223acd8
3 changed files with 997 additions and 3 deletions

View File

@ -124,7 +124,8 @@
- **批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 待人工批**。
- **批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 待人工批**。
- **批8 ✅ 已交付(account 用量与外部调用可观测读端 3 op,2026-06-16)**:`P1rAccountUsageObservabilityCompletedApprovalIT`(真实 PG `muse_p1r_account_usage_observability_test`,4/4 绿,独立复跑两次均绿)覆盖 getAppUsage/appGetIntegrationCallByCorrelation/adminGetIntegrationCallByCorrelation;经真实 `/app-api/muse/account/usage``/app-api/muse/account/integration-calls/by-correlation/{id}``/admin-api/muse/account/integration-calls/by-correlation/{id}`(member 模块租户行拦截器 + member_user fixture + admin `@PreAuthorize` method security),断言:getAppUsage 按 owner 聚合(token 输入 160/输出 235 求和、pending 计数 1、anomaly 计数 1、byModel MiniMaxM2=380/GLM=15、byAttribution work+agent)且跨用户隔离(他人 9999 不计入)、period 规范化(month)与白名单(yearly→BAD_REQUEST);app 侧外部调用读 owner 守卫(自有→归一化 pending_attribution/quota_request/retryGroup、越权→`ACCOUNT_RESOURCE_FORBIDDEN`),admin 侧无 owner 限制(可读任意用户、status/requestType 归一化、RBAC=`muse:account:query`);三 op 纯读全程 no-write(member_user/usage_record/integration_call/command/audit 快照前后一致),fail-closed(缺用户 `ACCOUNT_USER_NOT_EXISTS`/not-found `ACCOUNT_INTEGRATION_CALL_NOT_EXISTS`/API version/RBAC deny FORBIDDEN)。控制器附带依赖(权益/绑定/配额/归因 service)用抛错 mock 反假绿越界。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。
- 剩余:content 26(约 15 为 FileService/New-API/SSE fail-closed → 只能验"失败关闭"真实证据)+ market 14 + account 12,按批续推。
- **批9 ✅ 已交付(account New-API 绑定簇 3 op,2026-06-16)**:`P1rAccountNewApiBindingClusterCompletedApprovalIT`(真实 PG `muse_p1r_account_newapi_binding_test`,4/4 绿,独立复跑两次均绿)覆盖 getAppNewApiBinding/adminListNewApiBindings/appRecheckNewApiBinding;经真实 `/app-api/muse/account/new-api-binding``/admin-api/muse/account/new-api-bindings``/app-api/muse/account/new-api-binding/recheck`(member 模块租户行拦截器 + member_user fixture + admin `@PreAuthorize` method security),断言:getAppNewApiBinding 按 owner 读绑定摘要 + 存储态归一(active→bound、无绑定→unbound 且不可重验)、纯读 no-write;adminListNewApiBindings 以账户用户为分页主表(无筛选按 id 倒序返 bound/unbound/sync_failed 三态 + syncErrorMessage)、bindingStatus 走 EXISTS/NOT EXISTS 筛选、非法状态 BAD_REQUEST、RBAC=`muse:account:query` fail-closed、纯读 no-write;appRecheckNewApiBinding 在单体 New-API facade(`UnavailableNewApiAccountFacade`)不可用时**正确 fail-closed**——业务 `@Transactional` 回滚命令预占(0 残留),失败外部调用经 `REQUIRES_NEW` 独立落库一条 `unavailable`(error_code 可排障),binding 不被改写,客户端收 `ACCOUNT_NEW_API_UNAVAILABLE`;binding 不存在/缺用户/API version 三守卫全程 0 写。控制器/服务附带依赖(quota/usage/audit service)用抛错 mock 反假绿越界。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。
- 剩余:content 26(约 15 为 FileService/New-API/SSE fail-closed → 只能验"失败关闭"真实证据)+ market 14 + account 9,按批续推。
- ✅ **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/平台预存红三项全清。**

View File

@ -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 绿;`P1rAccountQuotaRequestCompletedApprovalIT` 覆盖 appCreateQuotaRequest/appGetQuotaRequest/adminCreateQuotaRequest(命令幂等+commandId 冲突回滚+owner/tenant 隔离+RBAC+类型白名单),真实 PG 5/5 绿(独立复跑);`P1rAccountUsageObservabilityCompletedApprovalIT` 覆盖 getAppUsage/appGetIntegrationCallByCorrelation/adminGetIntegrationCallByCorrelation(用量按 owner 聚合 token/pending/anomaly/byModel+跨用户隔离+period 白名单、外部调用 app owner 守卫/admin 无 owner 限制+RBAC、三 op 纯读 no-write、缺用户/not-found/API version/RBAC fail-closed),真实 PG 4/4 绿(独立复跑);证据就绪,completed 仍待人工批准。
- **关键风险 / TODO**:剩余 account 验收债 12 op(quota-request 3 op 批7 + usage/integration-call 3 op 批8 已补证):exports/downloads/new-api recheck 属对象存储/New-API 外部依赖未配置的正确 fail-closed;new-api-binding/admin 用量记录/security event 等需按批补真实 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 绿(独立复跑);`P1rAccountUsageObservabilityCompletedApprovalIT` 覆盖 getAppUsage/appGetIntegrationCallByCorrelation/adminGetIntegrationCallByCorrelation(用量按 owner 聚合 token/pending/anomaly/byModel+跨用户隔离+period 白名单、外部调用 app owner 守卫/admin 无 owner 限制+RBAC、三 op 纯读 no-write、缺用户/not-found/API version/RBAC fail-closed),真实 PG 4/4 绿(独立复跑);`P1rAccountNewApiBindingClusterCompletedApprovalIT` 覆盖 getAppNewApiBinding/adminListNewApiBindings/appRecheckNewApiBinding(绑定摘要 owner 读 + active→bound 归一、admin 列表以账户用户为分页主表 + EXISTS/NOT EXISTS 状态筛选 + RBAC、两读 no-write、recheck 单体 New-API 不可用正确 fail-closed:命令预占回滚但失败调用 REQUIRES_NEW 独立落库),真实 PG 4/4 绿(独立复跑);证据就绪,completed 仍待人工批准。
- **关键风险 / TODO**:剩余 account 验收债 9 op(quota-request 3 op 批7 + usage/integration-call 3 op 批8 + new-api 绑定簇 3 op 批9 已补证,recheck 已证正确 fail-closed):exports/downloads(appCreateExportTask/appGetExportTask/appDownloadExport)属对象存储 FileService 未配置的正确 fail-closed、adminCreateNewApiBinding/call-attribution-job 属 New-API 外部依赖;adminListUsageRecords/adminListPurchaseRecords/adminGetCallAttributionJob/appAcknowledgeSecurityEvent 等内部读写需按批补真实 PG 证据。前端仍有深页未全覆盖真实后端。

View File

@ -0,0 +1,993 @@
package cn.iocoder.muse.server.framework.api;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.muse.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
import cn.iocoder.muse.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO;
import cn.iocoder.muse.framework.common.enums.UserTypeEnum;
import cn.iocoder.muse.framework.common.pojo.CommonResult;
import cn.iocoder.muse.framework.datasource.config.MuseDataSourceAutoConfiguration;
import cn.iocoder.muse.framework.mybatis.config.MuseMybatisAutoConfiguration;
import cn.iocoder.muse.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.muse.framework.security.core.LoginUser;
import cn.iocoder.muse.framework.security.core.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.AccountNewApiBindingServiceImpl;
import cn.iocoder.muse.module.member.application.account.AccountQuotaService;
import cn.iocoder.muse.module.member.application.account.AccountUsageService;
import cn.iocoder.muse.module.member.application.account.facade.NewApiAccountFacade;
import cn.iocoder.muse.module.member.application.account.facade.UnavailableNewApiAccountFacade;
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.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.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.module.member.enums.ErrorCodeConstants.ACCOUNT_API_VERSION_UNSUPPORTED;
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_NEW_API_BINDING_NOT_EXISTS;
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_NEW_API_UNAVAILABLE;
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.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 New-API 绑定簇 completed approval3 operation HTTP + 真实 PostgreSQL 证据
*
* <p>本测试只连接显式传入的 PostgreSQL {@code _test} 隔离库经真实
* {@code /app-api/muse/account/new-api-binding}{@code /admin-api/muse/account/new-api-bindings}
* {@code /app-api/muse/account/new-api-binding/recheck} 入口进入 Controller覆盖 getAppNewApiBinding
* adminListNewApiBindingsappRecheckNewApiBinding断言:绑定摘要按 owner + 存储状态归一(activebound
* pending)admin 列表以账户用户为分页主表 + 绑定状态筛选(EXISTS/NOT EXISTS)+ RBAC两读纯 no-write;
* recheck 在单体 New-API facade 不可用时**正确 fail-closed**业务事务回滚命令预占,但失败外部调用经
* {@code REQUIRES_NEW} 独立落库供排障,客户端收 {@code ACCOUNT_NEW_API_UNAVAILABLE};并校验 binding 不存在/
* 缺用户/API version fail-closed 全程 0 绑定读/列表只依赖真实 mapper,recheck 接真实
* {@link UnavailableNewApiAccountFacade}(单体唯一实现);控制器/服务附带依赖(quota/usage/audit)用抛错 mock
* 反假绿越界</p>
*/
@SpringBootTest(
classes = P1rAccountNewApiBindingClusterCompletedApprovalIT.NewApiBindingClusterCompletedApprovalConfiguration.class,
webEnvironment = SpringBootTest.WebEnvironment.MOCK
)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class P1rAccountNewApiBindingClusterCompletedApprovalIT {
private static final Long TENANT_ID = 100L;
private static final Long BOUND_USER_ID = 1001L;
private static final Long UNBOUND_USER_ID = 1002L;
private static final Long SYNC_FAILED_USER_ID = 1003L;
private static final Long ADMIN_OPERATOR_ID = 9001L;
private static final Long MISSING_USER_ID = 9099L;
private static final String API_VERSION = "1";
private static final String QUERY_PERMISSION = "muse:account:query";
private static final Set<String> CREDENTIAL_QUERY_KEYS = Set.of(
"user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password",
"token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token");
private static volatile CompletedApprovalSettings cachedSettings;
private static volatile boolean originalFlywayPropertiesCaptured;
private static volatile String originalFlywayUrlSystemProperty;
private static volatile String originalFlywayUserSystemProperty;
@Autowired
private DataSource dataSource;
@Autowired
private WebApplicationContext webApplicationContext;
@Autowired
private 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-newapi-binding-cluster-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);
// 绑定表(V2 muse_member_new_api_binding)+ 命令(V11 muse_account_command)+ 外部调用(V11)均在 V11 内就绪
assertTrue(result.migrationsExecuted >= 11,
"Account New-API 绑定簇 completed approval 至少要迁移到含 V11 命令/外部调用 schema实际: "
+ result.migrationsExecuted);
createMemberUserFixtureTable();
}
@BeforeEach
void setUp() throws Exception {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
resetAccountTables();
setRuntimeContext(BOUND_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.newapi-binding.password", "must-not-be-used");
try {
AssertionError error = assertThrows(AssertionError.class,
P1rAccountNewApiBindingClusterCompletedApprovalIT::assertNoPasswordSystemProperties);
assertTrue(error.getMessage().contains("p1r.account.newapi-binding.password"),
"拒绝 JVM password system property 时必须指出属性名");
} finally {
System.clearProperty("p1r.account.newapi-binding.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_getAppNewApiBindingForOwnerWithStatusNormalizationAndNoWrite() throws Exception {
seedMemberUser(BOUND_USER_ID, "Bound User");
seedMemberUser(UNBOUND_USER_ID, "Unbound User");
// active 是历史存储态读端必须归一为合同态 bound
seedBinding(BOUND_USER_ID, "newapi-1001", "active", "cmd-seed-1001");
// 属主有绑定 bound + 可重验
assertNoAccountMutationDuring("getAppNewApiBinding happy path 是纯读 operation",
() -> mockMvc.perform(get("/app-api/muse/account/new-api-binding")
.header("X-API-Version", API_VERSION))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.bindingStatus").value("bound"))
.andExpect(jsonPath("$.data.canRecheck").value(true))
.andExpect(jsonPath("$.data.boundAt").exists())
.andExpect(jsonPath("$.data.lastSyncAt").exists()));
// 属主无绑定 unbound + 不可重验,且不会读到他人绑定
setRuntimeContext(UNBOUND_USER_ID, UserTypeEnum.MEMBER.getValue());
assertNoAccountMutationDuring("getAppNewApiBinding 无绑定时返回 unbound",
() -> mockMvc.perform(get("/app-api/muse/account/new-api-binding")
.header("X-API-Version", API_VERSION))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.bindingStatus").value("unbound"))
.andExpect(jsonPath("$.data.canRecheck").value(false)));
// 缺失登录用户事实 fail-closed,不写库
setRuntimeContext(MISSING_USER_ID, UserTypeEnum.MEMBER.getValue());
assertNoAccountMutationDuring("getAppNewApiBinding 缺失登录用户必须拒绝",
() -> mockMvc.perform(get("/app-api/muse/account/new-api-binding")
.header("X-API-Version", API_VERSION))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ACCOUNT_USER_NOT_EXISTS.getCode()))
.andExpect(jsonPath("$.data").doesNotExist()));
// API version 非法 拒绝,不写库
setRuntimeContext(BOUND_USER_ID, UserTypeEnum.MEMBER.getValue());
assertNoAccountMutationDuring("getAppNewApiBinding 非法 API version 必须拒绝",
() -> mockMvc.perform(get("/app-api/muse/account/new-api-binding")
.header("X-API-Version", "0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode()))
.andExpect(jsonPath("$.data").doesNotExist()));
}
@Test
void should_adminListNewApiBindingsWithStatusFilterRbacAndNoWrite() throws Exception {
setRuntimeContext(ADMIN_OPERATOR_ID, UserTypeEnum.ADMIN.getValue());
seedMemberUser(BOUND_USER_ID, "Bound User");
seedMemberUser(UNBOUND_USER_ID, "Unbound User");
seedMemberUser(SYNC_FAILED_USER_ID, "SyncFailed User");
seedBinding(BOUND_USER_ID, "newapi-1001", "active", "cmd-seed-1001");
seedBinding(SYNC_FAILED_USER_ID, "newapi-1003", "sync_failed", "cmd-seed-1003");
// 无筛选 三个账户用户都在, id 倒序;摘要归一各自绑定态
assertNoAccountMutationDuring("adminListNewApiBindings 是纯读 operation",
() -> mockMvc.perform(get("/admin-api/muse/account/new-api-bindings")
.header("X-API-Version", API_VERSION)
.param("pageNo", "1")
.param("pageSize", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(3))
.andExpect(jsonPath("$.data.list[0].userId").value(String.valueOf(SYNC_FAILED_USER_ID)))
.andExpect(jsonPath("$.data.list[0].bindingStatus").value("sync_failed"))
.andExpect(jsonPath("$.data.list[0].syncErrorMessage").value("New-API 同步失败"))
.andExpect(jsonPath("$.data.list[1].userId").value(String.valueOf(UNBOUND_USER_ID)))
.andExpect(jsonPath("$.data.list[1].bindingStatus").value("unbound"))
.andExpect(jsonPath("$.data.list[2].userId").value(String.valueOf(BOUND_USER_ID)))
.andExpect(jsonPath("$.data.list[2].bindingStatus").value("bound")));
// 放行态下首个 admin 调用即应触达 method security query 权限校验
assertEquals(QUERY_PERMISSION, securityFrameworkService.lastPermission(),
"adminListNewApiBindings 必须经 method security 校验 query 权限");
// bound 筛选 命中 active/bound 绑定的用户
assertNoAccountMutationDuring("adminListNewApiBindings bound 筛选",
() -> mockMvc.perform(get("/admin-api/muse/account/new-api-bindings")
.header("X-API-Version", API_VERSION)
.param("bindingStatus", "bound"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.list[0].userId").value(String.valueOf(BOUND_USER_ID)))
.andExpect(jsonPath("$.data.list[0].bindingStatus").value("bound")));
// unbound 筛选 命中无绑定事实的用户(以账户用户为分页主表)
assertNoAccountMutationDuring("adminListNewApiBindings unbound 筛选",
() -> mockMvc.perform(get("/admin-api/muse/account/new-api-bindings")
.header("X-API-Version", API_VERSION)
.param("bindingStatus", "unbound"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.list[0].userId").value(String.valueOf(UNBOUND_USER_ID)))
.andExpect(jsonPath("$.data.list[0].bindingStatus").value("unbound")));
// 非法 bindingStatus BAD_REQUEST,不写库
assertNoAccountMutationDuring("adminListNewApiBindings 非法状态必须拒绝",
() -> mockMvc.perform(get("/admin-api/muse/account/new-api-bindings")
.header("X-API-Version", API_VERSION)
.param("bindingStatus", "not-a-status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.data").doesNotExist()));
// RBAC 拒绝 forbidden,不写库,不泄露
securityFrameworkService.denyPermissions();
assertNoAccountMutationDuring("adminListNewApiBindings 无 query 权限必须 RBAC fail-closed",
() -> mockMvc.perform(get("/admin-api/muse/account/new-api-bindings")
.header("X-API-Version", API_VERSION))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(FORBIDDEN.getCode()))
.andExpect(jsonPath("$.data").doesNotExist()));
// 拒绝态调用同样要先触达权限校验(再返回 false),证明 RBAC method security fail-closed
assertEquals(QUERY_PERMISSION, securityFrameworkService.lastPermission(),
"adminListNewApiBindings RBAC 拒绝也必须经 method security 校验 query 权限");
}
@Test
void should_appRecheckNewApiBindingFailClosedWhenNewApiUnavailable() throws Exception {
seedMemberUser(BOUND_USER_ID, "Bound User");
seedMemberUser(UNBOUND_USER_ID, "Unbound User");
seedBinding(BOUND_USER_ID, "newapi-1001", "active", "cmd-seed-1001");
List<String> bindingBefore = tableSnapshot("muse_member_new_api_binding", "tenant_id, id");
// 单体 New-API 不可用 正确 fail-closed:客户端收 UNAVAILABLE
mockMvc.perform(post("/app-api/muse/account/new-api-binding/recheck")
.header("X-API-Version", API_VERSION)
.contentType("application/json")
.content("{\"commandId\":\"cmd-recheck-1001\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ACCOUNT_NEW_API_UNAVAILABLE.getCode()))
.andExpect(jsonPath("$.data").doesNotExist());
// 业务事务回滚 命令预占不落库;但失败外部调用经 REQUIRES_NEW 独立提交供 correlation 排障
assertEquals(0, queryInt("SELECT count(*) FROM muse_account_command"),
"recheck fail-closed 必须回滚命令预占,不残留 command 行");
assertEquals(1, queryInt("SELECT count(*) FROM muse_account_integration_call "
+ "WHERE account_user_id = ? AND operation_id = 'appRecheckNewApiBinding' "
+ "AND status = 'unavailable'", BOUND_USER_ID),
"recheck fail-closed 必须独立落库一条 unavailable 外部调用");
assertEquals("ACCOUNT_NEW_API_UNAVAILABLE", queryString(
"SELECT error_code FROM muse_account_integration_call "
+ "WHERE account_user_id = ? AND operation_id = 'appRecheckNewApiBinding'",
BOUND_USER_ID),
"失败外部调用必须记录可排障的 error_code");
// 绑定本身不被 recheck 改写
assertEquals(bindingBefore, tableSnapshot("muse_member_new_api_binding", "tenant_id, id"),
"recheck fail-closed 不能改写绑定事实");
// 无绑定用户 recheck not exists,且在命令预占前抛出 0
setRuntimeContext(UNBOUND_USER_ID, UserTypeEnum.MEMBER.getValue());
assertNoAccountMutationDuring("recheck 无绑定必须 not exists 且 0 写",
() -> mockMvc.perform(post("/app-api/muse/account/new-api-binding/recheck")
.header("X-API-Version", API_VERSION)
.contentType("application/json")
.content("{\"commandId\":\"cmd-recheck-1002\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ACCOUNT_NEW_API_BINDING_NOT_EXISTS.getCode()))
.andExpect(jsonPath("$.data").doesNotExist()));
// 缺失登录用户 fail-closed,0
setRuntimeContext(MISSING_USER_ID, UserTypeEnum.MEMBER.getValue());
assertNoAccountMutationDuring("recheck 缺失登录用户必须拒绝且 0 写",
() -> mockMvc.perform(post("/app-api/muse/account/new-api-binding/recheck")
.header("X-API-Version", API_VERSION)
.contentType("application/json")
.content("{\"commandId\":\"cmd-recheck-9099\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ACCOUNT_USER_NOT_EXISTS.getCode()))
.andExpect(jsonPath("$.data").doesNotExist()));
// API version 非法 守卫在进入服务前拒绝,0
setRuntimeContext(BOUND_USER_ID, UserTypeEnum.MEMBER.getValue());
assertNoAccountMutationDuring("recheck 非法 API version 必须在守卫拒绝且 0 写",
() -> mockMvc.perform(post("/app-api/muse/account/new-api-binding/recheck")
.header("X-API-Version", "0")
.contentType("application/json")
.content("{\"commandId\":\"cmd-recheck-bad-version\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode()))
.andExpect(jsonPath("$.data").doesNotExist()));
}
// ==================== 运行期上下文 ====================
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()) {
// 每个用例重置读/写路触达表 + 必须保持不变的写表,保证 no-write fail-closed 断言可识别任何误写
statement.execute("""
TRUNCATE TABLE
muse_account_audit,
muse_account_command,
muse_account_integration_call,
muse_member_new_api_binding,
member_user
RESTART IDENTITY CASCADE
""");
}
}
private void seedMemberUser(Long userId, String nickname) throws SQLException {
try (Connection connection = dataSource.getConnection()) {
insertMemberUser(connection, TENANT_ID, userId, nickname);
}
}
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 seedBinding(Long accountUserId, String newapiUserId, String bindingStatus, String commandId)
throws SQLException {
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement("""
INSERT INTO muse_member_new_api_binding(account_user_id, newapi_user_id, binding_status, command_id,
last_sync_at, tenant_id)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
""")) {
statement.setLong(1, accountUserId);
statement.setString(2, newapiUserId);
statement.setString(3, bindingStatus);
statement.setString(4, commandId);
statement.setLong(5, TENANT_ID);
assertEquals(1, statement.executeUpdate(), "seed New-API 绑定必须插入一行");
}
}
// ==================== 无突变断言(反假绿) ====================
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_member_new_api_binding", "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<String> tableSnapshot(String tableName, String orderBy) throws SQLException {
String sql = """
SELECT row_to_json(t)::text
FROM %s t
ORDER BY %s
""".formatted(tableName, orderBy);
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
List<String> rows = new ArrayList<>();
while (resultSet.next()) {
rows.add(resultSet.getString(1));
}
return rows;
}
}
private int queryInt(String sql, Object... args) throws SQLException {
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
bindArgs(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)) {
bindArgs(statement, args);
try (ResultSet resultSet = statement.executeQuery()) {
assertTrue(resultSet.next(), "单值查询必须返回一行");
return resultSet.getString(1);
}
}
}
private void bindArgs(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 {
statement.setString(i + 1, 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);
}
}
// ==================== 连接配置(密码仅 envURL 脱敏_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 '<user-redacted>'");
return new AssertionError(action + ": " + sanitizedMessage);
}
private static AssertionError sanitizedSqlFailure(String action, CompletedApprovalSettings settings,
SQLException exception) {
String sanitizedMessage = Objects.toString(exception.getMessage(), "")
.replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl()))
.replace(settings.jdbcUser(), "<user-redacted>");
return new AssertionError(action + ": " + sanitizedMessage);
}
private static String requiredProperty(String name) {
String value = System.getProperty(name);
assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name);
return value;
}
private static String requiredPasswordEnvironment() {
String password = firstNonBlankEnvironment("P1R_ACCOUNT_NEWAPI_BINDING_COMPLETED_PASSWORD",
"P1R_ACCOUNT_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD");
assertTrue(password != null,
"缺少必需环境变量: P1R_ACCOUNT_NEWAPI_BINDING_COMPLETED_PASSWORD、P1R_ACCOUNT_COMPLETED_PASSWORD、"
+ "P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD");
return password;
}
private static String firstNonBlankEnvironment(String... names) {
// 数据库密码只能来自环境变量避免 Surefire XML JVM 参数泄露
for (String name : names) {
String value = System.getenv(name);
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private static void assertNoPasswordSystemProperties() {
Properties properties = System.getProperties();
List<String> passwordProperties = properties.stringPropertyNames().stream()
.filter(P1rAccountNewApiBindingClusterCompletedApprovalIT::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 New-API 绑定簇 completed approval IT 要求显式使用 filesystem:sql/muse");
Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath();
String relativeLocation = requestedLocations.substring("filesystem:".length());
for (Path cursor = current; cursor != null; cursor = cursor.getParent()) {
Path candidate = cursor.resolve(relativeLocation);
if (Files.isDirectory(candidate)) {
return "filesystem:" + candidate;
}
}
throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current);
}
private static String jdbcDatabaseName(String url) {
String urlWithoutQuery = jdbcUrlWithoutQuery(url);
int databaseStart = urlWithoutQuery.lastIndexOf('/');
assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1,
"p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url));
return urlWithoutQuery.substring(databaseStart + 1);
}
private static String jdbcUrlWithoutQuery(String url) {
int queryStart = url.indexOf('?');
return queryStart < 0 ? url : url.substring(0, queryStart);
}
private static String maintenanceJdbcUrl(String url) {
String urlWithoutQuery = jdbcUrlWithoutQuery(url);
int databaseStart = urlWithoutQuery.lastIndexOf('/');
assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1,
"p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url));
String querySuffix = url.indexOf('?') < 0 ? "" : url.substring(url.indexOf('?'));
return urlWithoutQuery.substring(0, databaseStart + 1) + "postgres" + querySuffix;
}
private static String quotedIdentifier(String identifier) {
assertTrue(identifier.matches("[A-Za-z0-9_]+"),
"测试数据库名只能包含字母、数字和下划线: " + identifier);
return "\"" + identifier.replace("\"", "\"\"") + "\"";
}
private static String maskedUrl(String url) {
String urlWithoutQuery = jdbcUrlWithoutQuery(url);
int databaseStart = urlWithoutQuery.lastIndexOf('/');
if (databaseStart < 0) {
return maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url);
}
String prefix = urlWithoutQuery.substring(0, databaseStart + 1);
String database = urlWithoutQuery.substring(databaseStart + 1);
return maskJdbcHost(prefix) + database + maskedQuerySuffix(url);
}
private static String maskedQuerySuffix(String url) {
return url.indexOf('?') < 0 ? "" : "?<query-redacted>";
}
private static String maskJdbcHost(String urlPart) {
return urlPart.replaceAll("//([^:/?#]+)", "//<host>");
}
private static void redactFlywaySystemProperties(String url, String user) {
captureOriginalFlywaySystemProperties();
System.setProperty("p1r.flyway.url", maskedUrl(url));
System.setProperty("p1r.flyway.user", user == null || user.isBlank() ? "<user-redacted>" : "<user-redacted>");
}
private static void captureOriginalFlywaySystemProperties() {
if (originalFlywayPropertiesCaptured) {
return;
}
// IT 会为日志脱敏 p1r.flyway.*先保存原值避免同一 Surefire JVM 中污染后续 Flyway IT
originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url");
originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user");
originalFlywayPropertiesCaptured = true;
}
private static void restoreOriginalFlywaySystemProperties() {
if (!originalFlywayPropertiesCaptured) {
return;
}
// 恢复用户传入的原始连接属性让组合运行的 Flyway 验收测试继续读取真实 _test 库地址
restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty);
restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty);
}
private static void restoreSystemProperty(String name, String value) {
if (value == null) {
System.clearProperty(name);
return;
}
System.setProperty(name, value);
}
private static void silenceFlywayInfoLogs() {
try {
Object flywayLogger = LoggerFactory.getLogger("org.flywaydb");
Class<?> levelClass = Class.forName("ch.qos.logback.classic.Level");
Object warnLevel = levelClass.getField("WARN").get(null);
flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel);
} catch (ReflectiveOperationException | LinkageError ignored) {
// 日志实现不是 logback 时不影响迁移验收测试自身仍只输出脱敏 URL
}
}
@FunctionalInterface
private interface CheckedOperation {
void run() throws Exception;
}
private record AccountFactSnapshot(List<String> memberUsers,
List<String> bindings,
List<String> integrationCalls,
List<String> commands,
List<String> 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<String> 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,
AccountNewApiBindingServiceImpl.class,
AccountCommandServiceImpl.class,
SpringUtil.class
})
static class NewApiBindingClusterCompletedApprovalConfiguration {
@Bean("ss")
TestSecurityFrameworkService securityFrameworkService() {
return new TestSecurityFrameworkService();
}
@Bean
NewApiAccountFacade newApiAccountFacade() {
// 单体部署唯一实现:New-API 未配置时所有外部调用 fail-closed,recheck 走真实失败路径
return new UnavailableNewApiAccountFacade();
}
@Bean
AccountQuotaService accountQuotaService() {
// 控制器附带依赖,绑定簇用例不触达;任何调用都暴露为越界
return Mockito.mock(AccountQuotaService.class, invocation -> {
throw new AssertionError("New-API 绑定簇 IT 不允许调用 AccountQuotaService."
+ invocation.getMethod().getName());
});
}
@Bean
AccountUsageService accountUsageService() {
return Mockito.mock(AccountUsageService.class, invocation -> {
throw new AssertionError("New-API 绑定簇 IT 不允许调用 AccountUsageService."
+ invocation.getMethod().getName());
});
}
@Bean
AccountAuditService accountAuditService() {
// 绑定读/列表/recheck 都不写审计( adminCreateNewApiBinding ),抛错 mock 反假绿
return Mockito.mock(AccountAuditService.class, invocation -> {
throw new AssertionError("New-API 绑定簇 IT 不允许调用 AccountAuditService."
+ invocation.getMethod().getName());
});
}
@Bean
TenantLineInnerInterceptor tenantLineInnerInterceptor(MybatisPlusInterceptor interceptor) {
// member 模块依赖租户行拦截器:查询自动按 tenant 过滤插入自动补 tenant_id
TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(
new TenantDatabaseInterceptor(new TenantProperties()));
MyBatisUtils.addInterceptor(interceptor, inner, 0);
return inner;
}
@Bean
ApiErrorLogCommonApi apiErrorLogCommonApi() {
return new ApiErrorLogCommonApi() {
@Override
public CommonResult<Boolean> createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) {
return CommonResult.success(true);
}
};
}
}
}