From c4a8f4ea5d10fa92a83aca1c3a3d39826be96420 Mon Sep 17 00:00:00 2001 From: zizi Date: Sat, 30 May 2026 01:50:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(p1r):=20=E9=80=80=E5=BD=B9=20Account=20?= =?UTF-8?q?=E5=90=88=E5=90=8C=E5=85=9C=E5=BA=95=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminMuseAccountContractController.java | 52 ---- .../app/AppMuseAccountContractController.java | 52 ---- .../AdminAccountUserControllerTest.java | 52 ++-- .../AppAccountEntitlementControllerTest.java | 5 +- .../AppAccountExportControllerTest.java | 5 +- .../AppAccountMarketRecordControllerTest.java | 5 +- .../AppAccountNewApiControllerTest.java | 5 +- .../AppAccountProfileControllerTest.java | 9 +- .../AppAccountSecurityControllerTest.java | 9 +- .../api/P1rAccountRouteOwnershipTest.java | 259 ++++++++++++++++++ 10 files changed, 310 insertions(+), 143 deletions(-) delete mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/admin/AdminMuseAccountContractController.java delete mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/AppMuseAccountContractController.java create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRouteOwnershipTest.java diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/admin/AdminMuseAccountContractController.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/admin/AdminMuseAccountContractController.java deleted file mode 100644 index 7dbad445..00000000 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/admin/AdminMuseAccountContractController.java +++ /dev/null @@ -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 合同入口。 - * - *

该入口只负责 P1 合同路由兜底、写命令基础校验和统一响应封装;领域事实由后续 Application 服务逐步替换。

- */ -@Tag(name = "管理后台 - Muse Account 合同入口") -@RestController -@RequestMapping("/muse") -@Validated -public class AdminMuseAccountContractController { - - /** 当前 Controller 拥有的 OpenAPI 合同域。 */ - private static final Set 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> handle(HttpServletRequest request, - @RequestParam Map queryParams, - @RequestBody(required = false) Map body) { - return success(contractPersistenceService.handle(DOMAINS, "admin", request.getMethod(), request.getRequestURI(), - request.getHeader("X-Command-Id"), queryParams, body, getLoginUserId())); - } - -} diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/AppMuseAccountContractController.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/AppMuseAccountContractController.java deleted file mode 100644 index 24a5f091..00000000 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/AppMuseAccountContractController.java +++ /dev/null @@ -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 合同入口。 - * - *

该入口只负责 P1 合同路由兜底、写命令基础校验和统一响应封装;领域事实由后续 Application 服务逐步替换。

- */ -@Tag(name = "用户 APP - Muse Account 合同入口") -@RestController -@RequestMapping("/muse") -@Validated -public class AppMuseAccountContractController { - - /** 当前 Controller 拥有的 OpenAPI 合同域。 */ - private static final Set 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> handle(HttpServletRequest request, - @RequestParam Map queryParams, - @RequestBody(required = false) Map body) { - return success(contractPersistenceService.handle(DOMAINS, "app", request.getMethod(), request.getRequestURI(), - request.getHeader("X-Command-Id"), queryParams, body, getLoginUserId())); - } - -} diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/admin/account/AdminAccountUserControllerTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/admin/account/AdminAccountUserControllerTest.java index 5287fc27..eed0928a 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/admin/account/AdminAccountUserControllerTest.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/admin/account/AdminAccountUserControllerTest.java @@ -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(); diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountEntitlementControllerTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountEntitlementControllerTest.java index 76285120..d2332df8 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountEntitlementControllerTest.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountEntitlementControllerTest.java @@ -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(); diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportControllerTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportControllerTest.java index 81e999d0..aaef02fb 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportControllerTest.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountExportControllerTest.java @@ -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(); diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountMarketRecordControllerTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountMarketRecordControllerTest.java index 89ae5574..a25a01ac 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountMarketRecordControllerTest.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountMarketRecordControllerTest.java @@ -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(); diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountNewApiControllerTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountNewApiControllerTest.java index 3dfd3c8d..33f53fc4 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountNewApiControllerTest.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountNewApiControllerTest.java @@ -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(); diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountProfileControllerTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountProfileControllerTest.java index 2a9111f3..56fa9adc 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountProfileControllerTest.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountProfileControllerTest.java @@ -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(); diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountSecurityControllerTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountSecurityControllerTest.java index 579b4b99..dfe50ec1 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountSecurityControllerTest.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountSecurityControllerTest.java @@ -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(); diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRouteOwnershipTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRouteOwnershipTest.java new file mode 100644 index 00000000..952f900e --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRouteOwnershipTest.java @@ -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 路由归属静态门禁。 + * + *

本测试只证明 33 个 Account 合同路由已经由专用 Controller 持有,且源码不再把这些路由交给 + * {@code MuseContractPersistenceService} 兜底;它不证明 Account 业务语义已经 completed。

+ */ +class P1rAccountRouteOwnershipTest { + + /** 旧管理端合同兜底 Controller,Task 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"; + + /** 旧用户端合同兜底 Controller,Task 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 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 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 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 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 collectAccountRoutes() throws IOException { + Map routes = new TreeMap<>(); + Path controllerRoot = findRepositoryRoot().resolve(ACCOUNT_CONTROLLER_ROOT); + try (Stream 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 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 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 methods = new LinkedHashMap<>(); + while (matcher.find()) { + methods.put(matcher.group(1), true); + } + yield methods.isEmpty() ? Set.of() : methods.keySet(); + } + }; + } + + private static Set extractMappingPaths(String mappingBody) { + Matcher matcher = Pattern.compile("\"([^\"]+)\"").matcher(mappingBody); + Map 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; + } + +}