diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fe6c7bb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.properties] +charset = utf-8 diff --git a/.env.example b/.env.example index a800616..1609ded 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,8 @@ PGSSLMODE=require # Spring Boot JDBC 连接(由上面的 PG 变量转换而来) SPRING_DATASOURCE_URL=jdbc:postgresql://ep-xxxx.ap-southeast-2.aws.neon.tech/neondb?sslmode=require -SPRING_SQL_INIT_MODE=always +# 首次部署时设为 always 以初始化 schema.sql,之后改为 never +SPRING_SQL_INIT_MODE=never # --- 本地开发用 Docker PostgreSQL(无 Neon 账号的开发者使用)--- POSTGRES_DB=involution_hell @@ -28,11 +29,15 @@ OPENAI_MODEL=gpt-4.1 # --- 应用基本设置 --- SPRING_APPLICATION_NAME=backend SERVER_PORT=8080 +# 前端域名,用于 GitHub OAuth 回调重定向和 Sa-Token 会话重定向 +# 生产环境必须设置为实际域名,否则登录后会重定向到 localhost +AUTH_URL=https://involutionhell.com # --- Actuator --- MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE=health,info,metrics MANAGEMENT_ENDPOINT_HEALTH_PROBES_ENABLED=true -MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS=always +# when-authorized 生产推荐;开发时可改为 always +MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS=when-authorized # --- 网关(Caddy)--- CADDY_HTTP_PORT=80 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cc30acd..01ab443 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,7 +47,7 @@ jobs: # 等待服务启动,最多 60 秒 echo "等待服务健康检查..." for i in $(seq 1 12); do - if docker exec involution-hell-backend curl -fsS "http://127.0.0.1:8080/api/v1/actuator/health" | grep -q '"status":"UP"'; then + if docker exec involution-hell-backend curl -fsS "http://127.0.0.1:8080/actuator/health" | grep -q '"status":"UP"'; then echo "✅ 部署成功,服务正常运行" exit 0 fi diff --git a/Caddyfile b/Caddyfile index 2778bfa..d22b24d 100644 --- a/Caddyfile +++ b/Caddyfile @@ -3,6 +3,6 @@ {$CADDY_SITE_ADDRESS::80} { # 将所有请求转发至后端 Spring Boot 服务 reverse_proxy {$CADDY_UPSTREAM:backend:8080} { - health_uri /api/v1/actuator/health + health_uri /actuator/health } } diff --git a/README.md b/README.md index 23c8cda..d6cac6f 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ docker compose up -d postgres redis ./mvnw spring-boot:run ``` -默认接口入口:`http://127.0.0.1:8080/api/v1` +默认接口入口:`http://127.0.0.1:8080` ### 4. 调用示例接口 内置种子账号: @@ -104,7 +104,7 @@ docker compose up -d postgres redis 登录示例: ```bash -curl -X POST http://127.0.0.1:8080/api/auth/login \ +curl -X POST http://127.0.0.1:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"username": "admin", "password": "Admin@123456"}' ``` @@ -136,7 +136,7 @@ push to main **生产环境健康检查端点:** ``` -GET https://api.involutionhell.com/api/v1/actuator/health +GET https://api.involutionhell.com/actuator/health ``` 返回 `{"status":"UP"}` 表示服务正常运行。 diff --git a/docker-compose.yml b/docker-compose.yml index e436f16..b7adcbb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: expose: - "${SERVER_PORT:-8080}" healthcheck: - test: ["CMD-SHELL", "curl -fsS \"http://127.0.0.1:${SERVER_PORT:-8080}/api/v1/actuator/health\" | grep '\"status\":\"UP\"' >/dev/null || exit 1"] + test: ["CMD-SHELL", "curl -fsS \"http://127.0.0.1:${SERVER_PORT:-8080}/actuator/health\" | grep '\"status\":\"UP\"' >/dev/null || exit 1"] interval: 30s timeout: 5s retries: 3 diff --git a/docs/dev1.md b/docs/dev1.md new file mode 100644 index 0000000..c2799d1 --- /dev/null +++ b/docs/dev1.md @@ -0,0 +1,92 @@ +# 开发参考手册 + +## API 端点速查 + +| 说明 | 路径 | +|------|------| +| 后端监听 | `http://localhost:8080` | +| 发起 GitHub 登录(浏览器直跳) | `http://localhost:8080/oauth/render/github` | +| GitHub 回调(经 Next.js rewrite 代理) | `localhost:3000/api/auth/callback/github` → `localhost:8080/api/auth/callback/github` | +| 获取当前用户 | `GET /auth/me`(需 `satoken` header) | +| 退出登录 | `POST /auth/logout`(需 `satoken` header) | +| 健康检查 | `GET /actuator/health` | + +--- + +## 用户 ID 体系说明 + +项目中存在**两套独立的用户 ID**,混淆会导致数据关联错误: + +| 表 | 主键类型 | 管理方 | 说明 | +|----|---------|--------|------| +| `users` | `Int`(自增) | Prisma / NextAuth(已迁移,不再写入) | 遗留表,历史数据保留 | +| `user_accounts` | `BigInt`(自增) | Spring Boot / Sa-Token | **当前有效用户体系** | + +- `Chat.userId` 和 `AnalyticsEvent.userId` 均为 `BigInt`,对应 `user_accounts.id` +- `doc_contributors.github_id` 为 `BigInt`,对应 `user_accounts.github_id`(GitHub 数字用户 ID) +- 前端 `UserView.id` 使用 TypeScript `number`,安全范围内(≤ 2^53) + +--- + +## 前端服务端身份验证 + +前端 Next.js API Route 通过 `lib/server-auth.ts` 中的 `resolveUserId()` 验证用户: + +``` +请求携带 x-satoken header + → Next.js API Route 调用 resolveUserId(req) + → 服务端向后端 GET /auth/me 发起请求(BACKEND_URL 环境变量) + → 返回 user_accounts.id(BigInt)或 null(匿名) +``` + +**使用方:** +- `frontend/app/api/chat/route.ts` — 保存 Chat 记录时关联用户 +- `frontend/app/api/analytics/route.ts` — 保存 AnalyticsEvent 时关联用户 + +不要在 `resolveUserId` 以外的地方重新实现这段逻辑。 + +--- + +## 前后端职责分工现状(2026-03-29) + +### 已迁移到后端 + +| 功能 | 迁移前 | 迁移后 | +|------|--------|--------| +| GitHub OAuth 登录 | NextAuth(前端) | JustAuth + Sa-Token(后端) | +| 会话管理 | NextAuth Session / Prisma `sessions` | Sa-Token `user_accounts` | +| 用户数据 | Prisma `users` 表 | `user_accounts` 表 | + +### 前端 API Route 现状 + +| 路由 | 说明 | 状态 | +|------|------|------| +| `api/chat` | AI 对话,优先代理到后端 `/openai/responses/stream`,失败时 fallback 本地推理 | ⚠️ AI Key 仍分散在前后端,待统一 | +| `api/analytics` | 埋点写 Neon | 暂留前端,功能自洽 | +| `api/upload` | 上传到 Cloudflare R2 | 暂留前端,功能自洽 | +| `api/suggestions` | AI 生成建议问题 | 暂留前端 | +| `api/docs-tree` | Fumadocs 文档导航树 | 不迁移,Fumadocs 专属 | +| `api/indexnow` | SEO ping | 不迁移,构建侧逻辑 | + +### TODO:Chat AI Key 统一 + +**现状:** 前端 `/api/chat` 已优先尝试代理到后端 `/openai/responses/stream`(5s 超时),失败时 fallback 到本地 Vercel AI SDK 推理。代理路径已打通,但 fallback 仍依赖前端自己的 AI Key 和模型配置。 + +**剩余工作:** 确认后端 `/openai/responses/stream` 稳定后,删除前端 fallback 逻辑,移除前端侧 AI Key 配置,AI 推理完全由后端负责。 + +**优先级:** 低,待后端 AI 接口稳定后处理。 + +--- + +## Sa-Token 会话流程 + +``` +用户点击登录 + → 前端跳转 /oauth/render/github(后端直接重定向 GitHub) + → GitHub 回调 /api/auth/callback/github(经 Next.js rewrite 转发给后端) + → 后端 JustAuth 解析 AuthUser,查找或创建 user_accounts 记录 + → StpUtil.login(userId) 建立 Sa-Token 会话 + → 后端重定向到前端首页,URL 携带 ?token=xxx + → 前端 AuthProvider 读取 token 存入 localStorage,清除 URL 参数 + → 后续请求通过 x-satoken header 或 satoken header 传递 token +``` diff --git a/pom.xml b/pom.xml index 8388e8b..faa1f72 100644 --- a/pom.xml +++ b/pom.xml @@ -61,7 +61,8 @@ spring-boot-starter-jdbc - + + + + + + me.zhyd.oauth + JustAuth + 1.16.6 + + + cn.hutool + hutool-http + 5.8.25 + + + + cn.hutool + hutool-json + 5.8.25 + @@ -82,6 +102,13 @@ runtime + + + cn.dev33 + sa-token-spring-boot3-starter + 1.39.0 + + diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java new file mode 100644 index 0000000..9becfa3 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java @@ -0,0 +1,28 @@ +package com.involutionhell.backend.common.config; + +import cn.dev33.satoken.interceptor.SaInterceptor; +import cn.dev33.satoken.router.SaRouter; +import cn.dev33.satoken.stp.StpUtil; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class SaTokenConfigure implements WebMvcConfigurer { + + // 注册 SaToken 拦截器 + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 注册 SaToken 拦截器,定义详细认证规则 + registry.addInterceptor(new SaInterceptor(handler -> { + // 拦截规则配置 + SaRouter + .match("/**") // 拦截所有路由 + .notMatch("/auth/login") // 账号密码登录 + .notMatch("/auth/register") // 注册 + .notMatch("/oauth/render/github") // GitHub OAuth 授权发起 + .notMatch("/api/auth/callback/github") // GitHub OAuth 回调(路径与 OAuth App 注册保持一致) + .check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException + })).addPathPatterns("/**"); + } +} \ No newline at end of file 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..2bdf707 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenPermissionImpl.java @@ -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 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 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 a0f1421..0671a09 100644 --- a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java @@ -1,7 +1,8 @@ package com.involutionhell.backend.common.error; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.InsufficientAuthenticationException; +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.exception.NotPermissionException; +import cn.dev33.satoken.exception.NotRoleException; import com.involutionhell.backend.common.api.ApiResponse; import jakarta.validation.ConstraintViolationException; import java.util.Optional; @@ -15,30 +16,57 @@ @RestControllerAdvice public class GlobalExceptionHandler { + // ========================================== + // Sa-Token 异常拦截 + // ========================================== + /** - * 将未登录异常转换为 401 响应 - */ - /** - * 将认证不足异常转换为 401 响应 + * Sa-Token: 拦截未登录异常 */ - @ExceptionHandler(InsufficientAuthenticationException.class) - public ResponseEntity> handleAuthentication(InsufficientAuthenticationException exception) { + @ExceptionHandler(NotLoginException.class) + public ResponseEntity> handleNotLoginException(NotLoginException e) { + // 判断场景值,定制化异常信息 + String message = switch (e.getType()) { + case NotLoginException.NOT_TOKEN -> "未提供 Token"; + case NotLoginException.INVALID_TOKEN -> "Token 无效"; + case NotLoginException.TOKEN_TIMEOUT -> "Token 已过期"; + case NotLoginException.BE_REPLACED -> "Token 已被顶下线"; + case NotLoginException.KICK_OUT -> "Token 已被踢下线"; + case NotLoginException.TOKEN_FREEZE -> "Token 已被冻结"; + case NotLoginException.NO_PREFIX -> "未按照指定前缀提交 Token"; + default -> "当前会话未登录"; + }; return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(ApiResponse.fail("未登录或登录状态已失效")); + .body(ApiResponse.fail(message)); } /** - * 将权限不足异常转换为 403 响应 + * Sa-Token: 拦截权限不足异常。 + * + * 注意要用 e.getPermission() 而不是 e.getCode()。 + * getCode() 是父类 SaTokenException 的方法,返回的是整数场景码(比如 -1), + * 而权限字符串(比如 "user:center:read")在 NotPermissionException 自己的 permission 字段里, + * 要调 getPermission() 才能拿到。用错了的话错误消息会变成 "拒绝访问: 缺少权限 [-1]",没有任何意义。 */ + @ExceptionHandler(NotPermissionException.class) + public ResponseEntity> handleNotPermissionException(NotPermissionException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.fail("拒绝访问: 缺少权限 [" + e.getPermission() + "]")); + } + /** - * 将权限不足异常转换为 403 响应 (Spring Security 版) + * Sa-Token: 拦截角色不足异常 */ - @ExceptionHandler(AccessDeniedException.class) - public ResponseEntity> handleAccessDenied(AccessDeniedException exception) { + @ExceptionHandler(NotRoleException.class) + public ResponseEntity> handleNotRoleException(NotRoleException e) { return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(ApiResponse.fail("拒绝访问: 权限不足")); + .body(ApiResponse.fail("拒绝访问: 缺少角色 [" + e.getRole() + "]")); } + // ========================================== + // 业务与通用异常拦截 + // ========================================== + /** * 将参数校验异常转换为 400 响应 */ @@ -65,6 +93,7 @@ public ResponseEntity> handleBusiness(Exception exception) { */ @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpected(Exception exception) { + exception.printStackTrace(); // 建议在开发阶段打印堆栈,生产环境应使用日志框架 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ApiResponse.fail("服务器内部错误")); } @@ -91,4 +120,4 @@ private String resolveValidationMessage(Exception exception) { } return "请求参数不合法"; } -} +} \ No newline at end of file diff --git a/src/main/java/com/involutionhell/backend/openai/controller/OpenAiStreamController.java b/src/main/java/com/involutionhell/backend/openai/controller/OpenAiStreamController.java index 654a62c..f26cc9d 100644 --- a/src/main/java/com/involutionhell/backend/openai/controller/OpenAiStreamController.java +++ b/src/main/java/com/involutionhell/backend/openai/controller/OpenAiStreamController.java @@ -1,39 +1,53 @@ package com.involutionhell.backend.openai.controller; -import org.springframework.security.access.prepost.PreAuthorize; +import cn.dev33.satoken.annotation.SaCheckLogin; import com.involutionhell.backend.openai.dto.OpenAiStreamRequest; import com.involutionhell.backend.openai.service.OpenAiStreamService; import jakarta.validation.Valid; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; @RestController -@RequestMapping("/api/openai") +@RequestMapping("/openai") public class OpenAiStreamController { private final OpenAiStreamService openAiStreamService; - /** - * 创建 OpenAI 流式控制器并注入流式服务。 - */ public OpenAiStreamController(OpenAiStreamService openAiStreamService) { this.openAiStreamService = openAiStreamService; } /** - * 调用 OpenAI Responses API 并以 SSE 形式持续推送模型输出。 + * 流式对话核心路由,供前端 Vercel SDK 直接转发。 + * 采用 TEXT_PLAIN_VALUE 返回纯文本流,抛弃了 Spring 的 SseEmitter,以完全符合 Vercel Stream Data + * 协议要求。 */ - @PreAuthorize("isAuthenticated()") - @PostMapping( - path = "/responses/stream", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.TEXT_EVENT_STREAM_VALUE - ) - public SseEmitter streamResponses(@Valid @RequestBody OpenAiStreamRequest request) { - return openAiStreamService.stream(request); + @SaCheckLogin + @PostMapping(path = "/responses/stream", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity streamResponses(@Valid @RequestBody OpenAiStreamRequest request) { + /* + * ============================ + * 🗑️ 被废弃的旧版方法声明留痕: + * public SseEmitter streamResponses(@Valid @RequestBody OpenAiStreamRequest + * request) { + * return openAiStreamService.stream(request); + * } + * 上文旧版的错误:返回 SseEmitter 导致了在流失推送中总是多出 Vercel 数据不认识的 `data: ` 和 `event: ` 包裹层。 + * ============================ + */ + + // 利用 StreamingResponseBody 避免额外的协议前缀干扰,实现裸文字流打字输出 + StreamingResponseBody stream = out -> { + openAiStreamService.streamToOutputStream(request, out); + }; + return ResponseEntity.ok() + // 专门显式告知 Vercel AI SDK:这是最新一代的支持 Stream-Data 的端点。 + .header("X-Experimental-Stream-Data", "true") + .body(stream); } -} +} \ No newline at end of file diff --git a/src/main/java/com/involutionhell/backend/openai/dto/OpenAiStreamRequest.java b/src/main/java/com/involutionhell/backend/openai/dto/OpenAiStreamRequest.java index 7172e5e..6723bbb 100644 --- a/src/main/java/com/involutionhell/backend/openai/dto/OpenAiStreamRequest.java +++ b/src/main/java/com/involutionhell/backend/openai/dto/OpenAiStreamRequest.java @@ -1,11 +1,31 @@ package com.involutionhell.backend.openai.dto; -import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +/** + * 接收来自前端的 OpenAI 对话请求体。 + * 格式与 Vercel AI SDK 默认发送的 payload ({"messages": [...]}) 保持高度一致。 + */ public record OpenAiStreamRequest( - @NotBlank(message = "消息不能为空") - String message, - String instructions, - String model -) { + /* + * ============================ + * 🗑️ 被废弃的旧版结构留痕(由于它引发了参数名寻找报错): + * + * @NotBlank(message = "消息不能为空") String message, + * String instructions, + * ============================ + */ + + // 接收完整的多轮对话记录,赋能 AI 完整的上下文记忆能力 + @NotEmpty(message = "对话历史不能为空") List messages, + // 可选的模型设定,由于保留了该参数,前端传参优先级最高 + String model) { + /** + * 单条对话消息的结构体。 + * role: 例如 "user" (用户) 或者 "assistant" (模型) + * content: 实际的消息文本 + */ + public record Message(String role, String content) { + } } diff --git a/src/main/java/com/involutionhell/backend/openai/service/HttpOpenAiStreamGateway.java b/src/main/java/com/involutionhell/backend/openai/service/HttpOpenAiStreamGateway.java index 23ff4cc..a9e8215 100644 --- a/src/main/java/com/involutionhell/backend/openai/service/HttpOpenAiStreamGateway.java +++ b/src/main/java/com/involutionhell/backend/openai/service/HttpOpenAiStreamGateway.java @@ -13,6 +13,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import tools.jackson.databind.ObjectMapper; @@ -20,6 +23,8 @@ @Service public class HttpOpenAiStreamGateway implements OpenAiStreamGateway { + private static final Logger log = LoggerFactory.getLogger(HttpOpenAiStreamGateway.class); + private final HttpClient httpClient; private final ObjectMapper objectMapper; private final OpenAiProperties properties; @@ -30,13 +35,19 @@ public class HttpOpenAiStreamGateway implements OpenAiStreamGateway { public HttpOpenAiStreamGateway( HttpClient httpClient, ObjectMapper objectMapper, - OpenAiProperties properties - ) { + OpenAiProperties properties) { this.httpClient = httpClient; this.objectMapper = objectMapper; this.properties = properties; } + @PostConstruct + void warnIfNotConfigured() { + if (!StringUtils.hasText(properties.apiKey())) { + log.warn("[OpenAI] OPENAI_API_KEY 未配置,/openai/responses/stream 调用时将返回 400 错误"); + } + } + /** * 校验 OpenAI 地址、密钥和模型配置是否满足调用要求。 */ @@ -64,39 +75,60 @@ public InputStream openStream(OpenAiStreamRequest request) throws IOException, I if (response.statusCode() < 200 || response.statusCode() >= 300) { try (InputStream errorStream = response.body()) { throw new IllegalStateException( - "OpenAI 调用失败: HTTP " + response.statusCode() + " - " + abbreviateErrorBody(errorStream) - ); + "OpenAI 调用失败: HTTP " + response.statusCode() + " - " + abbreviateErrorBody(errorStream)); } } return response.body(); } /** - * 按照 OpenAI Responses API 请求格式构造流式请求体。 + * 按照 OpenAI 标准的 /chat/completions 格式构造全套流式请求体。 + * 直接透传完整的 messages 数组,实现真正意义上的上下文多轮理解。 */ String buildRequestBody(OpenAiStreamRequest request) { Map requestBody = new LinkedHashMap<>(); requestBody.put("model", resolveModel(request)); - requestBody.put("stream", true); - requestBody.put("input", List.of(Map.of( - "role", "user", - "content", List.of(Map.of( - "type", "input_text", - "text", request.message() - )) - ))); - - if (StringUtils.hasText(request.instructions())) { - requestBody.put("instructions", request.instructions()); + requestBody.put("stream", true); // 指定官方进行实时切片下发 + + /* + * ============================ + * 🗑️ 被废弃的畸形伪造格式旧码: + * requestBody.put("input", List.of(Map.of("role", "user", "content", ... 嵌套 ... + * ))); + * if (StringUtils.hasText(request.instructions())) { + * requestBody.put("instructions", request.instructions()); + * } + * ============================ + */ + + requestBody.put("messages", request.messages()); + + try { + return objectMapper.writeValueAsString(requestBody); + } catch (Exception e) { + throw new IllegalStateException("请求序列化失败", e); } - return objectMapper.writeValueAsString(requestBody); } /** - * 构造访问 OpenAI Responses API 的 HTTP 请求。 + * 构造发往远端(或中转代理)大模型的真实 HTTP 报文。 */ HttpRequest buildHttpRequest(String requestBody) { - return HttpRequest.newBuilder(URI.create(properties.apiUrl())) + String apiUrl = properties.apiUrl(); + // 自动容错:如果环境变量里仅配置了根基地址,自动帮忙拼接聊天补全入口。 + if (!apiUrl.endsWith("/chat/completions")) { + apiUrl = apiUrl.replaceAll("/+$", "") + "/chat/completions"; + } + + /* + * ============================ + * 🗑️ 被废弃的基础发包旧路径(因强依赖 properties 没有保护校验): + * return HttpRequest.newBuilder(URI.create(properties.apiUrl())) + * ============================ + */ + + return HttpRequest.newBuilder(URI.create(apiUrl)) + // 重点保护区:只有 Java 这里碰到了真实密钥。 .header("Authorization", "Bearer " + properties.apiKey()) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") diff --git a/src/main/java/com/involutionhell/backend/openai/service/OpenAiStreamService.java b/src/main/java/com/involutionhell/backend/openai/service/OpenAiStreamService.java index 3c40ab3..d41a8bb 100644 --- a/src/main/java/com/involutionhell/backend/openai/service/OpenAiStreamService.java +++ b/src/main/java/com/involutionhell/backend/openai/service/OpenAiStreamService.java @@ -5,12 +5,10 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; -import java.util.Map; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -20,177 +18,97 @@ public class OpenAiStreamService { private final OpenAiStreamGateway gateway; private final ObjectMapper objectMapper; - /** - * 创建 OpenAI 流式服务并注入上游网关和 JSON 工具。 - */ public OpenAiStreamService(OpenAiStreamGateway gateway, ObjectMapper objectMapper) { this.gateway = gateway; this.objectMapper = objectMapper; } /** - * 创建一个新的 SSE 推送通道并异步转发 OpenAI 的流式响应。 + * 开启上游请求并把结果实时桥接至指定的 HTTP 响应输出流。 */ - public SseEmitter stream(OpenAiStreamRequest request) { - gateway.validateConfiguration(request); - - SseEmitter emitter = new SseEmitter(0L); - OpenAiEventSink sink = new SseEmitterOpenAiEventSink(emitter); - Thread.ofVirtual() - .name("openai-sse-", 0) - .start(() -> streamToSink(request, sink)); - return emitter; - } + public void streamToOutputStream(OpenAiStreamRequest request, OutputStream outputStream) { + /* + * ============================ + * 🗑️ 旧版本 SseEmitter 打包人逻辑(直接导致 Vercel 不认字): + * public SseEmitter stream(OpenAiStreamRequest request) { ... + * SseEmitter emitter = new SseEmitter(0L); + * OpenAiEventSink sink = new SseEmitterOpenAiEventSink(emitter); + * ... 开启 VirtualThread 扔到后台去 dispatchEvent() + * return emitter; + * ============================ + */ - /** - * 将单次 OpenAI 流式请求转发到指定事件下游。 - */ - void streamToSink(OpenAiStreamRequest request, OpenAiEventSink sink) { + gateway.validateConfiguration(request); try (InputStream inputStream = gateway.openStream(request)) { - relayEvents(inputStream, sink); - sink.complete(); + relayEvents(inputStream, outputStream); } catch (Exception exception) { - handleStreamError(exception, sink); + handleStreamError(exception, outputStream); } } /** - * 解析 OpenAI 返回的 SSE 文本流并逐条转发给前端。 + * 【重构核心】:这是拦截并转译第三方生硬大模型 JSON 的“文字切割手术台”。 + * 该方法提取有价值的短文本切片,将其打包为 Vercel 能识别的前置格式。 */ - void relayEvents(InputStream inputStream, OpenAiEventSink sink) throws IOException { + void relayEvents(InputStream inputStream, OutputStream outputStream) throws IOException { + /* + * ============================ + * 🗑️ 旧版纯粹转发、不对核心做精洗的代码路线: + * String currentEventName = null; + * StringBuilder currentData = new StringBuilder(); + * while ((line = reader.readLine()) != null) { ... 只要遇到 data 就全部原样拼接通过 + * EventEmitter 发送 ... } + * ============================ + */ + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { String line; - String currentEventName = null; - StringBuilder currentData = new StringBuilder(); - while ((line = reader.readLine()) != null) { - if (line.isBlank()) { - if (dispatchEvent(currentEventName, currentData, sink)) { - return; + // 第三方接口返回的标准特征点都是以 "data: " 开头 + if (line.startsWith("data: ")) { + String payload = line.substring(6).trim(); + + // "[DONE]" 形态意味着大模型生成句号完毕,立刻跳出断开连接 + if ("[DONE]".equals(payload)) { + break; } - currentEventName = null; - currentData.setLength(0); - continue; - } - if (line.startsWith(":")) { - continue; - } - if (line.startsWith("event:")) { - currentEventName = line.substring("event:".length()).trim(); - continue; - } - if (line.startsWith("data:")) { - if (currentData.length() > 0) { - currentData.append('\n'); + try { + JsonNode jsonNode = objectMapper.readTree(payload); + + // 深入臃肿的大树内部:直接去 choices 第一组的 delta 里面寻找关键节点唯一的 content + JsonNode deltaNode = jsonNode.path("choices").path(0).path("delta").path("content"); + + // 并不是所有的 JSON 都有字(第一包可能只包含角色分配) + if (!deltaNode.isMissingNode() && deltaNode.isTextual()) { + String textChunk = deltaNode.asText(); + // ★ 最为关键的协议转译:对原生纯文本加上 Vercel Stream Text 前导符 '0:' 结合 JSON_Escaped_String 和强回车。 + String vercelChunk = "0:" + objectMapper.writeValueAsString(textChunk) + "\n"; + + outputStream.write(vercelChunk.getBytes(StandardCharsets.UTF_8)); + // 高频推送缓冲区,使前端能产生实时的视觉卡顿流水效果! + outputStream.flush(); + } + } catch (Exception ignored) { + // 出于防守目的,故意默默捕捉并忽略空包异常,而不让流水中断。 } - currentData.append(line.substring("data:".length()).stripLeading()); } } - - dispatchEvent(currentEventName, currentData, sink); - } - } - - /** - * 发送单条已组装完成的 SSE 事件,并在完成事件出现时终止读取循环。 - */ - private boolean dispatchEvent(String explicitEventName, StringBuilder dataBuffer, OpenAiEventSink sink) - throws IOException { - if (dataBuffer.length() == 0) { - return false; - } - - String payload = dataBuffer.toString(); - if ("[DONE]".equals(payload)) { - sink.send("done", payload); - return true; - } - - String eventName = resolveEventName(explicitEventName, payload); - sink.send(eventName, payload); - return "response.completed".equals(eventName) || "response.failed".equals(eventName); - } - - /** - * 从 OpenAI 返回的 JSON 负载中推断事件名,显式事件名优先。 - */ - private String resolveEventName(String explicitEventName, String payload) { - if (StringUtils.hasText(explicitEventName)) { - return explicitEventName; - } - try { - JsonNode jsonNode = objectMapper.readTree(payload); - if (jsonNode.hasNonNull("type")) { - return jsonNode.get("type").asText(); - } - } catch (JacksonException ignored) { - // 如果当前数据不是 JSON,则退回到默认 SSE 事件名。 } - return "message"; } /** - * 将上游异常转换为 SSE error 事件,并正确结束当前推送。 + * 当前端断连或网络奔溃时,向流下发 Vercel 专门容错的错误前导符标识 'e:' 使得前端抛出红字。 */ - private void handleStreamError(Exception exception, OpenAiEventSink sink) { + private void handleStreamError(Exception exception, OutputStream outputStream) { if (exception instanceof InterruptedException) { Thread.currentThread().interrupt(); } try { - sink.send("error", serializeError(exception)); + String message = StringUtils.hasText(exception.getMessage()) ? exception.getMessage() : "OpenAI Error"; + String errorChunk = "e:" + objectMapper.writeValueAsString(message) + "\n"; + outputStream.write(errorChunk.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); } catch (IOException ignored) { - // 下游连接已断开时不再重复抛错。 - } - sink.completeWithError(exception); - } - - /** - * 把异常信息包装为统一的 JSON 文本,便于前端直接消费。 - */ - private String serializeError(Exception exception) { - try { - return objectMapper.writeValueAsString(Map.of( - "message", - StringUtils.hasText(exception.getMessage()) ? exception.getMessage() : "OpenAI 流式调用失败" - )); - } catch (JacksonException jsonProcessingException) { - return "{\"message\":\"OpenAI 流式调用失败\"}"; - } - } - - private static final class SseEmitterOpenAiEventSink implements OpenAiEventSink { - - private final SseEmitter emitter; - - /** - * 使用 Spring MVC 的 SseEmitter 适配下游事件发送能力。 - */ - private SseEmitterOpenAiEventSink(SseEmitter emitter) { - this.emitter = emitter; - } - - /** - * 发送一条带事件名的 SSE 消息。 - */ - @Override - public void send(String eventName, String data) throws IOException { - emitter.send(SseEmitter.event().name(eventName).data(data)); - } - - /** - * 正常完成当前 SSE 响应。 - */ - @Override - public void complete() { - emitter.complete(); - } - - /** - * 以异常状态结束当前 SSE 响应。 - */ - @Override - public void completeWithError(Throwable throwable) { - emitter.completeWithError(throwable); } } } diff --git a/src/main/java/com/involutionhell/backend/usercenter/HealthTestController.java b/src/main/java/com/involutionhell/backend/usercenter/HealthTestController.java index e62ede8..99a31b4 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/HealthTestController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/HealthTestController.java @@ -4,9 +4,11 @@ import com.involutionhell.backend.common.api.ApiResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@RestController("/health_check") +@RestController +@RequestMapping("/health_check") public class HealthTestController { @GetMapping("/get") diff --git a/src/main/java/com/involutionhell/backend/usercenter/config/SecurityConfig.java b/src/main/java/com/involutionhell/backend/usercenter/config/SecurityConfig.java index 6357cea..bf0a118 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/config/SecurityConfig.java +++ b/src/main/java/com/involutionhell/backend/usercenter/config/SecurityConfig.java @@ -1,51 +1,53 @@ -package com.involutionhell.backend.usercenter.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.security.web.SecurityFilterChain; - -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; - -import static org.springframework.security.config.Customizer.withDefaults; - -@Configuration -@EnableWebSecurity -@EnableMethodSecurity -public class SecurityConfig { - - @Value("${jwt.secret-key}") - private String secretKey; - - @Bean - public JwtDecoder jwtDecoder() { - // 使用 HmacSHA256 算法生成 SecretKeySpec - SecretKeySpec keySpec = new SecretKeySpec( - secretKey.getBytes(StandardCharsets.UTF_8), - "HmacSHA256" - ); - return NimbusJwtDecoder.withSecretKey(keySpec).build(); - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/api/auth/login", "/actuator/**", "/public/**").permitAll() - .anyRequest().authenticated() - ) - .oauth2Login(withDefaults()) // 启用 OAuth2 登录支持 - .oauth2Client(withDefaults()) // 启用 OAuth2 Client 支持 - .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())); // 启用 JWT 校验 (Resource Server) - - return http.build(); - } -} +// Temporarily commented out for JustAuth Migration + +// package com.involutionhell.backend.usercenter.config; + +// import org.springframework.beans.factory.annotation.Value; +// import org.springframework.context.annotation.Bean; +// import org.springframework.context.annotation.Configuration; +// import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +// import org.springframework.security.config.annotation.web.builders.HttpSecurity; +// import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +// import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +// import org.springframework.security.oauth2.jwt.JwtDecoder; +// import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +// import org.springframework.security.web.SecurityFilterChain; + +// import javax.crypto.spec.SecretKeySpec; +// import java.nio.charset.StandardCharsets; + +// import static org.springframework.security.config.Customizer.withDefaults; + +// @Configuration +// @EnableWebSecurity +// @EnableMethodSecurity +// public class SecurityConfig { +// +// @Value("${jwt.secret-key}") +// private String secretKey; + +// @Bean +// public JwtDecoder jwtDecoder() { +// // 使用 HmacSHA256 算法生成 SecretKeySpec +// SecretKeySpec keySpec = new SecretKeySpec( +// secretKey.getBytes(StandardCharsets.UTF_8), +// "HmacSHA256" +// ); +// return NimbusJwtDecoder.withSecretKey(keySpec).build(); +// } + +// @Bean +// public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { +// http +// .csrf(AbstractHttpConfigurer::disable) +// .authorizeHttpRequests(authorize -> authorize +// .requestMatchers("/api/auth/login", "/actuator/**", "/public/**").permitAll() +// .anyRequest().authenticated() +// ) +// .oauth2Login(withDefaults()) // 启用 OAuth2 登录支持 +// .oauth2Client(withDefaults()) // 启用 OAuth2 Client 支持 +// .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())); // 启用 JWT 校验 (Resource Server) + +// return http.build(); +// } +// } diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/AuthController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/AuthController.java index e3bdd80..284aca7 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/controller/AuthController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/AuthController.java @@ -1,6 +1,6 @@ package com.involutionhell.backend.usercenter.controller; -import org.springframework.security.access.prepost.PreAuthorize; +import cn.dev33.satoken.annotation.SaCheckLogin; import com.involutionhell.backend.common.api.ApiResponse; import com.involutionhell.backend.usercenter.dto.LoginRequest; import com.involutionhell.backend.usercenter.dto.LoginResponse; @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/auth") +@RequestMapping("/auth") // context-path 已含 /api/v1,此处不再重复加 /api 前缀 public class AuthController { private final AuthService authService; @@ -37,7 +37,7 @@ public ApiResponse login(@Valid @RequestBody LoginRequest request /** * 退出当前登录会话。 */ - @PreAuthorize("isAuthenticated()") + @SaCheckLogin @PostMapping("/logout") public ApiResponse logout() { authService.logout(); @@ -47,9 +47,9 @@ public ApiResponse logout() { /** * 查询当前登录用户信息。 */ - @PreAuthorize("isAuthenticated()") + @SaCheckLogin @GetMapping("/me") public ApiResponse currentUser() { return ApiResponse.ok(authService.currentUser()); } -} +} \ No newline at end of file diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java new file mode 100644 index 0000000..fb6f50b --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java @@ -0,0 +1,93 @@ +package com.involutionhell.backend.usercenter.controller; + +import com.involutionhell.backend.usercenter.dto.LoginResponse; +import com.involutionhell.backend.usercenter.service.AuthService; +import me.zhyd.oauth.config.AuthConfig; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthGithubRequest; +import me.zhyd.oauth.request.AuthRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +@RestController +public class OAuthController { + + private static final Logger log = LoggerFactory.getLogger(OAuthController.class); + + @Value("${justauth.type.github.client-id}") + private String githubClientId; + + @Value("${justauth.type.github.client-secret}") + private String githubClientSecret; + + @Value("${justauth.type.github.redirect-uri}") + private String githubRedirectUri; + + @Value("${AUTH_URL:http://localhost:3000}") + private String frontEndUrl; + + // 注入认证服务,用于查询/注册用户并执行 Sa-Token 登录 + private final AuthService authService; + + public OAuthController(AuthService authService) { + this.authService = authService; + } + + /** + * 获取 GitHub 授权请求对象 + */ + private AuthRequest getAuthRequest() { + return new AuthGithubRequest(AuthConfig.builder() + .clientId(githubClientId) + .clientSecret(githubClientSecret) + .redirectUri(githubRedirectUri) + .build()); + } + + /** + * 构建授权链接并重定向到 GitHub + * 前端直接跳转到后端此地址(NEXT_PUBLIC_BACKEND_URL + /oauth/render/github)发起登录 + */ + @GetMapping("/oauth/render/github") + public void renderAuth(HttpServletResponse response) throws IOException { + // 打印当前使用的 GitHub Client ID 和 redirect_uri,便于排查 token 配置问题 + log.info("[OAuth] GitHub Client ID = {}, redirect_uri = {}", githubClientId, githubRedirectUri); + AuthRequest authRequest = getAuthRequest(); + response.sendRedirect(authRequest.authorize(me.zhyd.oauth.utils.AuthStateUtils.createState())); + } + + /** + * GitHub OAuth 回调地址,路径与 GitHub OAuth App 注册保持一致(/api/auth/callback/github) + * GitHub → localhost:3000/api/auth/callback/github → Next.js rewrite → localhost:8080/api/auth/callback/github + */ + @GetMapping("/api/auth/callback/github") + public void login(AuthCallback callback, HttpServletResponse response) throws IOException { + AuthRequest authRequest = getAuthRequest(); + AuthResponse authResponse = authRequest.login(callback); + + if (authResponse.ok()) { + AuthUser authUser = (AuthUser) authResponse.getData(); + + // 调用 AuthService.loginByGithub():查询或自动注册用户,然后执行 Sa-Token 登录 + // 返回的 LoginResponse 包含 tokenName、tokenValue 和用户视图 + LoginResponse loginResponse = authService.loginByGithub(authUser); + + // 登录成功后重定向到前端,将 token 放在 URL fragment(#token=)中传给前端 + // fragment 不会出现在服务器日志和 Referer 头中,避免 token 泄露 + // 前端读取 #token= 参数后存入 localStorage,并清除 URL 中的 fragment + String redirectUrl = frontEndUrl + "/#token=" + loginResponse.tokenValue(); + response.sendRedirect(redirectUrl); + } else { + // 登录失败,重定向回前端并带上错误信息 + response.sendRedirect(frontEndUrl + "/login?error=oauth_failed"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/UserCenterController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/UserCenterController.java index 90b8ebe..ce4d43b 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/controller/UserCenterController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/UserCenterController.java @@ -1,12 +1,11 @@ package com.involutionhell.backend.usercenter.controller; -import org.springframework.security.access.prepost.PreAuthorize; +import cn.dev33.satoken.annotation.SaCheckPermission; import com.involutionhell.backend.common.api.ApiResponse; import com.involutionhell.backend.usercenter.dto.UserAuthorizationUpdateRequest; import com.involutionhell.backend.usercenter.dto.UserView; import com.involutionhell.backend.usercenter.service.UserCenterService; import jakarta.validation.Valid; -import java.util.List; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; @@ -14,55 +13,50 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @RestController -@RequestMapping("/api/user-center") +@RequestMapping("/users") // context-path 已含 /api/v1,此处不再重复加 /api 前缀 public class UserCenterController { private final UserCenterService userCenterService; /** - * 创建用户中心控制器并注入用户服务。 + * 创建用户中心控制器并注入服务。 */ public UserCenterController(UserCenterService userCenterService) { this.userCenterService = userCenterService; } /** - * 查询当前登录用户的用户中心资料。 - */ - @PreAuthorize("hasAuthority('user:profile:read')") - @GetMapping("/profile") - public ApiResponse currentProfile() { - return ApiResponse.ok(userCenterService.currentUser()); - } - - /** - * 查询用户中心中的全部用户。 + * 查询系统内所有用户,通常用于管理后台。 */ - @PreAuthorize("hasAuthority('user:center:read')") - @GetMapping("/users") + @SaCheckPermission("user:center:read") + @GetMapping public ApiResponse> listUsers() { return ApiResponse.ok(userCenterService.listUsers()); } /** - * 按用户 ID 查询单个用户详情。 + * 获取指定用户的详细信息。 */ - @PreAuthorize("hasAuthority('user:center:read')") - @GetMapping("/users/{userId}") + @SaCheckPermission("user:profile:read") + @GetMapping("/{userId}") public ApiResponse getUser(@PathVariable Long userId) { return ApiResponse.ok(userCenterService.getUser(userId)); } /** - * 更新指定用户的角色与权限集合。 + * 更新指定用户的角色与权限。 */ - @PreAuthorize("hasAuthority('user:center:manage')") - @PutMapping("/users/{userId}/authorization") + @SaCheckPermission("user:center:manage") + @PutMapping("/{userId}/authorization") public ApiResponse updateAuthorization( @PathVariable Long userId, - @Valid @RequestBody UserAuthorizationUpdateRequest request - ) { - return ApiResponse.ok("权限更新成功", userCenterService.updateAuthorization(userId, request)); + @Valid @RequestBody UserAuthorizationUpdateRequest request) { + return ApiResponse.ok( + "权限更新成功", + userCenterService.updateAuthorization(userId, request) + ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/involutionhell/backend/usercenter/dto/UserView.java b/src/main/java/com/involutionhell/backend/usercenter/dto/UserView.java index 4e90982..3748163 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/dto/UserView.java +++ b/src/main/java/com/involutionhell/backend/usercenter/dto/UserView.java @@ -9,7 +9,10 @@ public record UserView( String displayName, boolean enabled, Set roles, - Set permissions + Set permissions, + String avatarUrl, // GitHub 头像 URL,前端 UserMenu 显示头像用 + String email, // GitHub 邮箱(可为 null) + Long githubId // GitHub 数字 ID,用于贡献者追踪 ) { /** @@ -22,7 +25,10 @@ public static UserView from(UserAccount userAccount) { userAccount.displayName(), userAccount.enabled(), userAccount.roles(), - userAccount.permissions() + userAccount.permissions(), + userAccount.avatarUrl(), + userAccount.email(), + userAccount.githubId() ); } } diff --git a/src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java b/src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java index b22412e..59bba0d 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java +++ b/src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java @@ -11,7 +11,10 @@ public record UserAccount( String displayName, boolean enabled, Set roles, - Set permissions + Set permissions, + String avatarUrl, // GitHub 头像 URL + String email, // GitHub 邮箱(可为 null,GitHub 用户可设为私密) + Long githubId // GitHub 数字 ID,用于 doc_contributors 贡献者追踪 ) { /** @@ -26,7 +29,7 @@ public record UserAccount( * 基于当前用户信息生成一个新的授权快照。 */ public UserAccount withAuthorization(Set newRoles, Set newPermissions) { - return new UserAccount(id, username, passwordHash, displayName, enabled, newRoles, newPermissions); + return new UserAccount(id, username, passwordHash, displayName, enabled, newRoles, newPermissions, avatarUrl, email, githubId); } /** diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java index 5feadff..9c02405 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java @@ -1,6 +1,8 @@ package com.involutionhell.backend.usercenter.repository; import com.involutionhell.backend.usercenter.model.UserAccount; + +import java.sql.PreparedStatement; import java.util.Arrays; import java.util.HashSet; import java.util.List; @@ -8,6 +10,8 @@ import java.util.Set; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; /** @@ -29,7 +33,10 @@ public class JdbcUserAccountRepository implements UserAccountRepository { rs.getString("display_name"), rs.getBoolean("enabled"), parseSet(rs.getString("roles")), - parseSet(rs.getString("permissions")) + parseSet(rs.getString("permissions")), + rs.getString("avatar_url"), + rs.getString("email"), + rs.getObject("github_id", Long.class) // nullable Long ); public JdbcUserAccountRepository(JdbcTemplate jdbc) { @@ -64,6 +71,56 @@ public UserAccount updateAuthorization(Long userId, Set roles, Set new IllegalArgumentException("用户不存在: " + userId)); } + @Override + public UserAccount insert(UserAccount userAccount) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + String sql = "INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions, avatar_url, email, github_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"}); + ps.setString(1, userAccount.username()); + ps.setString(2, userAccount.passwordHash()); + ps.setString(3, userAccount.displayName()); + ps.setBoolean(4, userAccount.enabled()); + ps.setString(5, joinSet(userAccount.roles())); + ps.setString(6, joinSet(userAccount.permissions())); + ps.setString(7, userAccount.avatarUrl()); + ps.setString(8, userAccount.email()); + // github_id 可为 null,用 setObject 处理 + ps.setObject(9, userAccount.githubId()); + return ps; + }, keyHolder); + + Number key = keyHolder.getKey(); + if (key == null) { + throw new IllegalStateException("插入用户失败,无法获取生成的 ID"); + } + + return new UserAccount( + key.longValue(), + userAccount.username(), + userAccount.passwordHash(), + userAccount.displayName(), + userAccount.enabled(), + userAccount.roles(), + userAccount.permissions(), + userAccount.avatarUrl(), + userAccount.email(), + userAccount.githubId() + ); + } + + @Override + public UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId) { + // 每次 GitHub 用户登录时刷新其展示名称、头像、邮箱等资料 + jdbc.update( + "UPDATE user_accounts SET display_name = ?, avatar_url = ?, email = ?, github_id = ? WHERE id = ?", + displayName, avatarUrl, email, githubId, userId); + return findById(userId) + .orElseThrow(() -> new IllegalArgumentException("用户不存在: " + userId)); + } + /** * 将逗号分隔字符串解析为集合,空串返回空集合。 */ @@ -83,4 +140,4 @@ private static String joinSet(Set values) { } return String.join(",", values); } -} +} \ No newline at end of file diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java index c160bf9..0083456 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java @@ -29,4 +29,14 @@ public interface UserAccountRepository { * 更新指定用户的角色与权限,返回更新后的用户对象。 */ UserAccount updateAuthorization(Long userId, Set roles, Set permissions); -} + + /** + * 新增用户,并返回插入后的用户对象(包含生成的自增 ID)。 + */ + UserAccount insert(UserAccount userAccount); + + /** + * 更新 GitHub 用户的个人资料(展示名、头像、邮箱、GitHub ID),每次登录时刷新。 + */ + UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId); +} \ No newline at end of file diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java index 229e38e..2aa130f 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java @@ -1,14 +1,16 @@ package com.involutionhell.backend.usercenter.service; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.jwt.Jwt; +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 me.zhyd.oauth.model.AuthUser; import org.springframework.stereotype.Service; +import java.util.Set; +import java.util.UUID; + @Service public class AuthService { @@ -24,11 +26,12 @@ public AuthService(UserCenterService userCenterService, PasswordService password } /** - * 校验登录请求。当前阶段仅演示,实际建议通过 OAuth2 流程。 + * 校验登录请求 (传统账号密码登录)。 */ public LoginResponse login(LoginRequest request) { UserAccount userAccount = userCenterService.findByUsername(request.username()) .orElseThrow(() -> new IllegalArgumentException("用户名或密码错误")); + if (!userAccount.enabled()) { throw new IllegalStateException("账号已被禁用"); } @@ -36,15 +39,82 @@ public LoginResponse login(LoginRequest request) { throw new IllegalArgumentException("用户名或密码错误"); } - // 迁移标记:原 Sa-Token 登录已移除,此处应生成基于 JWT 的 Token 或交由 OAuth2 控制。 - return new LoginResponse("Bearer", "MOCK_TOKEN_" + userAccount.id(), UserView.from(userAccount)); + return executeLogin(userAccount); + } + + /** + * 第三方 GitHub 授权登录逻辑。 + * 如果用户不存在,则自动注册;如果已存在,则刷新其头像、邮箱等资料。 + */ + public LoginResponse loginByGithub(AuthUser githubUser) { + // 使用特殊的 github_ 前缀来标识这是第三方登录的用户,防止与普通用户名冲突 + String githubUsername = "github_" + githubUser.getUuid(); + + // 从 JustAuth 提取 GitHub 资料字段 + String displayName = githubUser.getNickname() != null ? githubUser.getNickname() : githubUser.getUsername(); + String avatarUrl = githubUser.getAvatar(); + String email = githubUser.getEmail(); + // JustAuth 对 GitHub 的 uuid 就是 GitHub 的数字用户 ID(字符串形式) + // 用 final 变量包装,确保 lambda 内可以引用(try-catch 双路赋值不是 effectively final) + Long parsedGithubId; + try { + parsedGithubId = Long.parseLong(githubUser.getUuid()); + } catch (NumberFormatException e) { + parsedGithubId = null; + } + final Long githubId = parsedGithubId; + + // 查找是否已经有该用户 + UserAccount userAccount = userCenterService.findByUsername(githubUsername).map(existing -> { + // 已存在:刷新头像、邮箱、展示名称(GitHub 用户可能更新了自己的资料) + return userCenterService.updateProfile(existing.id(), displayName, avatarUrl, email, githubId); + }).orElseGet(() -> { + // 不存在:自动注册新用户 + UserAccount newUser = new UserAccount( + null, // ID 由数据库自动生成 + githubUsername, + // 给第三方用户生成一个随机超长密码,他们不需要用密码登录 + passwordService.hash(UUID.randomUUID().toString()), + displayName, + true, // 默认启用 + Set.of("user"), // 赋予默认角色(小写,与 normalizeSet 一致) + Set.of(), // 默认权限 + avatarUrl, + email, + githubId + ); + return userCenterService.createUser(newUser); + }); + + // 检查该用户是否已被系统管理员禁用 + if (!userAccount.enabled()) { + throw new IllegalStateException("账号已被禁用"); + } + + // 执行 Sa-Token 登录并返回信息 + return executeLogin(userAccount); + } + + /** + * 执行底层 Sa-Token 登录操作并封装返回结果。 + */ + private LoginResponse executeLogin(UserAccount userAccount) { + // 使用 Sa-Token 建立会话 + StpUtil.login(userAccount.id()); + + // 返回包含 Token 信息的响应 + return new LoginResponse( + StpUtil.getTokenName(), + StpUtil.getTokenValue(), + UserView.from(userAccount) + ); } /** * 退出当前登录会话。 */ public void logout() { - // 迁移标记:Spring Security 的退出通常通过 SecurityContextLogoutHandler 控制。 + StpUtil.logout(); } /** @@ -53,4 +123,4 @@ public void logout() { public UserView currentUser() { return userCenterService.currentUser(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java b/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java index 2bf3990..752d421 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java @@ -1,14 +1,14 @@ package com.involutionhell.backend.usercenter.service; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; +import cn.dev33.satoken.stp.StpUtil; import com.involutionhell.backend.usercenter.dto.UserAuthorizationUpdateRequest; import com.involutionhell.backend.usercenter.dto.UserView; import com.involutionhell.backend.usercenter.model.UserAccount; import com.involutionhell.backend.usercenter.repository.UserAccountRepository; +import org.springframework.stereotype.Service; + import java.util.List; import java.util.Optional; -import org.springframework.stereotype.Service; @Service public class UserCenterService { @@ -28,23 +28,26 @@ public UserCenterService(UserAccountRepository userAccountRepository) { public Optional findByUsername(String username) { return userAccountRepository.findByUsername(username); } + + /** + * 新增用户。 + */ + public UserAccount createUser(UserAccount userAccount) { + return userAccountRepository.insert(userAccount); + } + + /** + * 刷新 GitHub 用户的个人资料(展示名、头像、邮箱、GitHub ID),每次登录时调用。 + */ + public UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId) { + return userAccountRepository.updateProfile(userId, displayName, avatarUrl, email, githubId); + } /** * 获取当前登录用户的信息。 */ public UserView currentUser() { - Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - Long currentUserId; - - if (principal instanceof Jwt jwt) { - currentUserId = Long.parseLong(jwt.getSubject()); - } else if (principal instanceof Long id) { - currentUserId = id; - } else { - // 这里可以扩展更多的主体解析逻辑 - throw new IllegalStateException("无法解析当前用户身份"); - } - + long currentUserId = StpUtil.getLoginIdAsLong(); return getUser(currentUserId); } @@ -77,4 +80,4 @@ public UserView updateAuthorization(Long userId, UserAuthorizationUpdateRequest ); return UserView.from(updatedAccount); } -} +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5f4ebb5..58ad58b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,6 +1,6 @@ spring.application.name=${SPRING_APPLICATION_NAME:backend} server.port=${SERVER_PORT:8080} -server.servlet.context-path=/api/v1 +# 去掉 context-path,避免 /api/v1/api/xxx 双重前缀问题;版本控制由路由本身维护 # 数据源配置 (仅保留占位符,不写死任何密钥) spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://${PGHOST:localhost}:${PGPORT:5432}/${PGDATABASE:neondb}?sslmode=${PGSSLMODE:disable}} @@ -9,7 +9,9 @@ spring.datasource.password=${PGPASSWORD:} spring.datasource.driver-class-name=org.postgresql.Driver # 初始化配置 -spring.sql.init.mode=${SPRING_SQL_INIT_MODE:always} +# 默认 never:生产环境不在每次启动时执行 schema.sql +# 首次部署或本地初始化时设置 SPRING_SQL_INIT_MODE=always +spring.sql.init.mode=${SPRING_SQL_INIT_MODE:never} spring.sql.init.schema-locations=classpath:schema.sql # Spring Security OAuth2 Client 配置 (仅保留占位符) @@ -17,14 +19,41 @@ spring.security.oauth2.client.registration.github.client-id=${AUTH_GITHUB_ID:} spring.security.oauth2.client.registration.github.client-secret=${AUTH_GITHUB_SECRET:} spring.security.oauth2.client.registration.github.scope=read:user,user:email -# JWT 配置 -jwt.secret-key=${AUTH_SECRET:involutionhell-default-secret-key-32-chars-long} +# JustAuth GitHub 配置 +# 开发环境优先读 AUTH_GITHUB_ID_DEV / AUTH_GITHUB_SECRET_DEV(与前端 NextAuth 的约定一致) +# 生产环境读 AUTH_GITHUB_ID / AUTH_GITHUB_SECRET +justauth.type.github.client-id=${AUTH_GITHUB_ID_DEV:${AUTH_GITHUB_ID:dummy-id}} +justauth.type.github.client-secret=${AUTH_GITHUB_SECRET_DEV:${AUTH_GITHUB_SECRET:dummy-secret}} +# 回调地址与 GitHub OAuth App 注册保持一致,通过 Next.js rewrite 转发给后端 +justauth.type.github.redirect-uri=${AUTH_URL:http://localhost:3000}/api/auth/callback/github -# Actuator 监控 +# JWT ?? (Temporarily Commented Out for JustAuth Migration) +# jwt.secret-key=${AUTH_SECRET:involutionhell-default-secret-key-32-chars-long} + +# Actuator management.endpoints.web.exposure.include=${MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE:health,info,metrics} -management.endpoint.health.show-details=always +# when-authorized:未认证请求只看到 UP/DOWN,不暴露数据库、磁盘等细节 +management.endpoint.health.show-details=${MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS:when-authorized} # AI 配置 (还原为您之前的结构,取消我擅自建议的默认值) openai.api-key=${OPENAI_API_KEY:} openai.api-url=${OPENAI_API_URL:https://api.openai.com/v1} openai.model=${OPENAI_MODEL:gpt-4.1} + +# ========================================== +# Sa-Token ?? +# ========================================== +# token?? (????cookie???) +sa-token.token-name=satoken +# token??????s ??30?, -1?????? +sa-token.timeout=2592000 +# token????? (???????????token??) ??: ? +sa-token.active-timeout=-1 +# ???????????? (?true???????, ?false?????????) +sa-token.is-concurrent=true +# ?????????????????token (?true?????????token, ?false?????????token) +sa-token.is-share=true +# token?? +sa-token.token-style=uuid +# ???????? +sa-token.is-log=false diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 0b25b90..39c97b1 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,13 +1,17 @@ -- Java 侧自管理的用户账号表(Sa-Token 认证,非 Auth.js OAuth 用户) -- 与 Prisma 管理的 users 表相互独立 CREATE TABLE IF NOT EXISTS user_accounts ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, 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 存储 GitHub 数字用户 ID,与 doc_contributors.github_id 对应 + github_id BIGINT UNIQUE ); -- 默认种子账号(已存在则跳过) diff --git a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java index 58d6d4f..9673245 100644 --- a/src/test/java/com/involutionhell/backend/BackendApplicationTests.java +++ b/src/test/java/com/involutionhell/backend/BackendApplicationTests.java @@ -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() { } diff --git a/src/test/java/com/involutionhell/backend/common/error/GlobalExceptionHandlerTests.java b/src/test/java/com/involutionhell/backend/common/error/GlobalExceptionHandlerTests.java index 65ac08a..f3eec66 100644 --- a/src/test/java/com/involutionhell/backend/common/error/GlobalExceptionHandlerTests.java +++ b/src/test/java/com/involutionhell/backend/common/error/GlobalExceptionHandlerTests.java @@ -2,8 +2,9 @@ import static org.assertj.core.api.Assertions.assertThat; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.InsufficientAuthenticationException; +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.exception.NotPermissionException; +import cn.dev33.satoken.exception.NotRoleException; import com.involutionhell.backend.common.api.ApiResponse; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Validation; @@ -23,23 +24,33 @@ class GlobalExceptionHandlerTests { private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); @Test - void handleAuthenticationReturnsUnauthorized() { - ResponseEntity> response = handler.handleAuthentication( - new InsufficientAuthenticationException("未登录") + void handleNotLoginExceptionReturnsUnauthorized() { + ResponseEntity> response = handler.handleNotLoginException( + new NotLoginException("未登录", "login", NotLoginException.NOT_TOKEN) ); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - assertThat(response.getBody()).isEqualTo(ApiResponse.fail("未登录或登录状态已失效")); + assertThat(response.getBody().message()).isEqualTo("未提供 Token"); } @Test - void handleAccessDeniedReturnsForbidden() { - ResponseEntity> response = handler.handleAccessDenied( - new AccessDeniedException("拒绝访问") + void handleNotPermissionExceptionReturnsForbidden() { + ResponseEntity> response = handler.handleNotPermissionException( + new NotPermissionException("p1") ); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - assertThat(response.getBody().message()).isEqualTo("拒绝访问: 权限不足"); + assertThat(response.getBody().message()).contains("拒绝访问: 缺少权限 [p1]"); + } + + @Test + void handleNotRoleExceptionReturnsForbidden() { + ResponseEntity> response = handler.handleNotRoleException( + new NotRoleException("admin") + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody().message()).contains("拒绝访问: 缺少角色 [admin]"); } @Test 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 2ba1574..9f0a031 100644 --- a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java @@ -1,101 +1,103 @@ 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(); - MvcResult mvcResult = mockMvc.perform(post("/api/openai/responses/stream") + + // 第一步:发起请求,确认异步处理已启动(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 void streamRejectsAnonymousRequest() throws Exception { - mockMvc.perform(post("/api/openai/responses/stream") + mockMvc.perform(post("/openai/responses/stream") .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("/api/openai/responses/stream") + 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() { @@ -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)); } diff --git a/src/test/java/com/involutionhell/backend/openai/service/HttpOpenAiStreamGatewayTests.java b/src/test/java/com/involutionhell/backend/openai/service/HttpOpenAiStreamGatewayTests.java index 2b34e8e..6d7c801 100644 --- a/src/test/java/com/involutionhell/backend/openai/service/HttpOpenAiStreamGatewayTests.java +++ b/src/test/java/com/involutionhell/backend/openai/service/HttpOpenAiStreamGatewayTests.java @@ -40,15 +40,18 @@ class HttpOpenAiStreamGatewayTests { private final RecordingHttpClient httpClient = new RecordingHttpClient(); private final ObjectMapper objectMapper = new ObjectMapper(); + private OpenAiStreamRequest createMockRequest(String model) { + return new OpenAiStreamRequest(List.of(new OpenAiStreamRequest.Message("user", "你好")), model); + } + @Test void validateConfigurationRejectsMissingEnvironmentValues() { HttpOpenAiStreamGateway gateway = new HttpOpenAiStreamGateway( httpClient, objectMapper, - new OpenAiProperties("", "", "") - ); + new OpenAiProperties("", "", "")); - assertThatThrownBy(() -> gateway.validateConfiguration(new OpenAiStreamRequest("你好", null, null))) + assertThatThrownBy(() -> gateway.validateConfiguration(createMockRequest(null))) .isInstanceOf(IllegalStateException.class) .hasMessage("OPENAI_API_URL 未配置"); } @@ -58,23 +61,20 @@ void openStreamBuildsStreamingRequestFromEnvironmentConfiguration() throws Excep HttpOpenAiStreamGateway gateway = new HttpOpenAiStreamGateway( httpClient, objectMapper, - new OpenAiProperties("https://api.openai.com/v1/responses", "test-key", "gpt-4.1") - ); + new OpenAiProperties("https://api.openai.com/v1", "test-key", "gpt-4.1")); ByteArrayInputStream body = new ByteArrayInputStream("data: [DONE]\n\n".getBytes(StandardCharsets.UTF_8)); httpClient.response = new StubHttpResponse(200, body); - InputStream result = gateway.openStream(new OpenAiStreamRequest("你好", "请简洁回答", null)); + InputStream result = gateway.openStream(createMockRequest(null)); HttpRequest capturedRequest = httpClient.lastRequest; assertThat(result).isSameAs(body); - assertThat(capturedRequest.uri().toString()).isEqualTo("https://api.openai.com/v1/responses"); + assertThat(capturedRequest.uri().toString()).isEqualTo("https://api.openai.com/v1/chat/completions"); assertThat(capturedRequest.headers().firstValue("Authorization")).hasValue("Bearer test-key"); assertThat(capturedRequest.headers().firstValue("Accept")).hasValue("text/event-stream"); assertThat(readRequestBody(capturedRequest)) .contains("\"stream\":true") .contains("\"model\":\"gpt-4.1\"") - .contains("\"instructions\":\"请简洁回答\"") - .contains("\"type\":\"input_text\"") - .contains("\"text\":\"你好\""); + .contains("\"messages\":[{\"role\":\"user\",\"content\":\"你好\"}]"); } @Test @@ -82,14 +82,12 @@ void openStreamAllowsPerRequestModelOverride() throws Exception { HttpOpenAiStreamGateway gateway = new HttpOpenAiStreamGateway( httpClient, objectMapper, - new OpenAiProperties("https://api.openai.com/v1/responses", "test-key", "gpt-4.1") - ); + new OpenAiProperties("https://api.openai.com/v1", "test-key", "gpt-4.1")); httpClient.response = new StubHttpResponse( 200, - new ByteArrayInputStream("data: [DONE]\n\n".getBytes(StandardCharsets.UTF_8)) - ); + new ByteArrayInputStream("data: [DONE]\n\n".getBytes(StandardCharsets.UTF_8))); - gateway.openStream(new OpenAiStreamRequest("你好", null, "gpt-5")); + gateway.openStream(createMockRequest("gpt-5")); assertThat(readRequestBody(httpClient.lastRequest)).contains("\"model\":\"gpt-5\""); } @@ -99,13 +97,12 @@ void openStreamRaisesReadableErrorWhenOpenAiReturnsFailure() throws Exception { HttpOpenAiStreamGateway gateway = new HttpOpenAiStreamGateway( httpClient, objectMapper, - new OpenAiProperties("https://api.openai.com/v1/responses", "test-key", "gpt-4.1") - ); + new OpenAiProperties("https://api.openai.com/v1", "test-key", "gpt-4.1")); httpClient.response = new StubHttpResponse(401, new ByteArrayInputStream(""" {"error":{"message":"Invalid API key"}} """.getBytes(StandardCharsets.UTF_8))); - assertThatThrownBy(() -> gateway.openStream(new OpenAiStreamRequest("你好", null, null))) + assertThatThrownBy(() -> gateway.openStream(createMockRequest(null))) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("HTTP 401") .hasMessageContaining("Invalid API key"); @@ -215,8 +212,7 @@ public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler @Override public CompletableFuture> sendAsync( HttpRequest request, - HttpResponse.BodyHandler responseBodyHandler - ) { + HttpResponse.BodyHandler responseBodyHandler) { throw new UnsupportedOperationException(); } @@ -224,8 +220,7 @@ public CompletableFuture> sendAsync( public CompletableFuture> sendAsync( HttpRequest request, HttpResponse.BodyHandler responseBodyHandler, - HttpResponse.PushPromiseHandler pushPromiseHandler - ) { + HttpResponse.PushPromiseHandler pushPromiseHandler) { throw new UnsupportedOperationException(); } } diff --git a/src/test/java/com/involutionhell/backend/openai/service/OpenAiStreamServiceTests.java b/src/test/java/com/involutionhell/backend/openai/service/OpenAiStreamServiceTests.java index 374e642..f7cec0e 100644 --- a/src/test/java/com/involutionhell/backend/openai/service/OpenAiStreamServiceTests.java +++ b/src/test/java/com/involutionhell/backend/openai/service/OpenAiStreamServiceTests.java @@ -5,10 +5,10 @@ import com.involutionhell.backend.openai.dto.OpenAiStreamRequest; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; @@ -17,68 +17,40 @@ class OpenAiStreamServiceTests { private final ObjectMapper objectMapper = new ObjectMapper(); - @Test - void streamToSinkRelaysTypedEventsAndCompletesNormally() { - OpenAiStreamService service = new OpenAiStreamService( - new FakeGateway(""" - data: {"type":"response.output_text.delta","delta":"你"} - - data: {"type":"response.completed"} - - """), - objectMapper - ); - RecordingSink sink = new RecordingSink(); - - service.streamToSink(new OpenAiStreamRequest("你好", null, null), sink); - - assertThat(sink.events).hasSize(2); - assertThat(sink.events.get(0).eventName()).isEqualTo("response.output_text.delta"); - assertThat(sink.events.get(0).data()).contains("\"delta\":\"你\""); - assertThat(sink.events.get(1).eventName()).isEqualTo("response.completed"); - assertThat(sink.completed).isTrue(); - assertThat(sink.error).isNull(); + private OpenAiStreamRequest createMockRequest() { + return new OpenAiStreamRequest(List.of(new OpenAiStreamRequest.Message("user", "你好")), null); } @Test - void streamToSinkRelaysDoneSentinelAndCompletesNormally() { + void streamToOutputStreamRelaysAndTransformsEvents() { OpenAiStreamService service = new OpenAiStreamService( new FakeGateway(""" - data: [DONE] + data: {"id":"123","choices":[{"delta":{"content":"你"}}]} - """), - objectMapper - ); - RecordingSink sink = new RecordingSink(); + data: {"id":"123","choices":[{"delta":{"content":"好"}}]} - service.streamToSink(new OpenAiStreamRequest("你好", null, null), sink); + data: [DONE] - assertThat(sink.events).containsExactly(new RecordedEvent("done", "[DONE]")); - assertThat(sink.completed).isTrue(); - assertThat(sink.error).isNull(); - } + """), + objectMapper); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - @Test - void streamRejectsMissingConfigurationBeforeCreatingEmitter() { - OpenAiStreamService service = new OpenAiStreamService(new InvalidGateway(), objectMapper); + service.streamToOutputStream(createMockRequest(), outputStream); - assertThatThrownBy(() -> service.stream(new OpenAiStreamRequest("你好", null, null))) - .isInstanceOf(IllegalStateException.class) - .hasMessage("OPENAI_API_KEY 未配置"); + String result = outputStream.toString(StandardCharsets.UTF_8); + assertThat(result).contains("0:\"你\"\n"); + assertThat(result).contains("0:\"好\"\n"); } @Test - void streamToSinkSendsErrorEventWhenGatewayFails() { + void streamToOutputStreamSendsErrorEventWhenGatewayFails() { OpenAiStreamService service = new OpenAiStreamService(new FailingGateway(), objectMapper); - RecordingSink sink = new RecordingSink(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - service.streamToSink(new OpenAiStreamRequest("你好", null, null), sink); + service.streamToOutputStream(createMockRequest(), outputStream); - assertThat(sink.events).hasSize(1); - assertThat(sink.events.get(0).eventName()).isEqualTo("error"); - assertThat(sink.events.get(0).data()).contains("上游连接失败"); - assertThat(sink.completed).isFalse(); - assertThat(sink.error).isInstanceOf(IOException.class); + String result = outputStream.toString(StandardCharsets.UTF_8); + assertThat(result).contains("e:\"上游连接失败\"\n"); } private static final class FakeGateway implements OpenAiStreamGateway { @@ -99,19 +71,6 @@ public InputStream openStream(OpenAiStreamRequest request) { } } - private static final class InvalidGateway implements OpenAiStreamGateway { - - @Override - public void validateConfiguration(OpenAiStreamRequest request) { - throw new IllegalStateException("OPENAI_API_KEY 未配置"); - } - - @Override - public InputStream openStream(OpenAiStreamRequest request) { - throw new UnsupportedOperationException(); - } - } - private static final class FailingGateway implements OpenAiStreamGateway { @Override @@ -123,29 +82,4 @@ public InputStream openStream(OpenAiStreamRequest request) throws IOException { throw new IOException("上游连接失败"); } } - - private static final class RecordingSink implements OpenAiEventSink { - - private final List events = new ArrayList<>(); - private boolean completed; - private Throwable error; - - @Override - public void send(String eventName, String data) { - events.add(new RecordedEvent(eventName, data)); - } - - @Override - public void complete() { - completed = true; - } - - @Override - public void completeWithError(Throwable throwable) { - error = throwable; - } - } - - private record RecordedEvent(String eventName, String data) { - } } diff --git a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java index 31260a9..c53b57c 100644 --- a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java +++ b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java @@ -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 { @@ -22,10 +51,10 @@ 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("/api/auth/login") + MvcResult result = mockMvc.perform(post("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -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"); } 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 9efd959..600dc90 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java @@ -9,11 +9,20 @@ 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 void loginReturnsTokenAndCurrentUserInfo() throws Exception { - mockMvc.perform(post("/api/auth/login") + mockMvc.perform(post("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -31,7 +40,7 @@ void loginReturnsTokenAndCurrentUserInfo() throws Exception { @Test void loginRejectsWrongPassword() throws Exception { - mockMvc.perform(post("/api/auth/login") + mockMvc.perform(post("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -46,7 +55,7 @@ void loginRejectsWrongPassword() throws Exception { @Test void loginValidatesBlankUsername() throws Exception { - mockMvc.perform(post("/api/auth/login") + mockMvc.perform(post("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -63,40 +72,43 @@ void loginValidatesBlankUsername() throws Exception { void meReturnsCurrentUserWhenLoggedIn() throws Exception { String token = loginAsAdmin(); - mockMvc.perform(get("/api/auth/me").header("satoken", token)) + mockMvc.perform(get("/auth/me").header("satoken", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.username").value("admin")) .andExpect(jsonPath("$.data.permissions[0]").isNotEmpty()); } + // 未带 token 是 NOT_TOKEN 场景,GlobalExceptionHandler 返回 "未提供 Token" @Test void meRejectsAnonymousRequest() throws Exception { - mockMvc.perform(get("/api/auth/me")) + mockMvc.perform(get("/auth/me")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.message").value("未登录或登录状态已失效")); + .andExpect(jsonPath("$.message").value("未提供 Token")); } @Test void logoutSucceedsAndMakesTokenInvalid() throws Exception { String token = loginAsAdmin(); - mockMvc.perform(post("/api/auth/logout").header("satoken", token)) + mockMvc.perform(post("/auth/logout").header("satoken", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("退出成功")); - mockMvc.perform(get("/api/auth/me").header("satoken", token)) + // 退出后原 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("/api/auth/logout")) + 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..620aad9 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java @@ -10,13 +10,30 @@ import org.springframework.http.MediaType; import org.springframework.test.annotation.DirtiesContext; +/** + * UserCenterController + AuthController(/auth/me)集成测试。 + * + * 旧测试用的 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 改对就好了。 + * + * 权限错误消息也变了:旧测试期望 "无权限访问: user:center:read", + * 但 GlobalExceptionHandler 实际输出是 "拒绝访问: 缺少权限 [user:center:read]",对齐即可。 + * + * @SaCheckPermission 之前一直 403 的原因:项目缺少 StpInterface 实现, + * Sa-Token 找不到实现 Bean 就用空列表兜底,所有权限校验必然失败。 + * 新增 SaTokenPermissionImpl 后才真正把权限数据接进来。 + */ class UserCenterControllerIntegrationTests extends AbstractWebIntegrationTest { + // 旧测试访问的 /api/user-center/profile 已不存在,现在是 /auth/me @Test 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,55 +43,63 @@ 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)); } + // alice 只有 user:profile:read,没有 user:center:read,访问 /users 会被拦截 + // 旧测试期望的消息 "无权限访问: ..." 和 GlobalExceptionHandler 实际输出不一致,已修正 @Test 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")); } + /** 匿名访问 /users/1 触发 NOT_TOKEN,返回 401。 */ @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)); } + /** + * 用户不存在时,UserCenterService 抛出 IllegalArgumentException, + * GlobalExceptionHandler 将其映射为 400 BAD_REQUEST。 + */ @Test 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")); } + // @DirtiesContext 确保本测试对 alice 的修改不会污染其他测试的预期数据 @Test @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) 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,15 +114,17 @@ 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)); } + /** 匿名 PUT 请求触发 NOT_TOKEN,返回 401。 */ @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 +140,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 +151,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/model/UserAccountTests.java b/src/test/java/com/involutionhell/backend/usercenter/model/UserAccountTests.java index 4fa08f1..3d50b26 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/model/UserAccountTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/model/UserAccountTests.java @@ -17,7 +17,8 @@ void constructorNormalizesRolesAndPermissions() { "管理员", true, Set.of(" Admin ", "admin", "USER"), - Set.of(" user:profile:read ", "USER:PROFILE:READ", "user:center:read") + Set.of(" user:profile:read ", "USER:PROFILE:READ", "user:center:read"), + null, null, null ); assertThat(account.roles()).containsExactlyInAnyOrder("admin", "user"); @@ -33,7 +34,8 @@ void withAuthorizationCreatesNewNormalizedSnapshot() { "管理员", true, Set.of("admin"), - Set.of("user:profile:read") + Set.of("user:profile:read"), + null, null, null ); UserAccount updated = account.withAuthorization(Set.of(" Reviewer "), Set.of(" USER:CENTER:READ ")); @@ -52,7 +54,8 @@ void userViewFromConvertsAccountToView() { "普通用户", true, Set.of("user"), - Set.of("user:profile:read") + Set.of("user:profile:read"), + null, null, null ); UserView view = UserView.from(account); 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 兼容写法