Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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 权限与角色加载实现。
*
* 项目原来缺少 StpInterface 实现,Sa-Token 找不到实现 Bean 时会回退到默认的空列表,
* 导致所有 @SaCheckPermission / @SaCheckRole 注解永远校验失败(403),
* 不管数据库里给用户配了什么权限都没用。这是个生产 Bug,加上这个类才算把权限体系真正接通。
*
* 关于 loginId 类型:Sa-Token 内部把登录 ID 序列化成 String 存储,
* 即使调用 StpUtil.login(Long) 传入的是 Long,回调这里时运行时类型也是 String,
* 所以不能直接强转,要先 toString() 再 Long.valueOf()。
*/
@Component
public class SaTokenPermissionImpl implements StpInterface {

private final UserAccountRepository userAccountRepository;

public SaTokenPermissionImpl(UserAccountRepository userAccountRepository) {
this.userAccountRepository = userAccountRepository;
}

/**
* 返回用户拥有的权限码列表,Sa-Token 执行 @SaCheckPermission 时会调用此方法。
*
* @param loginId 登录 ID,运行时实际类型是 String,不是 Long
* @param loginType 登录类型,单端场景下为 "login",此处忽略
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// Sa-Token 回传的 loginId 是 String,必须先 toString() 再转 Long
return userAccountRepository.findById(Long.valueOf(loginId.toString()))
.map(account -> List.copyOf(account.permissions()))
.orElse(List.of());
}

/**
* 返回用户拥有的角色列表,供 @SaCheckRole 使用,逻辑和 getPermissionList 对称。
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
return userAccountRepository.findById(Long.valueOf(loginId.toString()))
.map(account -> List.copyOf(account.roles()))
.orElse(List.of());
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,12 +41,17 @@ public ResponseEntity<ApiResponse<Void>> handleNotLoginException(NotLoginExcepti
}

/**
* Sa-Token: 拦截权限不足异常
* Sa-Token: 拦截权限不足异常。
*
* 注意要用 e.getPermission() 而不是 e.getCode()。
* getCode() 是父类 SaTokenException 的方法,返回的是整数场景码(比如 -1),
* 而权限字符串(比如 "user:center:read")在 NotPermissionException 自己的 permission 字段里,
* 要调 getPermission() 才能拿到。用错了的话错误消息会变成 "拒绝访问: 缺少权限 [-1]",没有任何意义。
*/
@ExceptionHandler(NotPermissionException.class)
public ResponseEntity<ApiResponse<Void>> handleNotPermissionException(NotPermissionException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.fail("拒绝访问: 缺少权限 [" + e.getCode() + "]"));
.body(ApiResponse.fail("拒绝访问: 缺少权限 [" + e.getPermission() + "]"));
}

/**
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,13 +4,36 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest
/**
* Spring Boot 上下文加载冒烟测试。
*
* 原来用的是裸 @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://...
*
* @SpringBootTest(properties) 的优先级比环境变量还高,所以在这里覆盖就能保证始终跑 H2。
*
* JustAuth 那几个属性也是同理:JustAuth 用 Apache Commons UrlValidator 校验 redirect-uri,
* 它不接受 localhost,不覆盖的话 AuthGithubRequest 初始化直接抛异常。
*/
@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 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"
})
@ActiveProfiles("test")
class BackendApplicationTests {

/**
* 验证 Spring Boot 测试上下文可以正常启动。
*/
@Test
void contextLoads() {
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,66 @@
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;

/**
* OpenAiStreamController 集成测试。
*
* 旧测试有三个问题:
*
* 1. 请求体字段写错了。DTO 已从单字段 message 改为多轮对话列表 messages,
* 旧测试还发 {"message":"..."}, 服务端 messages 为空,直接 400 校验失败,根本进不了 SSE 流程。
*
* 2. 匿名请求的期望消息过时了。未带 token 是 Sa-Token NOT_TOKEN 场景,
* GlobalExceptionHandler 现在返回 "未提供 Token",不是旧版的通用文案。
*
* 3. StreamingResponseBody 不能用轮询 getContentAsString() 来读响应体。
* Spring 把实际写入分派到异步线程,MockMvc 必须用两步走:
* 先 perform + asyncStarted 拿到 MvcResult,再 perform(asyncDispatch(mvcResult)) 才能读到内容。
* 旧版轮询方式始终读到空,超时报错。
*/
@Import(OpenAiStreamControllerIntegrationTests.OpenAiTestConfiguration.class)
class OpenAiStreamControllerIntegrationTests extends AbstractWebIntegrationTest {

@Test
void streamReturnsSseEventsForAuthenticatedUser() throws Exception {
String token = loginAsAdmin();

// 第一步:发起请求,确认异步处理已启动(StreamingResponseBody 异步写入)
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() 把内容转成 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
Expand All@@ -52,50 +69,35 @@ 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")
.header("satoken", token)
.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
static class OpenAiTestConfiguration {

/**
* 提供一个稳定的测试桩网关,避免控制器测试依赖真实 OpenAI 或 Mockito。
*/
// 用 stub 替换真实 Gateway,避免集成测试依赖外部 OpenAI 服务
@Bean
@Primary
OpenAiStreamGateway openAiStreamGateway() {
Expand All@@ -105,22 +107,18 @@ OpenAiStreamGateway openAiStreamGateway() {

private static final class StubOpenAiStreamGateway implements OpenAiStreamGateway {

/**
* 测试环境下跳过外部 OpenAI 配置校验。
*/
@Override
public void validateConfiguration(OpenAiStreamRequest request) {
}

/**
* 返回固定的 SSE 事件流,供控制器测试验证输出格式。
*/
// 必须用 OpenAI 实际的 SSE 格式,relayEvents() 从 choices[0].delta.content 读文本。
// 旧 stub 用的是自定义 {"type":"...","delta":"..."} 格式,relayEvents() 解析不到,输出为空。
@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));
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,36 @@
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

@SpringBootTest
/**
* Web 集成测试公共基类,提供 MockMvc 和预置登录辅助方法。
*
* 为什么要在 @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 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=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"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
public abstract class AbstractWebIntegrationTest {
Expand All@@ -22,7 +51,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")
Expand All@@ -40,23 +69,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");
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,15 @@
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

/**
* AuthController 集成测试(账号密码登录、退出、当前用户查询)。
*
* 旧测试断言的匿名请求错误消息是 "未登录或登录状态已失效",这是早期 GlobalExceptionHandler 的通用文案。
* 现在 GlobalExceptionHandler 对 NotLoginException 按场景值细分:
* 完全没带 token 是 NOT_TOKEN 场景,对应 "未提供 Token";
* token 格式非法是 INVALID_TOKEN,对应 "Token 无效";以此类推。
* 匿名请求属于 NOT_TOKEN,所以正确消息是 "未提供 Token"。
*/
class AuthControllerIntegrationTests extends AbstractWebIntegrationTest {

@Test
Expand DownExpand Up@@ -70,12 +79,13 @@ void meReturnsCurrentUserWhenLoggedIn() throws Exception {
.andExpect(jsonPath("$.data.permissions[0]").isNotEmpty());
}

// 未带 token 是 NOT_TOKEN 场景,GlobalExceptionHandler 返回 "未提供 Token"
@Test
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
Expand All@@ -87,16 +97,18 @@ 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));
}

// 同上,匿名 logout 也是 NOT_TOKEN 场景
@Test
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"));
}
}
Loading