fix(p1r): 退役 Account 合同兜底路由

This commit is contained in:
zizi 2026-05-30 01:50:32 +08:00
parent a37dc80609
commit c4a8f4ea5d
10 changed files with 310 additions and 143 deletions

View File

@ -1,52 +0,0 @@
package cn.iocoder.muse.module.member.controller.admin;
import cn.iocoder.muse.framework.common.pojo.CommonResult;
import cn.iocoder.muse.framework.mybatis.core.muse.MuseContractPersistenceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.muse.framework.common.pojo.CommonResult.success;
import static cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
/**
* 管理后台 - Muse Account 合同入口
*
* <p>该入口只负责 P1 合同路由兜底写命令基础校验和统一响应封装领域事实由后续 Application 服务逐步替换</p>
*/
@Tag(name = "管理后台 - Muse Account 合同入口")
@RestController
@RequestMapping("/muse")
@Validated
public class AdminMuseAccountContractController {
/** 当前 Controller 拥有的 OpenAPI 合同域。 */
private static final Set<String> DOMAINS = Set.of("account");
@Resource
private MuseContractPersistenceService contractPersistenceService;
/**
* 处理当前领域下尚未落成专用 Controller OpenAPI 合同请求
*
* @param request Servlet 请求用于取得真实方法路径和命令头
* @param queryParams 查询参数
* @param body 可选 JSON 请求体
* @return 合同占位响应
*/
@RequestMapping(value = {"/account/**"}, method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.PATCH, RequestMethod.DELETE})
@Operation(summary = "处理 Muse Admin account 合同请求")
public CommonResult<Map<String, Object>> handle(HttpServletRequest request,
@RequestParam Map<String, String> queryParams,
@RequestBody(required = false) Map<String, Object> body) {
return success(contractPersistenceService.handle(DOMAINS, "admin", request.getMethod(), request.getRequestURI(),
request.getHeader("X-Command-Id"), queryParams, body, getLoginUserId()));
}
}

View File

@ -1,52 +0,0 @@
package cn.iocoder.muse.module.member.controller.app;
import cn.iocoder.muse.framework.common.pojo.CommonResult;
import cn.iocoder.muse.framework.mybatis.core.muse.MuseContractPersistenceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.muse.framework.common.pojo.CommonResult.success;
import static cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
/**
* 用户 APP - Muse Account 合同入口
*
* <p>该入口只负责 P1 合同路由兜底写命令基础校验和统一响应封装领域事实由后续 Application 服务逐步替换</p>
*/
@Tag(name = "用户 APP - Muse Account 合同入口")
@RestController
@RequestMapping("/muse")
@Validated
public class AppMuseAccountContractController {
/** 当前 Controller 拥有的 OpenAPI 合同域。 */
private static final Set<String> DOMAINS = Set.of("account");
@Resource
private MuseContractPersistenceService contractPersistenceService;
/**
* 处理当前领域下尚未落成专用 Controller OpenAPI 合同请求
*
* @param request Servlet 请求用于取得真实方法路径和命令头
* @param queryParams 查询参数
* @param body 可选 JSON 请求体
* @return 合同占位响应
*/
@RequestMapping(value = {"/me", "/profile", "/account/**"}, method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.PATCH, RequestMethod.DELETE})
@Operation(summary = "处理 Muse APP account 合同请求")
public CommonResult<Map<String, Object>> handle(HttpServletRequest request,
@RequestParam Map<String, String> queryParams,
@RequestBody(required = false) Map<String, Object> body) {
return success(contractPersistenceService.handle(DOMAINS, "app", request.getMethod(), request.getRequestURI(),
request.getHeader("X-Command-Id"), queryParams, body, getLoginUserId()));
}
}

View File

@ -8,12 +8,12 @@ import cn.iocoder.muse.module.member.application.account.AccountEntitlementServi
import cn.iocoder.muse.module.member.application.account.AccountProfileService;
import cn.iocoder.muse.module.member.application.account.AccountQuotaService;
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
import cn.iocoder.muse.module.member.controller.admin.AdminMuseAccountContractController;
import cn.iocoder.muse.module.member.controller.admin.account.vo.AdminAccountUserSummaryRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.AdminBalanceSnapshotEntryRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.AdminUserEntitlementDetailRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.QuotaAdjustmentLedgerEntryRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.QuotaAdjustmentResultRespVO;
import cn.iocoder.muse.module.member.domain.account.AccountUserIdResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
@ -31,6 +31,7 @@ import java.util.List;
import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_API_VERSION_UNSUPPORTED;
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_USER_NOT_EXISTS;
import static org.hamcrest.Matchers.hasKey;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@ -232,49 +233,66 @@ class AdminAccountUserControllerTest extends BaseMockitoUnitTest {
}
@Test
void should_rejectNonNumericUserIdAsUnifiedBadRequest_when_getUserEntitlements() throws Exception {
void should_returnUserNotExists_when_getUserEntitlementsUserIdIsNonNumeric() throws Exception {
when(entitlementService.getAdminUserEntitlements(AccountUserIdResolver.NON_NUMERIC_USER_ID_MARKER))
.thenThrow(new ServiceException(ACCOUNT_USER_NOT_EXISTS));
mockMvc.perform(get("/muse/account/users/external-user/entitlements")
.header("X-API-Version", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.code").value(ACCOUNT_USER_NOT_EXISTS.getCode()))
.andExpect(jsonPath("$").value(hasKey("data")));
verifyNoInteractions(entitlementService);
verify(entitlementService).getAdminUserEntitlements(AccountUserIdResolver.NON_NUMERIC_USER_ID_MARKER);
}
@Test
void should_rejectNonNumericUserIdAsUnifiedBadRequest_when_createQuotaAdjustment() throws Exception {
void should_returnUserNotExists_when_createQuotaAdjustmentUserIdIsNonNumeric() throws Exception {
when(quotaService.adminCreateQuotaAdjustment(eq(AccountUserIdResolver.NON_NUMERIC_USER_ID_MARKER), any(), any()))
.thenThrow(new ServiceException(ACCOUNT_USER_NOT_EXISTS));
mockMvc.perform(post("/muse/account/users/external-user/quota-adjustments")
.header("X-API-Version", "1")
.contentType("application/json")
.content(quotaAdjustmentBody()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.code").value(ACCOUNT_USER_NOT_EXISTS.getCode()))
.andExpect(jsonPath("$").value(hasKey("data")));
verifyNoInteractions(quotaService);
verify(quotaService).adminCreateQuotaAdjustment(eq(AccountUserIdResolver.NON_NUMERIC_USER_ID_MARKER),
any(), any());
}
@Test
void should_rejectNonNumericUserIdAsUnifiedBadRequest_when_listQuotaAdjustments() throws Exception {
void should_returnEmptyPage_when_listQuotaAdjustmentsUserIdIsNonNumeric() throws Exception {
when(quotaService.adminListQuotaAdjustments(eq(AccountUserIdResolver.NON_NUMERIC_USER_ID_MARKER), any(), isNull()))
.thenReturn(new AccountPageResult<>(0L, 1, 20, List.of()));
mockMvc.perform(get("/muse/account/users/external-user/quota-adjustments")
.header("X-API-Version", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
.andExpect(jsonPath("$").value(hasKey("data")));
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(0))
.andExpect(jsonPath("$.data.list").isEmpty());
verifyNoInteractions(quotaService);
verify(quotaService).adminListQuotaAdjustments(eq(AccountUserIdResolver.NON_NUMERIC_USER_ID_MARKER),
any(), isNull());
}
@Test
void should_rejectNonNumericUserIdAsUnifiedBadRequest_when_getBalanceSnapshots() throws Exception {
void should_returnEmptyPage_when_getBalanceSnapshotsUserIdIsNonNumeric() throws Exception {
when(entitlementService.getAdminBalanceSnapshots(eq(AccountUserIdResolver.NON_NUMERIC_USER_ID_MARKER), any()))
.thenReturn(new AccountPageResult<>(0L, 1, 20, List.of()));
mockMvc.perform(get("/muse/account/users/external-user/balance-snapshots")
.header("X-API-Version", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
.andExpect(jsonPath("$").value(hasKey("data")));
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(0))
.andExpect(jsonPath("$.data.list").isEmpty());
verifyNoInteractions(entitlementService);
verify(entitlementService).getAdminBalanceSnapshots(eq(AccountUserIdResolver.NON_NUMERIC_USER_ID_MARKER),
any());
}
@Test
@ -389,8 +407,8 @@ class AdminAccountUserControllerTest extends BaseMockitoUnitTest {
}
@Test
void should_keepDedicatedUserRouteAheadOfCatchAll_when_contractControllerExists() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AdminMuseAccountContractController())
void should_keepDedicatedUserRouteOwnedByAccountController() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new TestExceptionAdvice())
.build();
AdminAccountUserSummaryRespVO summary = new AdminAccountUserSummaryRespVO();

View File

@ -9,7 +9,6 @@ import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.member.application.account.AccountEntitlementService;
import cn.iocoder.muse.module.member.application.account.AccountUsageService;
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
import cn.iocoder.muse.module.member.controller.app.AppMuseAccountContractController;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppBalanceSnapshotEntryRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppEntitlementRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppUsageSummaryRespVO;
@ -190,8 +189,8 @@ class AppAccountEntitlementControllerTest extends BaseMockitoUnitTest {
}
@Test
void should_rejectMissingApiVersion_beforeCatchAll_when_entitlementRouteExists() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController())
void should_rejectMissingApiVersion_onDedicatedEntitlementRoute() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new TestExceptionAdvice())
.build();

View File

@ -8,7 +8,6 @@ import cn.iocoder.muse.framework.security.core.LoginUser;
import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.member.application.account.AccountExportService;
import cn.iocoder.muse.module.member.controller.app.AppMuseAccountContractController;
import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskCreateReqVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskDetailRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.AccountExportTaskResultRespVO;
@ -66,8 +65,8 @@ class AppAccountExportControllerTest extends BaseMockitoUnitTest {
}
@Test
void should_rejectMissingApiVersion_beforeCatchAll_when_exportRoutesExist() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController())
void should_rejectMissingApiVersion_onDedicatedExportRoutes() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new TestExceptionAdvice())
.build();

View File

@ -8,7 +8,6 @@ import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.member.application.account.AccountMarketRecordService;
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
import cn.iocoder.muse.module.member.controller.app.AppMuseAccountContractController;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppLicenseRecordRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppPublishRecordRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppPurchaseRecordRespVO;
@ -67,8 +66,8 @@ class AppAccountMarketRecordControllerTest extends BaseMockitoUnitTest {
}
@Test
void should_rejectMissingApiVersion_beforeCatchAll_when_marketRoutesExist() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController())
void should_rejectMissingApiVersion_onDedicatedMarketRoutes() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new TestExceptionAdvice())
.build();

View File

@ -9,7 +9,6 @@ import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.member.application.account.AccountNewApiBindingService;
import cn.iocoder.muse.module.member.application.account.AccountQuotaService;
import cn.iocoder.muse.module.member.application.account.AccountUsageService;
import cn.iocoder.muse.module.member.controller.app.AppMuseAccountContractController;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppIntegrationCallDetailRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppNewApiBindingRecheckRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppNewApiBindingSummaryRespVO;
@ -69,8 +68,8 @@ class AppAccountNewApiControllerTest extends BaseMockitoUnitTest {
}
@Test
void should_rejectMissingApiVersion_beforeCatchAll_when_bindingRouteExists() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController())
void should_rejectMissingApiVersion_onDedicatedBindingRoute() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new TestExceptionAdvice())
.build();

View File

@ -8,7 +8,6 @@ import cn.iocoder.muse.framework.security.core.LoginUser;
import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.member.application.account.AccountProfileService;
import cn.iocoder.muse.module.member.controller.app.AppMuseAccountContractController;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppProfileRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.AppProfileUpdateReqVO;
import org.junit.jupiter.api.AfterEach;
@ -70,8 +69,8 @@ class AppAccountProfileControllerTest extends BaseMockitoUnitTest {
}
@Test
void should_keepDedicatedProfileRouteAheadOfCatchAll_when_missingVersion() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController())
void should_rejectMissingApiVersion_onDedicatedProfileRoute() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new TestExceptionAdvice())
.build();
@ -82,8 +81,8 @@ class AppAccountProfileControllerTest extends BaseMockitoUnitTest {
}
@Test
void should_keepDedicatedProfileRouteAheadOfCatchAll_when_versionPresent() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController())
void should_keepDedicatedProfileRouteOwnedByAccountController() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new TestExceptionAdvice())
.build();
AppProfileRespVO profile = new AppProfileRespVO();

View File

@ -8,7 +8,6 @@ import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.member.application.account.AccountSecurityService;
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
import cn.iocoder.muse.module.member.controller.app.AppMuseAccountContractController;
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventAcknowledgeResultRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventDetailRespVO;
import cn.iocoder.muse.module.member.controller.app.account.vo.SecurityEventSummaryRespVO;
@ -65,8 +64,8 @@ class AppAccountSecurityControllerTest extends BaseMockitoUnitTest {
}
@Test
void should_rejectMissingApiVersion_beforeCatchAll_when_securityRoutesExist() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController())
void should_rejectMissingApiVersion_onDedicatedSecurityRoutes() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new TestExceptionAdvice())
.build();
@ -78,8 +77,8 @@ class AppAccountSecurityControllerTest extends BaseMockitoUnitTest {
}
@Test
void should_rejectMissingApiVersion_beforeCatchAll_when_securityDetailAndAckRoutesExist() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller, new AppMuseAccountContractController())
void should_rejectMissingApiVersion_onDedicatedSecurityDetailAndAckRoutes() throws Exception {
MockMvc routeMockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new TestExceptionAdvice())
.build();

View File

@ -0,0 +1,259 @@
package cn.iocoder.muse.server.framework.api;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static java.util.Map.entry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* P1R-3 Account 路由归属静态门禁
*
* <p>本测试只证明 33 Account 合同路由已经由专用 Controller 持有且源码不再把这些路由交给
* {@code MuseContractPersistenceService} 兜底它不证明 Account 业务语义已经 completed</p>
*/
class P1rAccountRouteOwnershipTest {
/** 旧管理端合同兜底 ControllerTask 11 优先删除。 */
private static final String ADMIN_CONTRACT_CONTROLLER = "muse-cloud/muse-module-member/muse-module-member-server/"
+ "src/main/java/cn/iocoder/muse/module/member/controller/admin/AdminMuseAccountContractController.java";
/** 旧用户端合同兜底 ControllerTask 11 优先删除。 */
private static final String APP_CONTRACT_CONTROLLER = "muse-cloud/muse-module-member/muse-module-member-server/"
+ "src/main/java/cn/iocoder/muse/module/member/controller/app/AppMuseAccountContractController.java";
/** Account Controller 源码根目录,用于扫描专用 Controller 的 Spring mapping。 */
private static final String ACCOUNT_CONTROLLER_ROOT = "muse-cloud/muse-module-member/muse-module-member-server/"
+ "src/main/java/cn/iocoder/muse/module/member/controller";
/** P1R-3 固定的 33 个 Account method + path来自阶段计划 Task 11。 */
private static final Map<String, String> P1R3_ACCOUNT_ROUTES = Map.ofEntries(
entry("GET /admin-api/muse/account/users", "adminListAccountUsers"),
entry("GET /admin-api/muse/account/users/{userId}/entitlements", "adminGetUserEntitlements"),
entry("POST /admin-api/muse/account/users/{userId}/quota-adjustments", "adminCreateQuotaAdjustment"),
entry("GET /admin-api/muse/account/users/{userId}/quota-adjustments", "adminListQuotaAdjustments"),
entry("GET /admin-api/muse/account/new-api-bindings", "adminListNewApiBindings"),
entry("POST /admin-api/muse/account/users/{userId}/new-api-binding", "adminCreateNewApiBinding"),
entry("POST /admin-api/muse/account/users/{userId}/quota-requests", "adminCreateQuotaRequest"),
entry("GET /admin-api/muse/account/users/{userId}/balance-snapshots", "adminGetBalanceSnapshots"),
entry("POST /admin-api/muse/account/call-attribution-jobs", "adminCreateCallAttributionJob"),
entry("GET /admin-api/muse/account/call-attribution-jobs/{jobId}", "adminGetCallAttributionJob"),
entry("GET /admin-api/muse/account/integration-calls/by-correlation/{correlationId}",
"adminGetIntegrationCallByCorrelation"),
entry("GET /admin-api/muse/account/usage-records", "adminListUsageRecords"),
entry("GET /admin-api/muse/account/purchase-records", "adminListPurchaseRecords"),
entry("GET /app-api/muse/me", "appGetCurrentUser"),
entry("GET /app-api/muse/profile", "appGetProfile"),
entry("PATCH /app-api/muse/profile", "appUpdateProfile"),
entry("GET /app-api/muse/account/entitlements", "appGetEntitlements"),
entry("GET /app-api/muse/account/usage", "appGetUsage"),
entry("GET /app-api/muse/account/new-api-binding", "appGetNewApiBinding"),
entry("POST /app-api/muse/account/new-api-binding/recheck", "appRecheckNewApiBinding"),
entry("GET /app-api/muse/account/balance-snapshots", "appGetBalanceSnapshots"),
entry("POST /app-api/muse/account/quota-requests", "appCreateQuotaRequest"),
entry("GET /app-api/muse/account/quota-requests/{requestId}", "appGetQuotaRequest"),
entry("GET /app-api/muse/account/integration-calls/by-correlation/{correlationId}",
"appGetIntegrationCallByCorrelation"),
entry("GET /app-api/muse/account/purchases", "appListPurchases"),
entry("GET /app-api/muse/account/licenses", "appListLicenses"),
entry("GET /app-api/muse/account/publish-records", "appListPublishRecords"),
entry("GET /app-api/muse/account/security-events", "appListSecurityEvents"),
entry("GET /app-api/muse/account/security-events/{eventId}", "appGetSecurityEvent"),
entry("POST /app-api/muse/account/security-events/{eventId}/acknowledge", "appAcknowledgeSecurityEvent"),
entry("POST /app-api/muse/account/export-tasks", "appCreateExportTask"),
entry("GET /app-api/muse/account/export-tasks/{taskId}", "appGetExportTask"),
entry("GET /app-api/muse/account/downloads/{credentialId}", "appDownloadExport")
);
/** 用源码扫描覆盖常见 Spring mapping 注解,避免启动完整 Spring 上下文。 */
private static final Pattern METHOD_MAPPING_PATTERN = Pattern.compile(
"@(GetMapping|PostMapping|PatchMapping|PutMapping|DeleteMapping|RequestMapping)\\s*\\((.*?)\\)",
Pattern.DOTALL);
/** 解析 RequestMapping(method = RequestMethod.GET) 形式时使用。 */
private static final Pattern REQUEST_METHOD_PATTERN = Pattern.compile("RequestMethod\\.([A-Z]+)");
@Test
void should_retire_old_account_contract_fallback_controllers() throws IOException {
assertRetiredOrNarrowed(findRepositoryRoot().resolve(ADMIN_CONTRACT_CONTROLLER));
assertRetiredOrNarrowed(findRepositoryRoot().resolve(APP_CONTRACT_CONTROLLER));
}
@Test
void should_keep_all_p1r3_account_routes_owned_by_dedicated_controllers() throws IOException {
Map<String, Path> actualRoutes = collectAccountRoutes();
assertEquals(33, P1R3_ACCOUNT_ROUTES.size(), "P1R-3 Account 路由清单必须保持 33 个");
for (String route : P1R3_ACCOUNT_ROUTES.keySet()) {
assertTrue(actualRoutes.containsKey(route),
P1R3_ACCOUNT_ROUTES.get(route) + " 必须由 Account 专用 Controller 持有: " + route);
}
}
@Test
void should_not_allow_p1r3_account_routes_to_use_contract_persistence() throws IOException {
Map<String, Path> actualRoutes = collectAccountRoutes();
for (String route : P1R3_ACCOUNT_ROUTES.keySet()) {
Path owner = actualRoutes.get(route);
assertTrue(owner != null, "P1R-3 Account 路由缺少专用 Controller: " + route);
assertNoContractPersistence(owner, route);
}
}
@Test
void should_not_keep_account_wildcard_or_app_profile_contract_fallback() throws IOException {
Path controllerRoot = findRepositoryRoot().resolve(ACCOUNT_CONTROLLER_ROOT);
try (Stream<Path> stream = Files.walk(controllerRoot)) {
for (Path sourceFile : stream
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().contains("Account"))
.filter(path -> path.getFileName().toString().endsWith("Controller.java"))
.toList()) {
assertNoRetiredFallbackTokens(sourceFile, Files.readString(sourceFile));
}
}
}
private static void assertRetiredOrNarrowed(Path controllerPath) throws IOException {
if (!Files.exists(controllerPath)) {
return;
}
String source = Files.readString(controllerPath);
assertNoContractPersistence(controllerPath, "旧 Account 合同兜底 Controller");
assertNoRetiredFallbackTokens(controllerPath, source);
}
private static Map<String, Path> collectAccountRoutes() throws IOException {
Map<String, Path> routes = new TreeMap<>();
Path controllerRoot = findRepositoryRoot().resolve(ACCOUNT_CONTROLLER_ROOT);
try (Stream<Path> stream = Files.walk(controllerRoot)) {
for (Path sourceFile : stream
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().contains("Account"))
.filter(path -> path.getFileName().toString().endsWith("Controller.java"))
.toList()) {
collectAccountRoutesFromFile(sourceFile, routes);
}
}
return routes;
}
private static void collectAccountRoutesFromFile(Path sourceFile, Map<String, Path> routes) throws IOException {
String source = Files.readString(sourceFile);
String sidePrefix = sourceFile.toString().contains("/controller/admin/") ? "/admin-api" : "/app-api";
String classBasePath = extractClassBasePath(source);
Matcher matcher = METHOD_MAPPING_PATTERN.matcher(source);
while (matcher.find()) {
String annotation = matcher.group(1);
String mappingBody = matcher.group(2);
for (String method : extractHttpMethods(annotation, mappingBody)) {
for (String methodPath : extractMappingPaths(mappingBody)) {
String fullPath = normalizePath(sidePrefix, classBasePath, methodPath);
if (isP1r3AccountPath(fullPath)) {
routes.put(method + " " + fullPath, sourceFile);
}
}
}
}
}
private static String extractClassBasePath(String source) {
int classIndex = source.indexOf("public class ");
String classAnnotations = classIndex >= 0 ? source.substring(0, classIndex) : source;
Matcher matcher = Pattern.compile("@RequestMapping\\s*\\((.*?)\\)", Pattern.DOTALL).matcher(classAnnotations);
if (!matcher.find()) {
return "";
}
return extractMappingPaths(matcher.group(1)).stream().findFirst().orElse("");
}
private static Set<String> extractHttpMethods(String annotation, String mappingBody) {
return switch (annotation) {
case "GetMapping" -> Set.of("GET");
case "PostMapping" -> Set.of("POST");
case "PatchMapping" -> Set.of("PATCH");
case "PutMapping" -> Set.of("PUT");
case "DeleteMapping" -> Set.of("DELETE");
default -> {
Matcher matcher = REQUEST_METHOD_PATTERN.matcher(mappingBody);
Map<String, Boolean> methods = new LinkedHashMap<>();
while (matcher.find()) {
methods.put(matcher.group(1), true);
}
yield methods.isEmpty() ? Set.of() : methods.keySet();
}
};
}
private static Set<String> extractMappingPaths(String mappingBody) {
Matcher matcher = Pattern.compile("\"([^\"]+)\"").matcher(mappingBody);
Map<String, Boolean> paths = new LinkedHashMap<>();
while (matcher.find()) {
String value = matcher.group(1);
if (value.startsWith("/")) {
paths.put(value, true);
}
}
return paths.keySet();
}
private static String normalizePath(String sidePrefix, String classBasePath, String methodPath) {
return Stream.of(sidePrefix, classBasePath, methodPath)
.filter(Objects::nonNull)
.filter(part -> !part.isBlank())
.map(part -> part.startsWith("/") ? part.substring(1) : part)
.reduce("", (left, right) -> left + "/" + right)
.replaceAll("/{2,}", "/");
}
private static boolean isP1r3AccountPath(String fullPath) {
return fullPath.startsWith("/admin-api/muse/account/")
|| fullPath.equals("/app-api/muse/me")
|| fullPath.equals("/app-api/muse/profile")
|| fullPath.startsWith("/app-api/muse/account/");
}
private static void assertNoContractPersistence(Path sourceFile, String context) throws IOException {
String source = Files.readString(sourceFile);
assertFalse(source.contains("MuseContractPersistenceService"),
context + " 不能依赖 MuseContractPersistenceService: " + sourceFile);
assertFalse(source.contains("contractPersistenceService.handle"),
context + " 不能调用 contractPersistenceService.handle: " + sourceFile);
}
private static void assertNoRetiredFallbackTokens(Path sourceFile, String source) {
assertFalse(source.contains("\"/account/**\""),
"Account Controller 不能保留 /account/** 兜底: " + sourceFile);
assertFalse(source.contains("\"/me\", \"/profile\", \"/account/**\""),
"App Account Controller 不能保留 /me、/profile、/account/** 组合兜底: " + sourceFile);
}
/**
* 从当前 Maven 执行目录逐级向上查找仓库根目录避免 surefire 在不同模块目录执行时路径失效
*/
private static Path findRepositoryRoot() {
Path current = Path.of("").toAbsolutePath();
for (Path candidate = current; candidate != null; candidate = candidate.getParent()) {
if (Files.exists(candidate.resolve("muse-cloud/muse-server/pom.xml"))) {
return candidate;
}
}
return current;
}
}