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);