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/error/GlobalExceptionHandler.java b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java index a0f1421..bb55665 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,52 @@ @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: 拦截权限不足异常 */ + @ExceptionHandler(NotPermissionException.class) + public ResponseEntity> handleNotPermissionException(NotPermissionException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.fail("拒绝访问: 缺少权限 [" + e.getCode() + "]")); + } + /** - * 将权限不足异常转换为 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 +88,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 +115,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..6888a00 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java @@ -0,0 +1,92 @@ +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 参数传给前端 + // 前端读取 ?token= 参数后存入 localStorage,并清除 URL 中的参数 + 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/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..9e5cb2b 100644 --- a/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java @@ -27,7 +27,7 @@ class OpenAiStreamControllerIntegrationTests extends AbstractWebIntegrationTest @Test void streamReturnsSseEventsForAuthenticatedUser() throws Exception { String token = loginAsAdmin(); - MvcResult mvcResult = mockMvc.perform(post("/api/openai/responses/stream") + MvcResult mvcResult = mockMvc.perform(post("/openai/responses/stream") .header("satoken", token) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -48,7 +48,7 @@ void streamReturnsSseEventsForAuthenticatedUser() throws Exception { @Test void streamRejectsAnonymousRequest() throws Exception { - mockMvc.perform(post("/api/openai/responses/stream") + mockMvc.perform(post("/openai/responses/stream") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -64,7 +64,7 @@ void streamRejectsAnonymousRequest() throws Exception { void streamValidatesBlankMessage() 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(""" 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..bc9c6ae 100644 --- a/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java +++ b/src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java @@ -25,7 +25,7 @@ public abstract class AbstractWebIntegrationTest { * 使用指定账号登录并提取 Sa-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(""" { 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..e5232ef 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java @@ -13,7 +13,7 @@ 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 +31,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 +46,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,7 +63,7 @@ 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")) @@ -72,7 +72,7 @@ void meReturnsCurrentUserWhenLoggedIn() throws Exception { @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("未登录或登录状态已失效")); @@ -82,19 +82,19 @@ void meRejectsAnonymousRequest() throws Exception { 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)) + mockMvc.perform(get("/auth/me").header("satoken", token)) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.success").value(false)); } @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("未登录或登录状态已失效")); 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);