From 749c21cb1c0797e2ff2cb24c33c157ce7ead7695 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 10 Apr 2026 20:39:25 +0000
Subject: [PATCH 1/3] test: improve test coverage and fix pre-existing test
failures
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New test suites:
- JdbcUserAccountRepositoryTests: 13 tests covering all CRUD operations,
null handling, and error paths with H2 in-memory database
- AuthServiceTests: 13 unit tests for login/loginByGithub/logout/currentUser
using Mockito with mockStatic for Sa-Token isolation
- OAuthControllerIntegrationTests: 4 tests covering GitHub OAuth redirect
URL validation and callback failure paths
Infrastructure fixes:
- AbstractWebIntegrationTest: add @SpringBootTest(properties) override to
prevent SPRING_DATASOURCE_URL env var from overriding H2 test config
- BackendApplicationTests: same H2 override fix for context load test
- test-schema.sql: add missing avatar_url, email, github_id columns that
JdbcUserAccountRepository.ROW_MAPPER reads
Production bug fixes revealed by tests:
- SaTokenPermissionImpl: implement StpInterface so @SaCheckPermission
annotations can resolve permissions from the database (was always 403)
- GlobalExceptionHandler: use e.getPermission() instead of e.getCode()
in handleNotPermissionException (getCode() returned -1, not the permission)
Pre-existing test fixes:
- AuthControllerIntegrationTests: update expected Sa-Token error messages
from old generic message to current specific messages ("未提供 Token")
- UserCenterControllerIntegrationTests: fix stale URL paths (/users instead
of /api/user-center/users) and update expected permission error messages
- OpenAiStreamControllerIntegrationTests: update for renamed DTO field
(message -> messages), fix asyncDispatch pattern for StreamingResponseBody,
and align stub gateway with OpenAI API format expected by relayEvents()
Result: 78/78 tests pass (was 40/78 with 36 errors + 2 failures baseline)
https://claude.ai/code/session_016Z9qEQdrSXSTAhCp1YMgnk
---
.../common/config/SaTokenPermissionImpl.java | 35 ++
.../common/error/GlobalExceptionHandler.java | 2 +-
.../backend/BackendApplicationTests.java | 14 +-
...penAiStreamControllerIntegrationTests.java | 52 ++-
.../support/AbstractWebIntegrationTest.java | 16 +-
.../AuthControllerIntegrationTests.java | 4 +-
.../OAuthControllerIntegrationTests.java | 95 ++++++
.../UserCenterControllerIntegrationTests.java | 24 +-
.../JdbcUserAccountRepositoryTests.java | 208 ++++++++++++
.../usercenter/service/AuthServiceTests.java | 299 ++++++++++++++++++
src/test/resources/test-schema.sql | 5 +-
11 files changed, 704 insertions(+), 50 deletions(-)
create mode 100644 src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java
create mode 100644 src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java
create mode 100644 src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java
create mode 100644 src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java
diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java
new file mode 100644
index 0000000..e42409e
--- /dev/null
+++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java
@@ -0,0 +1,35 @@
+package com.involutionhell.backend.common.config;
+
+import cn.dev33.satoken.stp.StpInterface;
+import com.involutionhell.backend.usercenter.repository.UserAccountRepository;
+import java.util.List;
+import org.springframework.stereotype.Component;
+
+/**
+ * Sa-Token 权限与角色加载实现。
+ * 根据登录用户 ID 从数据库加载其权限集合与角色集合,
+ * 供 {@code @SaCheckPermission} / {@code @SaCheckRole} 等注解使用。
+ */
+@Component
+public class SaTokenPermissionImpl implements StpInterface {
+
+ private final UserAccountRepository userAccountRepository;
+
+ public SaTokenPermissionImpl(UserAccountRepository userAccountRepository) {
+ this.userAccountRepository = userAccountRepository;
+ }
+
+ @Override
+ public List getPermissionList(Object loginId, String loginType) {
+ return userAccountRepository.findById(Long.valueOf(loginId.toString()))
+ .map(account -> List.copyOf(account.permissions()))
+ .orElse(List.of());
+ }
+
+ @Override
+ public List getRoleList(Object loginId, String loginType) {
+ return userAccountRepository.findById(Long.valueOf(loginId.toString()))
+ .map(account -> List.copyOf(account.roles()))
+ .orElse(List.of());
+ }
+}
diff --git a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java
index bb55665..799f306 100644
--- a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java
+++ b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java
@@ -46,7 +46,7 @@ public ResponseEntity> handleNotLoginException(NotLoginExcepti
@ExceptionHandler(NotPermissionException.class)
public ResponseEntity> handleNotPermissionException(NotPermissionException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
- .body(ApiResponse.fail("拒绝访问: 缺少权限 [" + e.getCode() + "]"));
+ .body(ApiResponse.fail("拒绝访问: 缺少权限 [" + e.getPermission() + "]"));
}
/**
diff --git a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java
index 58d6d4f..3595995 100644
--- a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java
+++ b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java
@@ -4,7 +4,19 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
-@SpringBootTest
+// 显式覆盖数据源配置,防止 SPRING_DATASOURCE_URL 环境变量(指向生产 PostgreSQL)
+// 优先于 application-test.properties,导致上下文加载失败。
+@SpringBootTest(properties = {
+ "spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.sql.init.mode=always",
+ "spring.sql.init.schema-locations=classpath:test-schema.sql",
+ "justauth.type.github.redirect-uri=https://example.com/api/auth/callback/github",
+ "justauth.type.github.client-id=test-client-id",
+ "justauth.type.github.client-secret=test-client-secret"
+})
@ActiveProfiles("test")
class BackendApplicationTests {
diff --git a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java
index 9e5cb2b..d142597 100644
--- a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java
+++ b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java
@@ -1,24 +1,24 @@
package com.involutionhell.backend.openai.controller;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.involutionhell.backend.openai.dto.OpenAiStreamRequest;
import com.involutionhell.backend.openai.service.OpenAiStreamGateway;
-import com.involutionhell.backend.openai.service.OpenAiStreamService;
import com.involutionhell.backend.support.AbstractWebIntegrationTest;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
-import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Primary;
import org.springframework.http.MediaType;
-import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.test.web.servlet.MvcResult;
@Import(OpenAiStreamControllerIntegrationTests.OpenAiTestConfiguration.class)
@@ -27,23 +27,23 @@ class OpenAiStreamControllerIntegrationTests extends AbstractWebIntegrationTest
@Test
void streamReturnsSseEventsForAuthenticatedUser() throws Exception {
String token = loginAsAdmin();
+ // StreamingResponseBody 采用异步派发:先获取 MvcResult,再通过 asyncDispatch 触发真实写入
MvcResult mvcResult = mockMvc.perform(post("/openai/responses/stream")
.header("satoken", token)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
- "message": "你好"
+ "messages": [{"role": "user", "content": "你好"}]
}
"""))
.andExpect(request().asyncStarted())
.andReturn();
- waitForSseBody(mvcResult);
-
- Assertions.assertThat(mvcResult.getResponse().getStatus()).isEqualTo(200);
- Assertions.assertThat(mvcResult.getResponse().getContentType()).startsWith(MediaType.TEXT_EVENT_STREAM_VALUE);
- Assertions.assertThat(mvcResult.getResponse().getContentAsString()).contains("response.output_text.delta");
- Assertions.assertThat(mvcResult.getResponse().getContentAsString()).contains("response.completed");
+ // relayEvents() 将 OpenAI choices[0].delta.content 转换为 Vercel Stream 格式 "0:\"hello\"\n"
+ mockMvc.perform(asyncDispatch(mvcResult))
+ .andExpect(status().isOk())
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("0:")))
+ .andExpect(content().string(org.hamcrest.Matchers.containsString("hello")));
}
@Test
@@ -52,16 +52,16 @@ void streamRejectsAnonymousRequest() throws Exception {
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
- "message": "你好"
+ "messages": [{"role": "user", "content": "你好"}]
}
"""))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false))
- .andExpect(jsonPath("$.message").value("未登录或登录状态已失效"));
+ .andExpect(jsonPath("$.message").value("未提供 Token"));
}
@Test
- void streamValidatesBlankMessage() throws Exception {
+ void streamValidatesEmptyMessages() throws Exception {
String token = loginAsAdmin();
mockMvc.perform(post("/openai/responses/stream")
@@ -69,25 +69,12 @@ void streamValidatesBlankMessage() throws Exception {
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
- "message": ""
+ "messages": []
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
- .andExpect(jsonPath("$.message").value("message: 消息不能为空"));
- }
-
- /**
- * 等待模拟的 SSE 推送线程把事件内容写入响应体。
- */
- private void waitForSseBody(MvcResult mvcResult) throws Exception {
- for (int attempt = 0; attempt < 20; attempt++) {
- if (!mvcResult.getResponse().getContentAsString().isBlank()) {
- return;
- }
- Thread.sleep(25L);
- }
- throw new IllegalStateException("SSE 响应内容未按预期写入");
+ .andExpect(jsonPath("$.message").value("messages: 对话历史不能为空"));
}
@TestConfiguration
@@ -113,14 +100,15 @@ public void validateConfiguration(OpenAiStreamRequest request) {
}
/**
- * 返回固定的 SSE 事件流,供控制器测试验证输出格式。
+ * 返回固定的 OpenAI SSE 格式事件流(与真实 API 格式保持一致)。
+ * relayEvents() 提取 choices[0].delta.content 写入 Vercel Stream 格式。
*/
@Override
public InputStream openStream(OpenAiStreamRequest request) {
return new ByteArrayInputStream("""
- data: {"type":"response.output_text.delta","delta":"hello"}
+ data: {"choices":[{"delta":{"content":"hello"}}]}
- data: {"type":"response.completed"}
+ data: [DONE]
""".getBytes(StandardCharsets.UTF_8));
}
diff --git a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java
index bc9c6ae..9bafcc6 100644
--- a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java
+++ b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java
@@ -13,7 +13,21 @@
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
-@SpringBootTest
+// 显式指定 H2 内存库配置,优先级高于环境变量(如 SPRING_DATASOURCE_URL),
+// 确保集成测试始终使用 H2 而非生产 PostgreSQL 连接。
+@SpringBootTest(properties = {
+ "spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.sql.init.mode=always",
+ "spring.sql.init.schema-locations=classpath:test-schema.sql",
+ // JustAuth 使用 Apache Commons UrlValidator 校验 redirect-uri,默认拒绝 localhost。
+ // 测试环境使用合法格式的占位 URL,不影响实际网络请求。
+ "justauth.type.github.redirect-uri=https://example.com/api/auth/callback/github",
+ "justauth.type.github.client-id=test-client-id",
+ "justauth.type.github.client-secret=test-client-secret"
+})
@AutoConfigureMockMvc
@ActiveProfiles("test")
public abstract class AbstractWebIntegrationTest {
diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java
index e5232ef..621903f 100644
--- a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java
+++ b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java
@@ -75,7 +75,7 @@ void meRejectsAnonymousRequest() throws Exception {
mockMvc.perform(get("/auth/me"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false))
- .andExpect(jsonPath("$.message").value("未登录或登录状态已失效"));
+ .andExpect(jsonPath("$.message").value("未提供 Token"));
}
@Test
@@ -97,6 +97,6 @@ void logoutRejectsAnonymousRequest() throws Exception {
mockMvc.perform(post("/auth/logout"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false))
- .andExpect(jsonPath("$.message").value("未登录或登录状态已失效"));
+ .andExpect(jsonPath("$.message").value("未提供 Token"));
}
}
diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java
new file mode 100644
index 0000000..53baa4f
--- /dev/null
+++ b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java
@@ -0,0 +1,95 @@
+package com.involutionhell.backend.usercenter.controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.involutionhell.backend.support.AbstractWebIntegrationTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MvcResult;
+
+/**
+ * OAuthController 集成测试。
+ *
+ * 设计说明:
+ * OAuthController 在内部通过 @Value 属性直接 new AuthGithubRequest(),
+ * 无法通过依赖注入替换 JustAuth 的 AuthRequest 实现。
+ * 因此本测试仅覆盖以下可验证的行为:
+ * 1. renderAuth —— JustAuth 在本地构建授权 URL,无实际 HTTP 调用,可直接验证 302 重定向。
+ * 2. callback 失败路径 —— 携带无效 state/code 时,JustAuth 返回失败响应,
+ * 控制器重定向至前端错误页。
+ *
+ * callback 成功路径(需要真实 GitHub code + state)超出集成测试范围,
+ * 该路径的业务逻辑已由 AuthServiceTests.loginByGithub*() 系列单元测试覆盖。
+ */
+class OAuthControllerIntegrationTests extends AbstractWebIntegrationTest {
+
+ // =============================================
+ // GET /oauth/render/github — 发起授权跳转
+ // =============================================
+
+ @Test
+ void renderAuthRedirectsToGitHubAuthorizationUrl() throws Exception {
+ MvcResult result = mockMvc.perform(get("/oauth/render/github"))
+ .andExpect(status().is3xxRedirection())
+ .andReturn();
+
+ String location = result.getResponse().getRedirectedUrl();
+ assertThat(location)
+ .as("授权重定向地址应指向 GitHub OAuth 授权端点")
+ .isNotNull()
+ .contains("github.com/login/oauth/authorize")
+ // 应携带测试环境配置的 dummy client_id
+ .contains("client_id=");
+ }
+
+ @Test
+ void renderAuthIncludesRedirectUriInAuthorizationUrl() throws Exception {
+ MvcResult result = mockMvc.perform(get("/oauth/render/github"))
+ .andExpect(status().is3xxRedirection())
+ .andReturn();
+
+ String location = result.getResponse().getRedirectedUrl();
+ // 授权 URL 必须携带 redirect_uri,否则 GitHub 会拒绝
+ assertThat(location)
+ .as("授权 URL 必须携带 redirect_uri 参数")
+ .contains("redirect_uri");
+ }
+
+ // =============================================
+ // GET /api/auth/callback/github — OAuth 回调
+ // =============================================
+
+ @Test
+ void callbackRedirectsToFrontendErrorPageWhenOAuthFails() throws Exception {
+ // 不携带合法的 code 和 state,JustAuth 会返回失败响应
+ // 控制器应将其重定向至前端错误页(/login?error=oauth_failed)
+ MvcResult result = mockMvc.perform(
+ get("/api/auth/callback/github")
+ .param("code", "invalid-code")
+ .param("state", "invalid-state"))
+ .andExpect(status().is3xxRedirection())
+ .andReturn();
+
+ String location = result.getResponse().getRedirectedUrl();
+ assertThat(location)
+ .as("OAuth 失败时应重定向至前端错误页")
+ .isNotNull()
+ .endsWith("/login?error=oauth_failed");
+ }
+
+ @Test
+ void callbackWithoutParametersRedirectsToFrontendErrorPage() throws Exception {
+ // 完全不携带任何参数,模拟用户直接访问回调地址
+ MvcResult result = mockMvc.perform(get("/api/auth/callback/github"))
+ .andExpect(status().is3xxRedirection())
+ .andReturn();
+
+ String location = result.getResponse().getRedirectedUrl();
+ assertThat(location)
+ .as("无参数请求时应重定向至前端错误页")
+ .isNotNull()
+ .endsWith("/login?error=oauth_failed");
+ }
+}
diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java
index 718b851..bcde63e 100644
--- a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java
+++ b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java
@@ -16,7 +16,7 @@ class UserCenterControllerIntegrationTests extends AbstractWebIntegrationTest {
void profileReturnsCurrentUserForAuthorizedUser() throws Exception {
String token = loginAsAlice();
- mockMvc.perform(get("/api/user-center/profile").header("satoken", token))
+ mockMvc.perform(get("/auth/me").header("satoken", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.username").value("alice"));
@@ -26,7 +26,7 @@ void profileReturnsCurrentUserForAuthorizedUser() throws Exception {
void usersListReturnsAllUsersForAdmin() throws Exception {
String token = loginAsAdmin();
- mockMvc.perform(get("/api/user-center/users").header("satoken", token))
+ mockMvc.perform(get("/users").header("satoken", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.length()").value(3));
@@ -36,17 +36,17 @@ void usersListReturnsAllUsersForAdmin() throws Exception {
void usersListRejectsUserWithoutReadPermission() throws Exception {
String token = loginAsAlice();
- mockMvc.perform(get("/api/user-center/users").header("satoken", token))
+ mockMvc.perform(get("/users").header("satoken", token))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.success").value(false))
- .andExpect(jsonPath("$.message").value("无权限访问: user:center:read"));
+ .andExpect(jsonPath("$.message").value("拒绝访问: 缺少权限 [user:center:read]"));
}
@Test
void getUserReturnsRequestedUserForAuditor() throws Exception {
String token = loginAsAuditor();
- mockMvc.perform(get("/api/user-center/users/2").header("satoken", token))
+ mockMvc.perform(get("/users/2").header("satoken", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.username").value("alice"));
@@ -54,7 +54,7 @@ void getUserReturnsRequestedUserForAuditor() throws Exception {
@Test
void getUserRejectsAnonymousRequest() throws Exception {
- mockMvc.perform(get("/api/user-center/users/1"))
+ mockMvc.perform(get("/users/1"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false));
}
@@ -63,7 +63,7 @@ void getUserRejectsAnonymousRequest() throws Exception {
void getUserReturnsBusinessErrorWhenUserMissing() throws Exception {
String token = loginAsAdmin();
- mockMvc.perform(get("/api/user-center/users/999").header("satoken", token))
+ mockMvc.perform(get("/users/999").header("satoken", token))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("用户不存在: 999"));
@@ -74,7 +74,7 @@ void getUserReturnsBusinessErrorWhenUserMissing() throws Exception {
void updateAuthorizationAllowsAdmin() throws Exception {
String token = loginAsAdmin();
- mockMvc.perform(put("/api/user-center/users/2/authorization")
+ mockMvc.perform(put("/users/2/authorization")
.header("satoken", token)
.contentType(MediaType.APPLICATION_JSON)
.content("""
@@ -89,7 +89,7 @@ void updateAuthorizationAllowsAdmin() throws Exception {
.andExpect(jsonPath("$.data.roles.length()").value(2))
.andExpect(jsonPath("$.data.permissions.length()").value(2));
- mockMvc.perform(get("/api/user-center/users/2").header("satoken", token))
+ mockMvc.perform(get("/users/2").header("satoken", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.roles.length()").value(2))
.andExpect(jsonPath("$.data.permissions.length()").value(2));
@@ -97,7 +97,7 @@ void updateAuthorizationAllowsAdmin() throws Exception {
@Test
void updateAuthorizationRejectsAnonymousRequest() throws Exception {
- mockMvc.perform(put("/api/user-center/users/2/authorization")
+ mockMvc.perform(put("/users/2/authorization")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
@@ -113,7 +113,7 @@ void updateAuthorizationRejectsAnonymousRequest() throws Exception {
void updateAuthorizationRejectsUserWithoutManagePermission() throws Exception {
String token = loginAsAlice();
- mockMvc.perform(put("/api/user-center/users/2/authorization")
+ mockMvc.perform(put("/users/2/authorization")
.header("satoken", token)
.contentType(MediaType.APPLICATION_JSON)
.content("""
@@ -124,6 +124,6 @@ void updateAuthorizationRejectsUserWithoutManagePermission() throws Exception {
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.success").value(false))
- .andExpect(jsonPath("$.message").value("无权限访问: user:center:manage"));
+ .andExpect(jsonPath("$.message").value("拒绝访问: 缺少权限 [user:center:manage]"));
}
}
diff --git a/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java b/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java
new file mode 100644
index 0000000..3961acb
--- /dev/null
+++ b/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java
@@ -0,0 +1,208 @@
+package com.involutionhell.backend.usercenter.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.involutionhell.backend.usercenter.model.UserAccount;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * JdbcUserAccountRepository 集成测试。
+ * 使用 H2 内存库(PostgreSQL MODE),种子数据:admin(id=1)、alice(id=2)、auditor(id=3)。
+ * 每个 @Test 都在事务中执行并自动回滚,保证测试间互不干扰。
+ */
+@SpringBootTest(properties = {
+ "spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.sql.init.mode=always",
+ "spring.sql.init.schema-locations=classpath:test-schema.sql"
+})
+@ActiveProfiles("test")
+@Transactional
+class JdbcUserAccountRepositoryTests {
+
+ @Autowired
+ private JdbcUserAccountRepository repository;
+
+ // =============================================
+ // findById
+ // =============================================
+
+ @Test
+ void findByIdReturnsUserWhenExists() {
+ Optional result = repository.findById(1L);
+
+ assertThat(result).isPresent();
+ assertThat(result.get().username()).isEqualTo("admin");
+ assertThat(result.get().enabled()).isTrue();
+ assertThat(result.get().roles()).contains("admin");
+ }
+
+ @Test
+ void findByIdReturnsEmptyWhenUserMissing() {
+ Optional result = repository.findById(999L);
+
+ assertThat(result).isEmpty();
+ }
+
+ // =============================================
+ // findByUsername
+ // =============================================
+
+ @Test
+ void findByUsernameReturnsUserWhenExists() {
+ Optional result = repository.findByUsername("alice");
+
+ assertThat(result).isPresent();
+ assertThat(result.get().id()).isEqualTo(2L);
+ assertThat(result.get().displayName()).isEqualTo("Alice");
+ // alice 没有 github_id,应为 null
+ assertThat(result.get().githubId()).isNull();
+ }
+
+ @Test
+ void findByUsernameReturnsEmptyWhenUserMissing() {
+ Optional result = repository.findByUsername("nobody");
+
+ assertThat(result).isEmpty();
+ }
+
+ // =============================================
+ // findAll
+ // =============================================
+
+ @Test
+ void findAllReturnsSeedUsersOrderedById() {
+ List all = repository.findAll();
+
+ // 种子数据:admin、alice、auditor
+ assertThat(all).hasSize(3);
+ assertThat(all.get(0).username()).isEqualTo("admin");
+ assertThat(all.get(1).username()).isEqualTo("alice");
+ assertThat(all.get(2).username()).isEqualTo("auditor");
+ }
+
+ // =============================================
+ // insert
+ // =============================================
+
+ @Test
+ void insertCreatesUserAndReturnsWithGeneratedId() {
+ UserAccount toInsert = new UserAccount(
+ null, "newuser", "hash-value", "新用户",
+ true, Set.of("user"), Set.of("user:profile:read"),
+ "https://avatar.example.com", "newuser@example.com", 99999L
+ );
+
+ UserAccount saved = repository.insert(toInsert);
+
+ assertThat(saved.id()).isNotNull().isPositive();
+ assertThat(saved.username()).isEqualTo("newuser");
+ assertThat(saved.displayName()).isEqualTo("新用户");
+ assertThat(saved.enabled()).isTrue();
+ assertThat(saved.roles()).containsExactly("user");
+ assertThat(saved.permissions()).containsExactly("user:profile:read");
+ assertThat(saved.avatarUrl()).isEqualTo("https://avatar.example.com");
+ assertThat(saved.email()).isEqualTo("newuser@example.com");
+ assertThat(saved.githubId()).isEqualTo(99999L);
+ }
+
+ @Test
+ void insertHandlesNullGithubIdAndEmail() {
+ // GitHub 用户邮箱可能设为私密(null),github_id 也可能无法解析
+ UserAccount toInsert = new UserAccount(
+ null, "github_user", "random-hash", "GitHub 用户",
+ true, Set.of("user"), Set.of(),
+ null, null, null
+ );
+
+ UserAccount saved = repository.insert(toInsert);
+
+ assertThat(saved.id()).isNotNull();
+ assertThat(saved.username()).isEqualTo("github_user");
+ assertThat(saved.githubId()).isNull();
+ assertThat(saved.email()).isNull();
+ assertThat(saved.avatarUrl()).isNull();
+ }
+
+ @Test
+ void insertPersistsEmptyRolesAsEmptySet() {
+ UserAccount toInsert = new UserAccount(
+ null, "norole_user", "hash", "无角色用户",
+ true, Set.of(), Set.of(), null, null, null
+ );
+
+ UserAccount saved = repository.insert(toInsert);
+
+ assertThat(saved.roles()).isEmpty();
+ assertThat(saved.permissions()).isEmpty();
+ }
+
+ // =============================================
+ // updateAuthorization
+ // =============================================
+
+ @Test
+ void updateAuthorizationChangesRolesAndPermissions() {
+ Set newRoles = Set.of("editor", "reviewer");
+ Set newPermissions = Set.of("user:profile:read", "user:center:read");
+
+ UserAccount updated = repository.updateAuthorization(2L, newRoles, newPermissions);
+
+ assertThat(updated.username()).isEqualTo("alice");
+ assertThat(updated.roles()).containsExactlyInAnyOrder("editor", "reviewer");
+ assertThat(updated.permissions()).containsExactlyInAnyOrder("user:profile:read", "user:center:read");
+ }
+
+ @Test
+ void updateAuthorizationThrowsWhenUserMissing() {
+ assertThatThrownBy(() -> repository.updateAuthorization(999L, Set.of("user"), Set.of()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("用户不存在: 999");
+ }
+
+ // =============================================
+ // updateProfile
+ // =============================================
+
+ @Test
+ void updateProfileUpdatesGithubFields() {
+ UserAccount updated = repository.updateProfile(
+ 2L, "Alice Updated", "https://new-avatar.com", "alice@github.com", 12345L
+ );
+
+ assertThat(updated.username()).isEqualTo("alice");
+ assertThat(updated.displayName()).isEqualTo("Alice Updated");
+ assertThat(updated.avatarUrl()).isEqualTo("https://new-avatar.com");
+ assertThat(updated.email()).isEqualTo("alice@github.com");
+ assertThat(updated.githubId()).isEqualTo(12345L);
+ }
+
+ @Test
+ void updateProfileHandlesNullEmailAndGithubId() {
+ // 邮箱私密、uuid 无法解析 → 允许 null
+ UserAccount updated = repository.updateProfile(2L, "Alice", null, null, null);
+
+ assertThat(updated.username()).isEqualTo("alice");
+ assertThat(updated.displayName()).isEqualTo("Alice");
+ assertThat(updated.avatarUrl()).isNull();
+ assertThat(updated.email()).isNull();
+ assertThat(updated.githubId()).isNull();
+ }
+
+ @Test
+ void updateProfileThrowsWhenUserMissing() {
+ assertThatThrownBy(() -> repository.updateProfile(999L, "Name", null, null, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("用户不存在: 999");
+ }
+}
diff --git a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java
new file mode 100644
index 0000000..d8509ea
--- /dev/null
+++ b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java
@@ -0,0 +1,299 @@
+package com.involutionhell.backend.usercenter.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.involutionhell.backend.usercenter.dto.LoginRequest;
+import com.involutionhell.backend.usercenter.dto.LoginResponse;
+import com.involutionhell.backend.usercenter.dto.UserView;
+import com.involutionhell.backend.usercenter.model.UserAccount;
+import java.util.Optional;
+import java.util.Set;
+import me.zhyd.oauth.model.AuthUser;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * AuthService 单元测试。
+ * StpUtil 是静态工具类,使用 Mockito.mockStatic 进行隔离,避免依赖 Sa-Token 容器。
+ */
+@ExtendWith(MockitoExtension.class)
+class AuthServiceTests {
+
+ @Mock
+ private UserCenterService userCenterService;
+
+ @Mock
+ private PasswordService passwordService;
+
+ @InjectMocks
+ private AuthService authService;
+
+ // =============================================
+ // 辅助方法
+ // =============================================
+
+ /** 创建一个已启用的标准用户。 */
+ private UserAccount enabledUser(Long id, String username, String passwordHash) {
+ return new UserAccount(id, username, passwordHash, "显示名称", true,
+ Set.of("user"), Set.of("user:profile:read"), null, null, null);
+ }
+
+ /** 创建一个已停用的用户。 */
+ private UserAccount disabledUser(Long id, String username) {
+ return new UserAccount(id, username, "hash", "显示名称", false,
+ Set.of("user"), Set.of(), null, null, null);
+ }
+
+ /**
+ * 创建 AuthUser Mock,模拟 JustAuth 返回的 GitHub 用户信息。
+ * nickname 为 null 时 AuthService 回退使用 username 字段。
+ */
+ private AuthUser githubUser(String uuid, String nickname, String avatar, String email) {
+ AuthUser user = mock(AuthUser.class);
+ when(user.getUuid()).thenReturn(uuid);
+ when(user.getNickname()).thenReturn(nickname);
+ if (nickname == null) {
+ when(user.getUsername()).thenReturn("github-login-" + uuid);
+ }
+ when(user.getAvatar()).thenReturn(avatar);
+ when(user.getEmail()).thenReturn(email);
+ return user;
+ }
+
+ // =============================================
+ // login() - 账号密码登录
+ // =============================================
+
+ @Test
+ void loginSucceedsWithCorrectCredentials() {
+ UserAccount account = enabledUser(1L, "alice", "correct-hash");
+ when(userCenterService.findByUsername("alice")).thenReturn(Optional.of(account));
+ when(passwordService.matches("Alice@123", "correct-hash")).thenReturn(true);
+
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(StpUtil::getTokenName).thenReturn("satoken");
+ stpUtil.when(StpUtil::getTokenValue).thenReturn("token-abc");
+
+ LoginResponse response = authService.login(new LoginRequest("alice", "Alice@123"));
+
+ // 验证 Sa-Token 登录以正确的用户 ID 被调用
+ stpUtil.verify(() -> StpUtil.login(1L));
+ assertThat(response.tokenName()).isEqualTo("satoken");
+ assertThat(response.tokenValue()).isEqualTo("token-abc");
+ assertThat(response.user().username()).isEqualTo("alice");
+ }
+ }
+
+ @Test
+ void loginThrowsWhenUsernameDoesNotExist() {
+ when(userCenterService.findByUsername("nobody")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> authService.login(new LoginRequest("nobody", "pass")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("用户名或密码错误");
+ }
+
+ @Test
+ void loginThrowsWhenAccountIsDisabled() {
+ when(userCenterService.findByUsername("alice")).thenReturn(Optional.of(disabledUser(2L, "alice")));
+
+ assertThatThrownBy(() -> authService.login(new LoginRequest("alice", "Alice@123")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("账号已被禁用");
+ }
+
+ @Test
+ void loginThrowsWhenPasswordIsWrong() {
+ UserAccount account = enabledUser(1L, "alice", "correct-hash");
+ when(userCenterService.findByUsername("alice")).thenReturn(Optional.of(account));
+ when(passwordService.matches("wrong-pass", "correct-hash")).thenReturn(false);
+
+ assertThatThrownBy(() -> authService.login(new LoginRequest("alice", "wrong-pass")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("用户名或密码错误");
+ }
+
+ // =============================================
+ // loginByGithub() - GitHub OAuth 登录
+ // =============================================
+
+ @Test
+ void loginByGithubAutoRegistersNewUser() {
+ // GitHub UUID 为纯数字,可被解析为 Long
+ AuthUser ghUser = githubUser("12345", "GitHubNick", "https://avatar.url", "user@github.com");
+
+ UserAccount createdAccount = enabledUser(10L, "github_12345", "random-hash");
+ when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty());
+ when(passwordService.hash(any())).thenReturn("random-hash");
+ when(userCenterService.createUser(any())).thenReturn(createdAccount);
+
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(StpUtil::getTokenName).thenReturn("satoken");
+ stpUtil.when(StpUtil::getTokenValue).thenReturn("token-xyz");
+
+ LoginResponse response = authService.loginByGithub(ghUser);
+
+ // 应调用 createUser 而非 updateProfile
+ verify(userCenterService).createUser(any());
+ assertThat(response.user().username()).isEqualTo("github_12345");
+ }
+ }
+
+ @Test
+ void loginByGithubSetsCorrectGithubIdOnNewUser() {
+ AuthUser ghUser = githubUser("99999", "Nick", null, null);
+
+ when(userCenterService.findByUsername("github_99999")).thenReturn(Optional.empty());
+ when(passwordService.hash(any())).thenReturn("hash");
+ when(userCenterService.createUser(any())).thenAnswer(inv -> {
+ UserAccount arg = inv.getArgument(0);
+ // 验证 githubId 被正确解析为 Long
+ assertThat(arg.githubId()).isEqualTo(99999L);
+ return enabledUser(10L, "github_99999", "hash");
+ });
+
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(StpUtil::getTokenName).thenReturn("satoken");
+ stpUtil.when(StpUtil::getTokenValue).thenReturn("token");
+ authService.loginByGithub(ghUser);
+ }
+ }
+
+ @Test
+ void loginByGithubSetsNullGithubIdWhenUuidIsNotNumeric() {
+ // GitHub UUID 非纯数字(极罕见,但代码中有 try-catch 处理)
+ AuthUser ghUser = githubUser("not-a-number", "Nick", null, null);
+
+ when(userCenterService.findByUsername("github_not-a-number")).thenReturn(Optional.empty());
+ when(passwordService.hash(any())).thenReturn("hash");
+ when(userCenterService.createUser(any())).thenAnswer(inv -> {
+ UserAccount arg = inv.getArgument(0);
+ // UUID 无法解析为数字时,githubId 应为 null
+ assertThat(arg.githubId()).isNull();
+ return enabledUser(10L, "github_not-a-number", "hash");
+ });
+
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(StpUtil::getTokenName).thenReturn("satoken");
+ stpUtil.when(StpUtil::getTokenValue).thenReturn("token");
+ authService.loginByGithub(ghUser);
+ }
+ }
+
+ @Test
+ void loginByGithubUsesUsernameAsFallbackWhenNicknameIsNull() {
+ // GitHub 用户昵称为 null 时,回退使用 username 字段作为 displayName
+ AuthUser ghUser = githubUser("12345", null, null, null);
+
+ when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty());
+ when(passwordService.hash(any())).thenReturn("hash");
+ when(userCenterService.createUser(any())).thenAnswer(inv -> {
+ UserAccount arg = inv.getArgument(0);
+ // displayName 应来自 getUsername(),即 "github-login-12345"
+ assertThat(arg.displayName()).isEqualTo("github-login-12345");
+ return enabledUser(10L, "github_12345", "hash");
+ });
+
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(StpUtil::getTokenName).thenReturn("satoken");
+ stpUtil.when(StpUtil::getTokenValue).thenReturn("token");
+ authService.loginByGithub(ghUser);
+ }
+ }
+
+ @Test
+ void loginByGithubUpdatesProfileWhenUserAlreadyExists() {
+ AuthUser ghUser = githubUser("12345", "UpdatedNick", "https://new-avatar.url", "new@github.com");
+
+ UserAccount existing = enabledUser(10L, "github_12345", "hash");
+ UserAccount afterUpdate = new UserAccount(
+ 10L, "github_12345", "hash", "UpdatedNick", true,
+ Set.of("user"), Set.of("user:profile:read"),
+ "https://new-avatar.url", "new@github.com", 12345L
+ );
+ when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(existing));
+ when(userCenterService.updateProfile(10L, "UpdatedNick", "https://new-avatar.url", "new@github.com", 12345L))
+ .thenReturn(afterUpdate);
+
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ stpUtil.when(StpUtil::getTokenName).thenReturn("satoken");
+ stpUtil.when(StpUtil::getTokenValue).thenReturn("token-xyz");
+
+ LoginResponse response = authService.loginByGithub(ghUser);
+
+ // 已有用户应走 updateProfile 而非 createUser
+ verify(userCenterService).updateProfile(10L, "UpdatedNick", "https://new-avatar.url", "new@github.com", 12345L);
+ assertThat(response.user().displayName()).isEqualTo("UpdatedNick");
+ assertThat(response.user().githubId()).isEqualTo(12345L);
+ }
+ }
+
+ @Test
+ void loginByGithubThrowsWhenExistingAccountIsDisabled() {
+ AuthUser ghUser = githubUser("12345", "Nick", null, null);
+
+ UserAccount disabledAccount = disabledUser(10L, "github_12345");
+ when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(disabledAccount));
+ // updateProfile 仍会被调用(刷新资料),但返回的账号仍是禁用状态
+ when(userCenterService.updateProfile(any(), any(), any(), any(), any()))
+ .thenReturn(disabledAccount);
+
+ assertThatThrownBy(() -> authService.loginByGithub(ghUser))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("账号已被禁用");
+ }
+
+ @Test
+ void loginByGithubThrowsWhenNewlyRegisteredAccountIsDisabled() {
+ // 理论上不会发生(新注册账号默认启用),但防御性测试
+ AuthUser ghUser = githubUser("12345", "Nick", null, null);
+
+ UserAccount disabledAccount = disabledUser(10L, "github_12345");
+ when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty());
+ when(passwordService.hash(any())).thenReturn("hash");
+ when(userCenterService.createUser(any())).thenReturn(disabledAccount);
+
+ assertThatThrownBy(() -> authService.loginByGithub(ghUser))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("账号已被禁用");
+ }
+
+ // =============================================
+ // logout() 和 currentUser()
+ // =============================================
+
+ @Test
+ void logoutInvalidatesCurrentSession() {
+ try (MockedStatic stpUtil = mockStatic(StpUtil.class)) {
+ authService.logout();
+
+ // 验证 Sa-Token 的 logout 方法被正确调用
+ stpUtil.verify(StpUtil::logout);
+ }
+ }
+
+ @Test
+ void currentUserDelegatesToUserCenterService() {
+ UserView expectedView = new UserView(
+ 1L, "alice", "Alice", true,
+ Set.of("user"), Set.of("user:profile:read"),
+ null, null, null
+ );
+ when(userCenterService.currentUser()).thenReturn(expectedView);
+
+ UserView result = authService.currentUser();
+
+ assertThat(result).isSameAs(expectedView);
+ }
+}
diff --git a/src/test/resources/test-schema.sql b/src/test/resources/test-schema.sql
index acb88ae..aea8bca 100644
--- a/src/test/resources/test-schema.sql
+++ b/src/test/resources/test-schema.sql
@@ -6,7 +6,10 @@ CREATE TABLE IF NOT EXISTS user_accounts (
display_name VARCHAR(255),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
roles TEXT NOT NULL DEFAULT '',
- permissions TEXT NOT NULL DEFAULT ''
+ permissions TEXT NOT NULL DEFAULT '',
+ avatar_url VARCHAR(500),
+ email VARCHAR(255),
+ github_id BIGINT UNIQUE
);
-- 种子账号(与生产保持一致)逐行插入,H2 兼容写法
From 1ed1c5cea9512bf487ff36917e5fc5fa4da29a5b Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 11 Apr 2026 00:41:13 +0000
Subject: [PATCH 2/3] fix: resolve 36 test failures and add detailed
explanatory comments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Root causes and fixes:
1. SaTokenPermissionImpl (NEW)
- Without StpInterface, Sa-Token returns empty permissions for all users,
causing every @SaCheckPermission check to unconditionally fail with 403.
- loginId must be parsed via toString() first: Sa-Token serializes it as
String internally even when StpUtil.login(Long) was called.
2. GlobalExceptionHandler
- NotPermissionException.getCode() returns the integer scene code (-1),
not the permission string. Fixed to getPermission() which returns the
actual missing permission name (e.g. "user:center:read").
3. AbstractWebIntegrationTest + BackendApplicationTests
- SPRING_DATASOURCE_URL env var (pointing to Neon PostgreSQL) has higher
Spring priority than application-test.properties, causing H2 context
load failure. Fixed via @SpringBootTest(properties) which overrides all
env vars.
- JustAuth UrlValidator rejects localhost redirect URIs; overridden with
a syntactically valid placeholder URL.
4. AuthControllerIntegrationTests
- Sa-Token NOT_TOKEN scenario now maps to "未提供 Token", not the old
generic "未登录或登录状态已失效" message.
5. UserCenterControllerIntegrationTests
- All URLs updated: /api/user-center/* paths were removed; current routes
are /auth/me, /users, /users/{id}, /users/{id}/authorization.
- Permission error message updated to match GlobalExceptionHandler output:
"拒绝访问: 缺少权限 []".
6. OpenAiStreamControllerIntegrationTests
- DTO field renamed: message (String) -> messages (List), aligning with
Vercel AI SDK payload format.
- Async test uses asyncDispatch two-step pattern (required for
StreamingResponseBody); polling getContentAsString() never sees data.
- Stub returns OpenAI SSE format choices[0].delta.content so relayEvents()
can extract the text and emit Vercel Stream prefix "0:".
All 78 tests now pass.
https://claude.ai/code/session_016Z9qEQdrSXSTAhCp1YMgnk
---
.../common/config/SaTokenPermissionImpl.java | 35 ++++++++++-
.../common/error/GlobalExceptionHandler.java | 10 ++-
.../backend/BackendApplicationTests.java | 24 ++++++-
...penAiStreamControllerIntegrationTests.java | 63 ++++++++++++++++---
.../support/AbstractWebIntegrationTest.java | 44 ++++++++-----
.../AuthControllerIntegrationTests.java | 29 +++++++++
.../UserCenterControllerIntegrationTests.java | 59 +++++++++++++++++
7 files changed, 236 insertions(+), 28 deletions(-)
diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java
index e42409e..49f286b 100644
--- a/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java
+++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java
@@ -7,8 +7,25 @@
/**
* Sa-Token 权限与角色加载实现。
- * 根据登录用户 ID 从数据库加载其权限集合与角色集合,
- * 供 {@code @SaCheckPermission} / {@code @SaCheckRole} 等注解使用。
+ *
+ * 为什么需要这个类?
+ * Sa-Token 的 {@code @SaCheckPermission} / {@code @SaCheckRole} 注解在执行权限校验时,
+ * 会调用 {@link StpInterface#getPermissionList} / {@link StpInterface#getRoleList}
+ * 来获取当前登录用户的权限集合与角色集合。
+ *
+ * 在此类被添加之前,项目中缺少 {@code StpInterface} 的实现 Bean,
+ * Sa-Token 回退使用默认的空列表实现,导致所有 {@code @SaCheckPermission} 检查
+ * 无论用户实际持有什么权限,一律抛出 {@code NotPermissionException}(HTTP 403)。
+ * 这是一个生产代码 Bug:权限体系在数据库层面已设计完备,但因缺少加载桥梁而完全失效。
+ *
+ * 实现逻辑
+ * 以登录时写入 Sa-Token Session 的用户 ID 为键,从 {@code user_accounts} 表加载
+ * {@code permissions} 与 {@code roles} 列(逗号分隔字符串已由 Repository 层解析为 Set)。
+ *
+ * loginId 类型说明
+ * Sa-Token 在内部将登录 ID 序列化为 {@code String} 存储,因此
+ * {@code loginId} 参数的运行时类型是 {@code String},而非调用 {@code StpUtil.login(Long)}
+ * 时传入的 {@code Long}。故此处需要通过 {@code Long.valueOf(loginId.toString())} 转换。
*/
@Component
public class SaTokenPermissionImpl implements StpInterface {
@@ -19,13 +36,27 @@ public SaTokenPermissionImpl(UserAccountRepository userAccountRepository) {
this.userAccountRepository = userAccountRepository;
}
+ /**
+ * 返回指定用户拥有的权限码列表。
+ *
+ * Sa-Token 每次执行 {@code @SaCheckPermission("user:xxx")} 时都会调用此方法,
+ * 将返回值与注解中声明的权限码对比,若不包含则抛出 {@code NotPermissionException}。
+ *
+ * @param loginId 登录 ID,运行时实际类型为 String(Sa-Token 内部序列化结果)
+ * @param loginType 登录类型,单端场景下为 "login",此处忽略
+ */
@Override
public List getPermissionList(Object loginId, String loginType) {
+ // loginId 由 Sa-Token 以 String 形式回传,需先 toString() 再解析为 Long
return userAccountRepository.findById(Long.valueOf(loginId.toString()))
.map(account -> List.copyOf(account.permissions()))
.orElse(List.of());
}
+ /**
+ * 返回指定用户拥有的角色标识列表,供 {@code @SaCheckRole} 使用。
+ * 逻辑与 {@link #getPermissionList} 完全对称,仅字段来源不同。
+ */
@Override
public List getRoleList(Object loginId, String loginType) {
return userAccountRepository.findById(Long.valueOf(loginId.toString()))
diff --git a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java
index 799f306..6f75754 100644
--- a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java
+++ b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java
@@ -41,7 +41,15 @@ public ResponseEntity> handleNotLoginException(NotLoginExcepti
}
/**
- * Sa-Token: 拦截权限不足异常
+ * Sa-Token: 拦截权限不足异常。
+ *
+ * 为什么用 {@code e.getPermission()} 而不是 {@code e.getCode()}?
+ * {@code NotPermissionException} 继承自 {@code SaTokenException},父类的 {@code getCode()}
+ * 返回的是异常场景码(整数,如 -1),表示"哪种类型的 Sa-Token 异常",而非权限字符串本身。
+ * 权限字符串(如 {@code "user:center:read"})存储在 {@code NotPermissionException}
+ * 自身的 {@code permission} 字段中,须通过 {@code getPermission()} 获取。
+ * 若误用 {@code getCode()},错误消息将显示为 "拒绝访问: 缺少权限 [-1]",
+ * 对调用方毫无诊断价值。
*/
@ExceptionHandler(NotPermissionException.class)
public ResponseEntity> handleNotPermissionException(NotPermissionException e) {
diff --git a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java
index 3595995..561f21e 100644
--- a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java
+++ b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java
@@ -4,15 +4,33 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
-// 显式覆盖数据源配置,防止 SPRING_DATASOURCE_URL 环境变量(指向生产 PostgreSQL)
-// 优先于 application-test.properties,导致上下文加载失败。
+/**
+ * Spring Boot 上下文加载冒烟测试。
+ *
+ * 为什么需要在这里覆盖数据源属性?
+ * 原始代码使用裸 {@code @SpringBootTest},未覆盖任何属性。
+ * 当测试服务器设置了 {@code SPRING_DATASOURCE_URL} 环境变量(指向生产 Neon PostgreSQL)时,
+ * 环境变量的优先级高于 {@code application-test.properties},
+ * Spring 会尝试用 PostgreSQL 驱动连接该 URL,而 H2 驱动拒绝 {@code jdbc:postgresql://} 格式,
+ * 导致上下文启动失败,报错:
+ * {@code Driver org.h2.Driver claims to not accept jdbcUrl, jdbc:postgresql://...}。
+ *
+ * {@code @SpringBootTest(properties)} 的优先级高于一切外部环境变量,
+ * 可确保本测试始终在 H2 内存库上运行,与生产数据库完全隔离。
+ *
+ * 同理,也覆盖了 JustAuth 的 redirect-uri:JustAuth 使用 Apache Commons
+ * {@code UrlValidator} 在 {@link me.zhyd.oauth.request.AuthGithubRequest} 初始化时
+ * 校验 redirect-uri,默认拒绝 localhost,故使用格式合法的占位 URL。
+ */
@SpringBootTest(properties = {
+ // 覆盖 SPRING_DATASOURCE_URL 环境变量,强制使用 H2,详见类 Javadoc
"spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.sql.init.mode=always",
"spring.sql.init.schema-locations=classpath:test-schema.sql",
+ // JustAuth UrlValidator 拒绝 localhost,使用合法占位 URL,不发起实际网络请求
"justauth.type.github.redirect-uri=https://example.com/api/auth/callback/github",
"justauth.type.github.client-id=test-client-id",
"justauth.type.github.client-secret=test-client-secret"
@@ -21,7 +39,7 @@
class BackendApplicationTests {
/**
- * 验证 Spring Boot 测试上下文可以正常启动。
+ * 验证 Spring Boot 测试上下文可以正常启动(所有 Bean 可注入、数据源可连接)。
*/
@Test
void contextLoads() {
diff --git a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java
index d142597..bcbcc3c 100644
--- a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java
+++ b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java
@@ -21,13 +21,47 @@
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
+/**
+ * OpenAiStreamController 集成测试。
+ *
+ * 三处改动说明
+ *
+ * 1. 请求体字段:message → messages
+ * {@link OpenAiStreamRequest} DTO 已从单条字符串字段 {@code message} 重构为
+ * 多轮对话列表字段 {@code messages},以对齐 Vercel AI SDK 的 payload 格式。
+ * 旧版测试发送 {@code {"message":"..."}},服务端将 {@code messages} 视为 null/空,
+ * 触发 {@code @NotEmpty} 校验失败(400),而非进入 SSE 处理流程。
+ *
+ * 2. 匿名请求错误消息:"未登录..." → "未提供 Token"
+ * 与 AuthControllerIntegrationTests 同理,未携带 token 属于 Sa-Token {@code NOT_TOKEN}
+ * 场景,GlobalExceptionHandler 的当前输出是 "未提供 Token",
+ * 旧版通用消息已过时。
+ *
+ * 3. StreamingResponseBody 的 MockMvc 测试方式:asyncDispatch 模式
+ * 控制器返回 {@link org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody},
+ * Spring 将实际写入操作分派到异步线程。MockMvc 对此类异步响应的正确测试步骤是:
+ *
+ * - 调用 {@code mockMvc.perform(...).andExpect(request().asyncStarted()).andReturn()}
+ * 触发异步处理,获取 {@code MvcResult};
+ * - 再调用 {@code mockMvc.perform(asyncDispatch(mvcResult))} 完成派发,
+ * 此时响应体才真正写入,可对 status / content 做断言。
+ *
+ * 旧版使用轮询 {@code getContentAsString()} 的方式无法获取到 {@code StreamingResponseBody}
+ * 写入的内容,测试超时报错 "SSE 响应内容未按预期写入"。
+ */
@Import(OpenAiStreamControllerIntegrationTests.OpenAiTestConfiguration.class)
class OpenAiStreamControllerIntegrationTests extends AbstractWebIntegrationTest {
+ /**
+ * 验证已登录用户可以发起 SSE 流式请求,并收到 Vercel Stream 格式的响应。
+ *
+ * 采用 asyncDispatch 两步模式:先启动异步,再派发获取完整响应体。
+ */
@Test
void streamReturnsSseEventsForAuthenticatedUser() throws Exception {
String token = loginAsAdmin();
- // StreamingResponseBody 采用异步派发:先获取 MvcResult,再通过 asyncDispatch 触发真实写入
+
+ // 第一步:发起请求,确认异步处理已启动(StreamingResponseBody 异步写入)
MvcResult mvcResult = mockMvc.perform(post("/openai/responses/stream")
.header("satoken", token)
.contentType(MediaType.APPLICATION_JSON)
@@ -39,13 +73,18 @@ void streamReturnsSseEventsForAuthenticatedUser() throws Exception {
.andExpect(request().asyncStarted())
.andReturn();
- // relayEvents() 将 OpenAI choices[0].delta.content 转换为 Vercel Stream 格式 "0:\"hello\"\n"
+ // 第二步:触发异步派发,断言响应体包含 Vercel Stream 前导符 "0:" 和内容 "hello"
+ // relayEvents() 从 choices[0].delta.content 提取文本,转换为 0:""\n 格式
mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("0:")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("hello")));
}
+ /**
+ * 未携带 token 访问流式接口,SaInterceptor 在请求到达控制器前即触发 NOT_TOKEN 异常,
+ * 返回 401 + "未提供 Token",不会进入异步处理流程。
+ */
@Test
void streamRejectsAnonymousRequest() throws Exception {
mockMvc.perform(post("/openai/responses/stream")
@@ -60,6 +99,10 @@ void streamRejectsAnonymousRequest() throws Exception {
.andExpect(jsonPath("$.message").value("未提供 Token"));
}
+ /**
+ * messages 为空数组时,@NotEmpty 校验失败,返回 400 + 字段级错误消息。
+ * 旧测试发送 {"message": ""} 并期望 "message: 消息不能为空",已按新 DTO 结构更新。
+ */
@Test
void streamValidatesEmptyMessages() throws Exception {
String token = loginAsAdmin();
@@ -81,7 +124,8 @@ void streamValidatesEmptyMessages() throws Exception {
static class OpenAiTestConfiguration {
/**
- * 提供一个稳定的测试桩网关,避免控制器测试依赖真实 OpenAI 或 Mockito。
+ * 替换真实 {@link OpenAiStreamGateway},避免集成测试依赖外部 OpenAI 服务或 Mockito。
+ * {@code @Primary} 确保此 Bean 在有多个同类型 Bean 时优先被注入。
*/
@Bean
@Primary
@@ -92,16 +136,19 @@ OpenAiStreamGateway openAiStreamGateway() {
private static final class StubOpenAiStreamGateway implements OpenAiStreamGateway {
- /**
- * 测试环境下跳过外部 OpenAI 配置校验。
- */
+ /** 测试环境无需校验 OpenAI 配置(apiKey 等),直接放行。 */
@Override
public void validateConfiguration(OpenAiStreamRequest request) {
}
/**
- * 返回固定的 OpenAI SSE 格式事件流(与真实 API 格式保持一致)。
- * relayEvents() 提取 choices[0].delta.content 写入 Vercel Stream 格式。
+ * 返回一条符合 OpenAI SSE 协议格式的固定响应,供 {@code relayEvents()} 解析。
+ *
+ * {@code relayEvents()} 从 {@code choices[0].delta.content} 提取文本,
+ * 转换为 {@code 0:"hello"\n} 写入输出流。
+ * 旧版 stub 使用 {@code {"type":"...","delta":"..."}} 格式,
+ * 与 OpenAI 实际格式不符,{@code relayEvents()} 无法找到 {@code choices} 节点,
+ * 导致输出流为空,asyncDispatch 后 content 断言失败。
*/
@Override
public InputStream openStream(OpenAiStreamRequest request) {
diff --git a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java
index 9bafcc6..2cd53c8 100644
--- a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java
+++ b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java
@@ -13,17 +13,39 @@
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
-// 显式指定 H2 内存库配置,优先级高于环境变量(如 SPRING_DATASOURCE_URL),
-// 确保集成测试始终使用 H2 而非生产 PostgreSQL 连接。
+/**
+ * Web 集成测试公共基类,提供 MockMvc 和预置登录辅助方法。
+ *
+ * 为什么在 @SpringBootTest 中显式指定数据源属性?
+ * Spring Boot 属性优先级从高到低依次为:
+ *
+ * - {@code @SpringBootTest(properties = {...})} — 最高
+ * - 操作系统环境变量(如 {@code SPRING_DATASOURCE_URL})
+ * - {@code application-test.properties} 等 Profile 配置文件 — 最低
+ *
+ * 测试服务器上的 {@code SPRING_DATASOURCE_URL} 环境变量指向生产 Neon PostgreSQL,
+ * 其优先级高于 {@code application-test.properties} 中的 H2 配置,导致集成测试
+ * 尝试连接 PostgreSQL,上下文启动失败,所有 Web 集成测试报错。
+ * 通过在 {@code @SpringBootTest(properties)} 中覆盖数据源属性,可绕过环境变量,
+ * 确保测试始终使用 H2 内存库。
+ *
+ * 为什么还要覆盖 JustAuth redirect-uri?
+ * JustAuth 内部使用 Apache Commons {@code UrlValidator} 校验 redirect-uri 格式。
+ * 默认情况下,{@code UrlValidator} 拒绝 {@code localhost} 域名(视为非法 URL),
+ * 而 {@code application.properties} 中的默认值是 {@code http://localhost:3000/...}。
+ * 若不覆盖,{@code OAuthController} 在构建 {@code AuthGithubRequest} 时会立即抛出
+ * {@code AuthException: Illegal redirect uri},导致所有 OAuth 相关集成测试以 500 失败。
+ * 测试环境仅需要一个格式合法的占位 URL,不会发起任何真实网络请求。
+ */
@SpringBootTest(properties = {
+ // --- 数据源:覆盖 SPRING_DATASOURCE_URL 环境变量,强制使用 H2 内存库 ---
"spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.sql.init.mode=always",
"spring.sql.init.schema-locations=classpath:test-schema.sql",
- // JustAuth 使用 Apache Commons UrlValidator 校验 redirect-uri,默认拒绝 localhost。
- // 测试环境使用合法格式的占位 URL,不影响实际网络请求。
+ // --- JustAuth:使用合法格式的占位 redirect-uri,规避 UrlValidator localhost 限制 ---
"justauth.type.github.redirect-uri=https://example.com/api/auth/callback/github",
"justauth.type.github.client-id=test-client-id",
"justauth.type.github.client-secret=test-client-secret"
@@ -36,7 +58,7 @@ public abstract class AbstractWebIntegrationTest {
protected MockMvc mockMvc;
/**
- * 使用指定账号登录并提取 Sa-Token 值。
+ * 使用指定账号登录并提取 Sa-Token 值,供子类测试方法携带 token 调用受保护接口。
*/
protected String loginAndGetToken(String username, String password) throws Exception {
MvcResult result = mockMvc.perform(post("/auth/login")
@@ -54,23 +76,17 @@ protected String loginAndGetToken(String username, String password) throws Excep
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.tokenValue");
}
- /**
- * 以管理员身份登录。
- */
+ /** 以管理员身份登录(拥有全部权限:user:profile:read, user:center:read, user:center:manage)。 */
protected String loginAsAdmin() throws Exception {
return loginAndGetToken("admin", "Admin@123456");
}
- /**
- * 以普通用户身份登录。
- */
+ /** 以普通用户身份登录(仅有 user:profile:read 权限,无法访问用户中心管理接口)。 */
protected String loginAsAlice() throws Exception {
return loginAndGetToken("alice", "Alice@123456");
}
- /**
- * 以审计员身份登录。
- */
+ /** 以审计员身份登录(拥有 user:profile:read, user:center:read,无 user:center:manage)。 */
protected String loginAsAuditor() throws Exception {
return loginAndGetToken("auditor", "Audit@123456");
}
diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java
index 621903f..8053a27 100644
--- a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java
+++ b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java
@@ -9,6 +9,26 @@
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
+/**
+ * AuthController 集成测试(账号密码登录、退出、当前用户查询)。
+ *
+ * 历史测试失败原因(已修复)
+ * 修复前,所有测试都报 "Driver claims to not accept jdbcUrl, jdbc:postgresql://...",
+ * 根本原因是 {@code SPRING_DATASOURCE_URL} 环境变量覆盖了 {@code application-test.properties}
+ * 中的 H2 配置,已通过 {@link com.involutionhell.backend.support.AbstractWebIntegrationTest}
+ * 的 {@code @SpringBootTest(properties)} 解决。
+ *
+ * 匿名请求错误消息的变化("未登录..." → "未提供 Token")
+ * 旧版测试断言 {@code "未登录或登录状态已失效"},此消息来自早期 GlobalExceptionHandler
+ * 使用通用文案的版本。当前 GlobalExceptionHandler 对 {@code NotLoginException} 按场景值细分:
+ *
+ * - {@code NOT_TOKEN}(完全未携带 token)→ "未提供 Token"
+ * - {@code INVALID_TOKEN}(token 格式非法)→ "Token 无效"
+ * - {@code TOKEN_TIMEOUT} → "Token 已过期"
+ * - …
+ *
+ * 匿名请求属于 {@code NOT_TOKEN} 场景,故正确消息为 "未提供 Token"。
+ */
class AuthControllerIntegrationTests extends AbstractWebIntegrationTest {
@Test
@@ -70,6 +90,10 @@ void meReturnsCurrentUserWhenLoggedIn() throws Exception {
.andExpect(jsonPath("$.data.permissions[0]").isNotEmpty());
}
+ /**
+ * 未携带任何 token 访问受保护接口,Sa-Token 抛出 NOT_TOKEN 场景的 NotLoginException,
+ * GlobalExceptionHandler 将其映射为 "未提供 Token"(而非旧版通用文案"未登录或登录状态已失效")。
+ */
@Test
void meRejectsAnonymousRequest() throws Exception {
mockMvc.perform(get("/auth/me"))
@@ -87,11 +111,16 @@ void logoutSucceedsAndMakesTokenInvalid() throws Exception {
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("退出成功"));
+ // 退出后原 token 应失效,再次访问 /me 返回 401
mockMvc.perform(get("/auth/me").header("satoken", token))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false));
}
+ /**
+ * 匿名 POST /auth/logout,同样属于 NOT_TOKEN 场景,期望 "未提供 Token"。
+ * 旧版测试使用通用消息,已更新为当前 GlobalExceptionHandler 的实际输出。
+ */
@Test
void logoutRejectsAnonymousRequest() throws Exception {
mockMvc.perform(post("/auth/logout"))
diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java
index bcde63e..39efd27 100644
--- a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java
+++ b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java
@@ -10,8 +10,37 @@
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
+/**
+ * UserCenterController + AuthController(/auth/me)集成测试。
+ *
+ * URL 路径修正(旧:/api/user-center/* → 新:/users/* 和 /auth/me)
+ * 旧版测试使用 {@code /api/user-center/profile}、{@code /api/user-center/users} 等路径,
+ * 这些路径在重构中被移除(注释"context-path 已含 /api/v1,此处不再重复加 /api 前缀"说明
+ * 服务曾有全局 context-path,后来去掉了)。当前实际映射为:
+ *
+ * - 当前用户信息 → {@code GET /auth/me}(AuthController)
+ * - 用户列表 → {@code GET /users}(UserCenterController)
+ * - 单个用户 → {@code GET /users/{id}}(UserCenterController)
+ * - 更新权限 → {@code PUT /users/{id}/authorization}(UserCenterController)
+ *
+ * 旧路径不存在时,Spring 抛出 {@code NoResourceFoundException},被 GlobalExceptionHandler
+ * 的 {@code handleUnexpected} 兜底捕获,返回 HTTP 500,导致测试全部失败。
+ *
+ * 权限错误消息修正(旧:"无权限访问:..." → 新:"拒绝访问: 缺少权限 [...]")
+ * 旧版断言使用的消息与 GlobalExceptionHandler 实际输出不符。
+ * 现在 GlobalExceptionHandler 输出 {@code "拒绝访问: 缺少权限 [<权限码>]"},
+ * 测试消息已与之对齐。
+ *
+ * @SaCheckPermission 之前为何一直 403?
+ * 项目缺少 {@code StpInterface} 实现,Sa-Token 回退使用空列表,
+ * 导致所有权限校验恒定失败。已通过新增 {@code SaTokenPermissionImpl} 解决。
+ */
class UserCenterControllerIntegrationTests extends AbstractWebIntegrationTest {
+ /**
+ * 当前用户信息接口现在位于 AuthController(/auth/me),
+ * 旧测试错误地访问了已不存在的 /api/user-center/profile。
+ */
@Test
void profileReturnsCurrentUserForAuthorizedUser() throws Exception {
String token = loginAsAlice();
@@ -22,6 +51,10 @@ void profileReturnsCurrentUserForAuthorizedUser() throws Exception {
.andExpect(jsonPath("$.data.username").value("alice"));
}
+ /**
+ * 管理员拥有 user:center:read 权限,可访问全量用户列表。
+ * 旧 URL /api/user-center/users 不存在,已修正为 /users。
+ */
@Test
void usersListReturnsAllUsersForAdmin() throws Exception {
String token = loginAsAdmin();
@@ -32,6 +65,11 @@ void usersListReturnsAllUsersForAdmin() throws Exception {
.andExpect(jsonPath("$.data.length()").value(3));
}
+ /**
+ * alice 仅有 user:profile:read,缺少 user:center:read,访问 /users 时 Sa-Token
+ * 抛出 NotPermissionException,GlobalExceptionHandler 返回 "拒绝访问: 缺少权限 [user:center:read]"。
+ * 旧测试期望的 "无权限访问: user:center:read" 是当时不同的错误消息格式,已对齐。
+ */
@Test
void usersListRejectsUserWithoutReadPermission() throws Exception {
String token = loginAsAlice();
@@ -42,6 +80,10 @@ void usersListRejectsUserWithoutReadPermission() throws Exception {
.andExpect(jsonPath("$.message").value("拒绝访问: 缺少权限 [user:center:read]"));
}
+ /**
+ * 审计员拥有 user:profile:read,可查询单个用户详情。
+ * 旧 URL /api/user-center/users/2 已修正为 /users/2。
+ */
@Test
void getUserReturnsRequestedUserForAuditor() throws Exception {
String token = loginAsAuditor();
@@ -52,6 +94,7 @@ void getUserReturnsRequestedUserForAuditor() throws Exception {
.andExpect(jsonPath("$.data.username").value("alice"));
}
+ /** 匿名访问 /users/1 触发 NOT_TOKEN,返回 401。 */
@Test
void getUserRejectsAnonymousRequest() throws Exception {
mockMvc.perform(get("/users/1"))
@@ -59,6 +102,10 @@ void getUserRejectsAnonymousRequest() throws Exception {
.andExpect(jsonPath("$.success").value(false));
}
+ /**
+ * 用户不存在时,UserCenterService 抛出 IllegalArgumentException,
+ * GlobalExceptionHandler 将其映射为 400 BAD_REQUEST。
+ */
@Test
void getUserReturnsBusinessErrorWhenUserMissing() throws Exception {
String token = loginAsAdmin();
@@ -69,6 +116,12 @@ void getUserReturnsBusinessErrorWhenUserMissing() throws Exception {
.andExpect(jsonPath("$.message").value("用户不存在: 999"));
}
+ /**
+ * 管理员成功更新 alice(id=2)的角色与权限后,再次查询验证持久化结果。
+ *
+ * {@code @DirtiesContext} 在方法执行后重置 Spring 上下文(含 H2 数据库),
+ * 防止本测试对 alice 的修改影响同一进程内后续测试的预期数据。
+ */
@Test
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
void updateAuthorizationAllowsAdmin() throws Exception {
@@ -89,12 +142,14 @@ void updateAuthorizationAllowsAdmin() throws Exception {
.andExpect(jsonPath("$.data.roles.length()").value(2))
.andExpect(jsonPath("$.data.permissions.length()").value(2));
+ // 二次查询,验证数据库已持久化
mockMvc.perform(get("/users/2").header("satoken", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.roles.length()").value(2))
.andExpect(jsonPath("$.data.permissions.length()").value(2));
}
+ /** 匿名 PUT 请求触发 NOT_TOKEN,返回 401。 */
@Test
void updateAuthorizationRejectsAnonymousRequest() throws Exception {
mockMvc.perform(put("/users/2/authorization")
@@ -109,6 +164,10 @@ void updateAuthorizationRejectsAnonymousRequest() throws Exception {
.andExpect(jsonPath("$.success").value(false));
}
+ /**
+ * alice 缺少 user:center:manage,更新权限时被 @SaCheckPermission 拦截,返回 403。
+ * 消息格式已从旧版 "无权限访问: ..." 对齐为 GlobalExceptionHandler 当前输出格式。
+ */
@Test
void updateAuthorizationRejectsUserWithoutManagePermission() throws Exception {
String token = loginAsAlice();
From f3d66bfff8d83638e59aecd7370ff8f245ca105c Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 11 Apr 2026 00:52:25 +0000
Subject: [PATCH 3/3] refactor: rewrite comments to remove HTML tags and sound
more human
Removed all , ,
,
, - ,
tags from Javadoc blocks.
Rewrote everything in plain Chinese as a developer would naturally write it.
https://claude.ai/code/session_016Z9qEQdrSXSTAhCp1YMgnk
---
.../common/config/SaTokenPermissionImpl.java | 35 +++--------
.../common/error/GlobalExceptionHandler.java | 11 ++--
.../backend/BackendApplicationTests.java | 27 +++-----
...penAiStreamControllerIntegrationTests.java | 63 ++++---------------
.../support/AbstractWebIntegrationTest.java | 35 +++++------
.../AuthControllerIntegrationTests.java | 31 +++------
.../UserCenterControllerIntegrationTests.java | 60 +++++-------------
7 files changed, 72 insertions(+), 190 deletions(-)
diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java
index 49f286b..2bdf707 100644
--- a/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java
+++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java
@@ -8,24 +8,13 @@
/**
* Sa-Token 权限与角色加载实现。
*
- * 为什么需要这个类?
- * Sa-Token 的 {@code @SaCheckPermission} / {@code @SaCheckRole} 注解在执行权限校验时,
- * 会调用 {@link StpInterface#getPermissionList} / {@link StpInterface#getRoleList}
- * 来获取当前登录用户的权限集合与角色集合。
+ * 项目原来缺少 StpInterface 实现,Sa-Token 找不到实现 Bean 时会回退到默认的空列表,
+ * 导致所有 @SaCheckPermission / @SaCheckRole 注解永远校验失败(403),
+ * 不管数据库里给用户配了什么权限都没用。这是个生产 Bug,加上这个类才算把权限体系真正接通。
*
- * 在此类被添加之前,项目中缺少 {@code StpInterface} 的实现 Bean,
- * Sa-Token 回退使用默认的空列表实现,导致所有 {@code @SaCheckPermission} 检查
- * 无论用户实际持有什么权限,一律抛出 {@code NotPermissionException}(HTTP 403)。
- * 这是一个生产代码 Bug:权限体系在数据库层面已设计完备,但因缺少加载桥梁而完全失效。
- *
- * 实现逻辑
- * 以登录时写入 Sa-Token Session 的用户 ID 为键,从 {@code user_accounts} 表加载
- * {@code permissions} 与 {@code roles} 列(逗号分隔字符串已由 Repository 层解析为 Set)。
- *
- * loginId 类型说明
- * Sa-Token 在内部将登录 ID 序列化为 {@code String} 存储,因此
- * {@code loginId} 参数的运行时类型是 {@code String},而非调用 {@code StpUtil.login(Long)}
- * 时传入的 {@code Long}。故此处需要通过 {@code Long.valueOf(loginId.toString())} 转换。
+ * 关于 loginId 类型:Sa-Token 内部把登录 ID 序列化成 String 存储,
+ * 即使调用 StpUtil.login(Long) 传入的是 Long,回调这里时运行时类型也是 String,
+ * 所以不能直接强转,要先 toString() 再 Long.valueOf()。
*/
@Component
public class SaTokenPermissionImpl implements StpInterface {
@@ -37,25 +26,21 @@ public SaTokenPermissionImpl(UserAccountRepository userAccountRepository) {
}
/**
- * 返回指定用户拥有的权限码列表。
- *
- * Sa-Token 每次执行 {@code @SaCheckPermission("user:xxx")} 时都会调用此方法,
- * 将返回值与注解中声明的权限码对比,若不包含则抛出 {@code NotPermissionException}。
+ * 返回用户拥有的权限码列表,Sa-Token 执行 @SaCheckPermission 时会调用此方法。
*
- * @param loginId 登录 ID,运行时实际类型为 String(Sa-Token 内部序列化结果)
+ * @param loginId 登录 ID,运行时实际类型是 String,不是 Long
* @param loginType 登录类型,单端场景下为 "login",此处忽略
*/
@Override
public List getPermissionList(Object loginId, String loginType) {
- // loginId 由 Sa-Token 以 String 形式回传,需先 toString() 再解析为 Long
+ // Sa-Token 回传的 loginId 是 String,必须先 toString() 再转 Long
return userAccountRepository.findById(Long.valueOf(loginId.toString()))
.map(account -> List.copyOf(account.permissions()))
.orElse(List.of());
}
/**
- * 返回指定用户拥有的角色标识列表,供 {@code @SaCheckRole} 使用。
- * 逻辑与 {@link #getPermissionList} 完全对称,仅字段来源不同。
+ * 返回用户拥有的角色列表,供 @SaCheckRole 使用,逻辑和 getPermissionList 对称。
*/
@Override
public List getRoleList(Object loginId, String loginType) {
diff --git a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java
index 6f75754..0671a09 100644
--- a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java
+++ b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java
@@ -43,13 +43,10 @@ public ResponseEntity> handleNotLoginException(NotLoginExcepti
/**
* Sa-Token: 拦截权限不足异常。
*
- * 为什么用 {@code e.getPermission()} 而不是 {@code e.getCode()}?
- * {@code NotPermissionException} 继承自 {@code SaTokenException},父类的 {@code getCode()}
- * 返回的是异常场景码(整数,如 -1),表示"哪种类型的 Sa-Token 异常",而非权限字符串本身。
- * 权限字符串(如 {@code "user:center:read"})存储在 {@code NotPermissionException}
- * 自身的 {@code permission} 字段中,须通过 {@code getPermission()} 获取。
- * 若误用 {@code getCode()},错误消息将显示为 "拒绝访问: 缺少权限 [-1]",
- * 对调用方毫无诊断价值。
+ * 注意要用 e.getPermission() 而不是 e.getCode()。
+ * getCode() 是父类 SaTokenException 的方法,返回的是整数场景码(比如 -1),
+ * 而权限字符串(比如 "user:center:read")在 NotPermissionException 自己的 permission 字段里,
+ * 要调 getPermission() 才能拿到。用错了的话错误消息会变成 "拒绝访问: 缺少权限 [-1]",没有任何意义。
*/
@ExceptionHandler(NotPermissionException.class)
public ResponseEntity> handleNotPermissionException(NotPermissionException e) {
diff --git a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java
index 561f21e..9673245 100644
--- a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java
+++ b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java
@@ -7,30 +7,26 @@
/**
* Spring Boot 上下文加载冒烟测试。
*
- * 为什么需要在这里覆盖数据源属性?
- * 原始代码使用裸 {@code @SpringBootTest},未覆盖任何属性。
- * 当测试服务器设置了 {@code SPRING_DATASOURCE_URL} 环境变量(指向生产 Neon PostgreSQL)时,
- * 环境变量的优先级高于 {@code application-test.properties},
- * Spring 会尝试用 PostgreSQL 驱动连接该 URL,而 H2 驱动拒绝 {@code jdbc:postgresql://} 格式,
- * 导致上下文启动失败,报错:
- * {@code Driver org.h2.Driver claims to not accept jdbcUrl, jdbc:postgresql://...}。
+ * 原来用的是裸 @SpringBootTest,没覆盖任何属性。
+ * 测试服务器上有 SPRING_DATASOURCE_URL 环境变量指向生产 Neon PostgreSQL,
+ * 环境变量优先级高于 application-test.properties,Spring 就会去连 PostgreSQL,
+ * H2 驱动拒绝 jdbc:postgresql:// 格式,直接报:
+ * Driver org.h2.Driver claims to not accept jdbcUrl, jdbc:postgresql://...
*
- * {@code @SpringBootTest(properties)} 的优先级高于一切外部环境变量,
- * 可确保本测试始终在 H2 内存库上运行,与生产数据库完全隔离。
+ * @SpringBootTest(properties) 的优先级比环境变量还高,所以在这里覆盖就能保证始终跑 H2。
*
- * 同理,也覆盖了 JustAuth 的 redirect-uri:JustAuth 使用 Apache Commons
- * {@code UrlValidator} 在 {@link me.zhyd.oauth.request.AuthGithubRequest} 初始化时
- * 校验 redirect-uri,默认拒绝 localhost,故使用格式合法的占位 URL。
+ * JustAuth 那几个属性也是同理:JustAuth 用 Apache Commons UrlValidator 校验 redirect-uri,
+ * 它不接受 localhost,不覆盖的话 AuthGithubRequest 初始化直接抛异常。
*/
@SpringBootTest(properties = {
- // 覆盖 SPRING_DATASOURCE_URL 环境变量,强制使用 H2,详见类 Javadoc
+ // 覆盖 SPRING_DATASOURCE_URL 环境变量,强制使用 H2
"spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.sql.init.mode=always",
"spring.sql.init.schema-locations=classpath:test-schema.sql",
- // JustAuth UrlValidator 拒绝 localhost,使用合法占位 URL,不发起实际网络请求
+ // JustAuth UrlValidator 不接受 localhost,用合法占位 URL 绕过
"justauth.type.github.redirect-uri=https://example.com/api/auth/callback/github",
"justauth.type.github.client-id=test-client-id",
"justauth.type.github.client-secret=test-client-secret"
@@ -38,9 +34,6 @@
@ActiveProfiles("test")
class BackendApplicationTests {
- /**
- * 验证 Spring Boot 测试上下文可以正常启动(所有 Bean 可注入、数据源可连接)。
- */
@Test
void contextLoads() {
}
diff --git a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java
index bcbcc3c..9f0a031 100644
--- a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java
+++ b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java
@@ -24,39 +24,22 @@
/**
* OpenAiStreamController 集成测试。
*
- * 三处改动说明
+ * 旧测试有三个问题:
*
- * 1. 请求体字段:message → messages
- * {@link OpenAiStreamRequest} DTO 已从单条字符串字段 {@code message} 重构为
- * 多轮对话列表字段 {@code messages},以对齐 Vercel AI SDK 的 payload 格式。
- * 旧版测试发送 {@code {"message":"..."}},服务端将 {@code messages} 视为 null/空,
- * 触发 {@code @NotEmpty} 校验失败(400),而非进入 SSE 处理流程。
+ * 1. 请求体字段写错了。DTO 已从单字段 message 改为多轮对话列表 messages,
+ * 旧测试还发 {"message":"..."}, 服务端 messages 为空,直接 400 校验失败,根本进不了 SSE 流程。
*
- * 2. 匿名请求错误消息:"未登录..." → "未提供 Token"
- * 与 AuthControllerIntegrationTests 同理,未携带 token 属于 Sa-Token {@code NOT_TOKEN}
- * 场景,GlobalExceptionHandler 的当前输出是 "未提供 Token",
- * 旧版通用消息已过时。
+ * 2. 匿名请求的期望消息过时了。未带 token 是 Sa-Token NOT_TOKEN 场景,
+ * GlobalExceptionHandler 现在返回 "未提供 Token",不是旧版的通用文案。
*
- * 3. StreamingResponseBody 的 MockMvc 测试方式:asyncDispatch 模式
- * 控制器返回 {@link org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody},
- * Spring 将实际写入操作分派到异步线程。MockMvc 对此类异步响应的正确测试步骤是:
- *
- * - 调用 {@code mockMvc.perform(...).andExpect(request().asyncStarted()).andReturn()}
- * 触发异步处理,获取 {@code MvcResult};
- * - 再调用 {@code mockMvc.perform(asyncDispatch(mvcResult))} 完成派发,
- * 此时响应体才真正写入,可对 status / content 做断言。
- *
- * 旧版使用轮询 {@code getContentAsString()} 的方式无法获取到 {@code StreamingResponseBody}
- * 写入的内容,测试超时报错 "SSE 响应内容未按预期写入"。
+ * 3. StreamingResponseBody 不能用轮询 getContentAsString() 来读响应体。
+ * Spring 把实际写入分派到异步线程,MockMvc 必须用两步走:
+ * 先 perform + asyncStarted 拿到 MvcResult,再 perform(asyncDispatch(mvcResult)) 才能读到内容。
+ * 旧版轮询方式始终读到空,超时报错。
*/
@Import(OpenAiStreamControllerIntegrationTests.OpenAiTestConfiguration.class)
class OpenAiStreamControllerIntegrationTests extends AbstractWebIntegrationTest {
- /**
- * 验证已登录用户可以发起 SSE 流式请求,并收到 Vercel Stream 格式的响应。
- *
- * 采用 asyncDispatch 两步模式:先启动异步,再派发获取完整响应体。
- */
@Test
void streamReturnsSseEventsForAuthenticatedUser() throws Exception {
String token = loginAsAdmin();
@@ -73,18 +56,13 @@ void streamReturnsSseEventsForAuthenticatedUser() throws Exception {
.andExpect(request().asyncStarted())
.andReturn();
- // 第二步:触发异步派发,断言响应体包含 Vercel Stream 前导符 "0:" 和内容 "hello"
- // relayEvents() 从 choices[0].delta.content 提取文本,转换为 0:""\n 格式
+ // 第二步:触发异步派发,此时响应体才真正写入,relayEvents() 把内容转成 0:"hello"\n 格式
mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("0:")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("hello")));
}
- /**
- * 未携带 token 访问流式接口,SaInterceptor 在请求到达控制器前即触发 NOT_TOKEN 异常,
- * 返回 401 + "未提供 Token",不会进入异步处理流程。
- */
@Test
void streamRejectsAnonymousRequest() throws Exception {
mockMvc.perform(post("/openai/responses/stream")
@@ -99,10 +77,6 @@ void streamRejectsAnonymousRequest() throws Exception {
.andExpect(jsonPath("$.message").value("未提供 Token"));
}
- /**
- * messages 为空数组时,@NotEmpty 校验失败,返回 400 + 字段级错误消息。
- * 旧测试发送 {"message": ""} 并期望 "message: 消息不能为空",已按新 DTO 结构更新。
- */
@Test
void streamValidatesEmptyMessages() throws Exception {
String token = loginAsAdmin();
@@ -123,10 +97,7 @@ void streamValidatesEmptyMessages() throws Exception {
@TestConfiguration
static class OpenAiTestConfiguration {
- /**
- * 替换真实 {@link OpenAiStreamGateway},避免集成测试依赖外部 OpenAI 服务或 Mockito。
- * {@code @Primary} 确保此 Bean 在有多个同类型 Bean 时优先被注入。
- */
+ // 用 stub 替换真实 Gateway,避免集成测试依赖外部 OpenAI 服务
@Bean
@Primary
OpenAiStreamGateway openAiStreamGateway() {
@@ -136,20 +107,12 @@ OpenAiStreamGateway openAiStreamGateway() {
private static final class StubOpenAiStreamGateway implements OpenAiStreamGateway {
- /** 测试环境无需校验 OpenAI 配置(apiKey 等),直接放行。 */
@Override
public void validateConfiguration(OpenAiStreamRequest request) {
}
- /**
- * 返回一条符合 OpenAI SSE 协议格式的固定响应,供 {@code relayEvents()} 解析。
- *
- * {@code relayEvents()} 从 {@code choices[0].delta.content} 提取文本,
- * 转换为 {@code 0:"hello"\n} 写入输出流。
- * 旧版 stub 使用 {@code {"type":"...","delta":"..."}} 格式,
- * 与 OpenAI 实际格式不符,{@code relayEvents()} 无法找到 {@code choices} 节点,
- * 导致输出流为空,asyncDispatch 后 content 断言失败。
- */
+ // 必须用 OpenAI 实际的 SSE 格式,relayEvents() 从 choices[0].delta.content 读文本。
+ // 旧 stub 用的是自定义 {"type":"...","delta":"..."} 格式,relayEvents() 解析不到,输出为空。
@Override
public InputStream openStream(OpenAiStreamRequest request) {
return new ByteArrayInputStream("""
diff --git a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java
index 2cd53c8..c53b57c 100644
--- a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java
+++ b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java
@@ -16,36 +16,29 @@
/**
* Web 集成测试公共基类,提供 MockMvc 和预置登录辅助方法。
*
- * 为什么在 @SpringBootTest 中显式指定数据源属性?
- * Spring Boot 属性优先级从高到低依次为:
- *
- * - {@code @SpringBootTest(properties = {...})} — 最高
- * - 操作系统环境变量(如 {@code SPRING_DATASOURCE_URL})
- * - {@code application-test.properties} 等 Profile 配置文件 — 最低
- *
- * 测试服务器上的 {@code SPRING_DATASOURCE_URL} 环境变量指向生产 Neon PostgreSQL,
- * 其优先级高于 {@code application-test.properties} 中的 H2 配置,导致集成测试
- * 尝试连接 PostgreSQL,上下文启动失败,所有 Web 集成测试报错。
- * 通过在 {@code @SpringBootTest(properties)} 中覆盖数据源属性,可绕过环境变量,
- * 确保测试始终使用 H2 内存库。
+ * 为什么要在 @SpringBootTest 里显式覆盖数据源属性?
+ * Spring Boot 属性优先级:@SpringBootTest(properties) > 环境变量 > application-test.properties。
+ * 测试服务器上存在 SPRING_DATASOURCE_URL 环境变量,指向生产 Neon PostgreSQL,
+ * 它的优先级高于 application-test.properties 里的 H2 配置,
+ * 导致测试启动时直接去连 PostgreSQL,H2 驱动拒绝 jdbc:postgresql:// 格式,上下文崩掉。
+ * 把数据源写进 @SpringBootTest(properties) 就能盖过环境变量,保证测试始终跑 H2。
*
- * 为什么还要覆盖 JustAuth redirect-uri?
- * JustAuth 内部使用 Apache Commons {@code UrlValidator} 校验 redirect-uri 格式。
- * 默认情况下,{@code UrlValidator} 拒绝 {@code localhost} 域名(视为非法 URL),
- * 而 {@code application.properties} 中的默认值是 {@code http://localhost:3000/...}。
- * 若不覆盖,{@code OAuthController} 在构建 {@code AuthGithubRequest} 时会立即抛出
- * {@code AuthException: Illegal redirect uri},导致所有 OAuth 相关集成测试以 500 失败。
- * 测试环境仅需要一个格式合法的占位 URL,不会发起任何真实网络请求。
+ * 为什么还要覆盖 JustAuth redirect-uri?
+ * JustAuth 用 Apache Commons UrlValidator 校验 redirect-uri,
+ * 而 UrlValidator 默认不接受 localhost 域名。
+ * application.properties 里默认是 http://localhost:3000/...,
+ * 不覆盖的话 OAuthController 初始化 AuthGithubRequest 时直接抛 AuthException,
+ * 所有 OAuth 相关测试都会 500。换成格式合法的占位 URL 就好了,测试里不会真的发请求。
*/
@SpringBootTest(properties = {
- // --- 数据源:覆盖 SPRING_DATASOURCE_URL 环境变量,强制使用 H2 内存库 ---
+ // 覆盖 SPRING_DATASOURCE_URL 环境变量,强制使用 H2 内存库
"spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.sql.init.mode=always",
"spring.sql.init.schema-locations=classpath:test-schema.sql",
- // --- JustAuth:使用合法格式的占位 redirect-uri,规避 UrlValidator localhost 限制 ---
+ // JustAuth UrlValidator 不接受 localhost,用合法占位 URL 绕过
"justauth.type.github.redirect-uri=https://example.com/api/auth/callback/github",
"justauth.type.github.client-id=test-client-id",
"justauth.type.github.client-secret=test-client-secret"
diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java
index 8053a27..600dc90 100644
--- a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java
+++ b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java
@@ -12,22 +12,11 @@
/**
* AuthController 集成测试(账号密码登录、退出、当前用户查询)。
*
- * 历史测试失败原因(已修复)
- * 修复前,所有测试都报 "Driver claims to not accept jdbcUrl, jdbc:postgresql://...",
- * 根本原因是 {@code SPRING_DATASOURCE_URL} 环境变量覆盖了 {@code application-test.properties}
- * 中的 H2 配置,已通过 {@link com.involutionhell.backend.support.AbstractWebIntegrationTest}
- * 的 {@code @SpringBootTest(properties)} 解决。
- *
- * 匿名请求错误消息的变化("未登录..." → "未提供 Token")
- * 旧版测试断言 {@code "未登录或登录状态已失效"},此消息来自早期 GlobalExceptionHandler
- * 使用通用文案的版本。当前 GlobalExceptionHandler 对 {@code NotLoginException} 按场景值细分:
- *
- * - {@code NOT_TOKEN}(完全未携带 token)→ "未提供 Token"
- * - {@code INVALID_TOKEN}(token 格式非法)→ "Token 无效"
- * - {@code TOKEN_TIMEOUT} → "Token 已过期"
- * - …
- *
- * 匿名请求属于 {@code NOT_TOKEN} 场景,故正确消息为 "未提供 Token"。
+ * 旧测试断言的匿名请求错误消息是 "未登录或登录状态已失效",这是早期 GlobalExceptionHandler 的通用文案。
+ * 现在 GlobalExceptionHandler 对 NotLoginException 按场景值细分:
+ * 完全没带 token 是 NOT_TOKEN 场景,对应 "未提供 Token";
+ * token 格式非法是 INVALID_TOKEN,对应 "Token 无效";以此类推。
+ * 匿名请求属于 NOT_TOKEN,所以正确消息是 "未提供 Token"。
*/
class AuthControllerIntegrationTests extends AbstractWebIntegrationTest {
@@ -90,10 +79,7 @@ void meReturnsCurrentUserWhenLoggedIn() throws Exception {
.andExpect(jsonPath("$.data.permissions[0]").isNotEmpty());
}
- /**
- * 未携带任何 token 访问受保护接口,Sa-Token 抛出 NOT_TOKEN 场景的 NotLoginException,
- * GlobalExceptionHandler 将其映射为 "未提供 Token"(而非旧版通用文案"未登录或登录状态已失效")。
- */
+ // 未带 token 是 NOT_TOKEN 场景,GlobalExceptionHandler 返回 "未提供 Token"
@Test
void meRejectsAnonymousRequest() throws Exception {
mockMvc.perform(get("/auth/me"))
@@ -117,10 +103,7 @@ void logoutSucceedsAndMakesTokenInvalid() throws Exception {
.andExpect(jsonPath("$.success").value(false));
}
- /**
- * 匿名 POST /auth/logout,同样属于 NOT_TOKEN 场景,期望 "未提供 Token"。
- * 旧版测试使用通用消息,已更新为当前 GlobalExceptionHandler 的实际输出。
- */
+ // 同上,匿名 logout 也是 NOT_TOKEN 场景
@Test
void logoutRejectsAnonymousRequest() throws Exception {
mockMvc.perform(post("/auth/logout"))
diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java
index 39efd27..620aad9 100644
--- a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java
+++ b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java
@@ -13,34 +13,22 @@
/**
* UserCenterController + AuthController(/auth/me)集成测试。
*
- * URL 路径修正(旧:/api/user-center/* → 新:/users/* 和 /auth/me)
- * 旧版测试使用 {@code /api/user-center/profile}、{@code /api/user-center/users} 等路径,
- * 这些路径在重构中被移除(注释"context-path 已含 /api/v1,此处不再重复加 /api 前缀"说明
- * 服务曾有全局 context-path,后来去掉了)。当前实际映射为:
- *
- * - 当前用户信息 → {@code GET /auth/me}(AuthController)
- * - 用户列表 → {@code GET /users}(UserCenterController)
- * - 单个用户 → {@code GET /users/{id}}(UserCenterController)
- * - 更新权限 → {@code PUT /users/{id}/authorization}(UserCenterController)
- *
- * 旧路径不存在时,Spring 抛出 {@code NoResourceFoundException},被 GlobalExceptionHandler
- * 的 {@code handleUnexpected} 兜底捕获,返回 HTTP 500,导致测试全部失败。
+ * 旧测试用的 URL 是 /api/user-center/profile、/api/user-center/users 等,
+ * 这些路径在重构中已经删掉了(服务以前有全局 context-path,后来去掉了,前缀 /api 也跟着没了)。
+ * 现在的实际路由是:GET /auth/me、GET /users、GET /users/{id}、PUT /users/{id}/authorization。
+ * 旧路径访问时 Spring 抛 NoResourceFoundException,被 GlobalExceptionHandler 兜底返回 500,
+ * 所以所有测试都失败了。把 URL 改对就好了。
*
- * 权限错误消息修正(旧:"无权限访问:..." → 新:"拒绝访问: 缺少权限 [...]")
- * 旧版断言使用的消息与 GlobalExceptionHandler 实际输出不符。
- * 现在 GlobalExceptionHandler 输出 {@code "拒绝访问: 缺少权限 [<权限码>]"},
- * 测试消息已与之对齐。
+ * 权限错误消息也变了:旧测试期望 "无权限访问: user:center:read",
+ * 但 GlobalExceptionHandler 实际输出是 "拒绝访问: 缺少权限 [user:center:read]",对齐即可。
*
- * @SaCheckPermission 之前为何一直 403?
- * 项目缺少 {@code StpInterface} 实现,Sa-Token 回退使用空列表,
- * 导致所有权限校验恒定失败。已通过新增 {@code SaTokenPermissionImpl} 解决。
+ * @SaCheckPermission 之前一直 403 的原因:项目缺少 StpInterface 实现,
+ * Sa-Token 找不到实现 Bean 就用空列表兜底,所有权限校验必然失败。
+ * 新增 SaTokenPermissionImpl 后才真正把权限数据接进来。
*/
class UserCenterControllerIntegrationTests extends AbstractWebIntegrationTest {
- /**
- * 当前用户信息接口现在位于 AuthController(/auth/me),
- * 旧测试错误地访问了已不存在的 /api/user-center/profile。
- */
+ // 旧测试访问的 /api/user-center/profile 已不存在,现在是 /auth/me
@Test
void profileReturnsCurrentUserForAuthorizedUser() throws Exception {
String token = loginAsAlice();
@@ -51,10 +39,6 @@ void profileReturnsCurrentUserForAuthorizedUser() throws Exception {
.andExpect(jsonPath("$.data.username").value("alice"));
}
- /**
- * 管理员拥有 user:center:read 权限,可访问全量用户列表。
- * 旧 URL /api/user-center/users 不存在,已修正为 /users。
- */
@Test
void usersListReturnsAllUsersForAdmin() throws Exception {
String token = loginAsAdmin();
@@ -65,11 +49,8 @@ void usersListReturnsAllUsersForAdmin() throws Exception {
.andExpect(jsonPath("$.data.length()").value(3));
}
- /**
- * alice 仅有 user:profile:read,缺少 user:center:read,访问 /users 时 Sa-Token
- * 抛出 NotPermissionException,GlobalExceptionHandler 返回 "拒绝访问: 缺少权限 [user:center:read]"。
- * 旧测试期望的 "无权限访问: user:center:read" 是当时不同的错误消息格式,已对齐。
- */
+ // alice 只有 user:profile:read,没有 user:center:read,访问 /users 会被拦截
+ // 旧测试期望的消息 "无权限访问: ..." 和 GlobalExceptionHandler 实际输出不一致,已修正
@Test
void usersListRejectsUserWithoutReadPermission() throws Exception {
String token = loginAsAlice();
@@ -80,10 +61,6 @@ void usersListRejectsUserWithoutReadPermission() throws Exception {
.andExpect(jsonPath("$.message").value("拒绝访问: 缺少权限 [user:center:read]"));
}
- /**
- * 审计员拥有 user:profile:read,可查询单个用户详情。
- * 旧 URL /api/user-center/users/2 已修正为 /users/2。
- */
@Test
void getUserReturnsRequestedUserForAuditor() throws Exception {
String token = loginAsAuditor();
@@ -116,12 +93,7 @@ void getUserReturnsBusinessErrorWhenUserMissing() throws Exception {
.andExpect(jsonPath("$.message").value("用户不存在: 999"));
}
- /**
- * 管理员成功更新 alice(id=2)的角色与权限后,再次查询验证持久化结果。
- *
- * {@code @DirtiesContext} 在方法执行后重置 Spring 上下文(含 H2 数据库),
- * 防止本测试对 alice 的修改影响同一进程内后续测试的预期数据。
- */
+ // @DirtiesContext 确保本测试对 alice 的修改不会污染其他测试的预期数据
@Test
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
void updateAuthorizationAllowsAdmin() throws Exception {
@@ -164,10 +136,6 @@ void updateAuthorizationRejectsAnonymousRequest() throws Exception {
.andExpect(jsonPath("$.success").value(false));
}
- /**
- * alice 缺少 user:center:manage,更新权限时被 @SaCheckPermission 拦截,返回 403。
- * 消息格式已从旧版 "无权限访问: ..." 对齐为 GlobalExceptionHandler 当前输出格式。
- */
@Test
void updateAuthorizationRejectsUserWithoutManagePermission() throws Exception {
String token = loginAsAlice();