Uh oh!
There was an error while loading. Please reload this page.
refactor: 重构ai对话方法 - #2
Conversation
There was a problem hiding this comment.
Pull request overview
本 PR 主要围绕“AI 对话流式输出协议重构 + 认证体系迁移”展开:将 OpenAI 流式接口从 Spring SseEmitter 改为输出流直写以适配 Vercel AI SDK 的 stream-data 格式,同时将认证从 Spring Security/JWT 迁移到 JustAuth + Sa-Token,并同步调整路由前缀与用户表结构以支持 GitHub 资料字段。
Changes:
- OpenAI 流式对话:改为
StreamingResponseBody+ 文本流协议转译(0:/e:),并调整网关请求到/chat/completions与messages入参结构 - 认证/鉴权:引入 Sa-Token + JustAuth GitHub OAuth,新增 OAuth 回调与 Sa-Token 拦截器,替换原 Spring Security 相关逻辑
- 基础设施与数据:移除 servlet
context-path、更新健康检查路径、扩展user_accounts表字段(avatar/email/github_id)及相关 DTO/Repository
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/java/com/involutionhell/backend/usercenter/model/UserAccountTests.java | 适配 UserAccount 新增字段的构造参数 |
| src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java | 调整认证接口路径到 /auth/* |
| src/test/java/com/involutionhell/backend/support/AbstractWebIntegrationTest.java | 登录辅助方法改用 /auth/login |
| src/test/java/com/involutionhell/backend/openai/service/OpenAiStreamServiceTests.java | 用 OutputStream 测试新的 stream-data 输出格式 |
| src/test/java/com/involutionhell/backend/openai/service/HttpOpenAiStreamGatewayTests.java | 适配 /chat/completions 与 messages 请求体 |
| src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java | 更新 OpenAI 流式接口路径到 /openai/responses/stream |
| src/test/java/com/involutionhell/backend/common/error/GlobalExceptionHandlerTests.java | 从 Spring Security 异常切换到 Sa-Token 异常断言 |
| src/main/resources/schema.sql | user_accounts 新增 avatar/email/github_id 字段 |
| src/main/resources/application.properties | 移除 context-path,调整 SQL init 默认值,引入 JustAuth 与 Sa-Token 配置 |
| src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java | 使用 Sa-Token 获取当前用户;新增创建用户与刷新 GitHub 资料方法 |
| src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java | 使用 Sa-Token 登录/退出;新增 GitHub OAuth 登录并自动注册/刷新资料 |
| src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java | 新增 insert 与 updateProfile 仓库接口 |
| src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java | 映射/写入新增资料列;实现插入与资料更新 |
| src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java | UserAccount 记录新增头像/邮箱/GitHubId 字段 |
| src/main/java/com/involutionhell/backend/usercenter/HealthTestController.java | 修正 Controller 基路径声明方式(@RequestMapping) |
| src/main/java/com/involutionhell/backend/usercenter/dto/UserView.java | UserView 增加头像/邮箱/GitHubId 字段 |
| src/main/java/com/involutionhell/backend/usercenter/controller/UserCenterController.java | 路由重构为 /users/* 并改为 Sa-Token 权限注解 |
| src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java | 新增:JustAuth GitHub OAuth 发起与回调处理 |
| src/main/java/com/involutionhell/backend/usercenter/controller/AuthController.java | 路由重构为 /auth/* 并改为 Sa-Token 登录注解 |
| src/main/java/com/involutionhell/backend/usercenter/config/SecurityConfig.java | 旧 Spring Security 配置整体注释(迁移占位) |
| src/main/java/com/involutionhell/backend/openai/service/OpenAiStreamService.java | 核心重构:从 SSE 事件转发改为输出流协议转译 |
| src/main/java/com/involutionhell/backend/openai/service/HttpOpenAiStreamGateway.java | 请求体改为 chat.completions + messages;拼接 endpoint;增加未配置告警 |
| src/main/java/com/involutionhell/backend/openai/dto/OpenAiStreamRequest.java | DTO 从 message 改为 messages,匹配 Vercel AI SDK |
| src/main/java/com/involutionhell/backend/openai/controller/OpenAiStreamController.java | 控制器改用 StreamingResponseBody 返回纯文本流 |
| src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java | 增加 Sa-Token 异常处理分支并调整未预期异常处理 |
| src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java | 新增:Sa-Token WebMVC 拦截器统一登录拦截规则 |
| README.md | 更新默认入口与示例请求路径(去掉 /api/v1 前缀) |
| pom.xml | 引入 JustAuth + Sa-Token,注释 Spring Security 依赖 |
| docs/dev1.md | 新增开发手册,记录端点与会话流程 |
| docker-compose.yml | 更新健康检查路径到 /actuator/health |
| Caddyfile | 更新 upstream health check URI 到 /actuator/health |
| .github/workflows/deploy.yml | 更新部署后健康检查路径到 /actuator/health |
| .env.example | 更新 SQL init 默认建议与健康检查 show-details 建议值 |
| .editorconfig | 新增基础 EditorConfig 规范 |
Comments suppressed due to low confidence (3)
src/test/java/com/involutionhell/backend/openai/controller/OpenAiStreamControllerIntegrationTests.java:37
- 该集成测试仍在发送旧的请求体字段 "message",并断言 TEXT_EVENT_STREAM + OpenAI Responses API 的事件名;但当前控制器 DTO 已改为 messages 数组且返回 TEXT_PLAIN 的 Vercel stream-data 格式。这会导致测试无法反映真实行为并直接失败。建议更新请求 JSON、stub 网关返回的示例流,以及断言逻辑以匹配新协议(例如包含 0:"..."/e:"..." 片段)。
void streamReturnsSseEventsForAuthenticatedUser() throws Exception {
String token = loginAsAdmin();
MvcResult mvcResult = mockMvc.perform(post("/openai/responses/stream")
.header("satoken", token)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"message": "你好"
}
"""))
src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java:79
- 匿名请求 /auth/me 的断言仍期望旧的统一文案“未登录或登录状态已失效”,但 GlobalExceptionHandler 现在对 NotLoginException.NOT_TOKEN 返回更具体的“未提供 Token”。在 SaTokenConfigure 拦截器生效时,这里实际会走 NotLoginException 分支。建议同步更新测试期望(或如果仍想保留旧文案,则需要调整异常处理映射)。
src/test/java/com/involutionhell/backend/usercenter/controller/AuthControllerIntegrationTests.java:101 - 匿名请求 /auth/logout 的断言仍期望旧的统一文案“未登录或登录状态已失效”,但当前全局异常处理对未提供 token 会返回“未提供 Token”。建议与新的 Sa-Token 异常文案保持一致,避免测试与实际行为不匹配。
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 |
There was a problem hiding this comment.
Sa-Token 拦截器目前拦截了所有路由但未放行 /actuator/(以及可能的 /health_check/)。这会导致容器/CI 的健康检查在未携带 token 时直接返回 401,从而判定服务不可用。建议在路由规则中显式 notMatch("/actuator/**")(必要时也放行健康检查/静态资源等)。
| 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 | ||
| ); |
There was a problem hiding this comment.
这里通过 CREATE TABLE IF NOT EXISTS 增加 avatar_url/email/github_id 并不会对已存在的 user_accounts 表生效,但 JdbcUserAccountRepository 使用 SELECT * 并读取这些新列;线上已有表未执行迁移会在查询时报“列不存在”。建议补充显式 ALTER TABLE 迁移脚本(或引入 Flyway/Liquibase),并确保部署流程会执行该迁移。
| * 获取指定用户的详细信息。 | ||
| */ | ||
| @PreAuthorize("hasAuthority('user:center:read')") | ||
| @GetMapping("/users/{userId}") | ||
| @SaCheckPermission("user:profile:read") | ||
| @GetMapping("/{userId}") | ||
| public ApiResponse<UserView> getUser(@PathVariable Long userId) { | ||
| return ApiResponse.ok(userCenterService.getUser(userId)); | ||
| } |
There was a problem hiding this comment.
getUser() 现在仅要求 user:profile:read 权限,但该接口按 userId 获取任意用户详情(不一定是本人),相较原先的 user:center:read 会放宽访问范围,可能造成越权读取其他用户信息。建议改回更严格的管理权限(例如 user:center:read),或改为仅允许查询本人并在服务层校验 userId == 当前登录用户。
| 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) { | ||
| // 出于防守目的,故意默默捕捉并忽略空包异常,而不让流水中断。 | ||
| } |
There was a problem hiding this comment.
relayEvents() 里对每个 data payload 的 JSON 解析使用 catch (Exception ignored) 直接吞掉所有异常,可能在上游协议变更/返回错误内容时静默产出空响应,排障困难。建议至少记录一次可控日志(debug/warn)或在连续解析失败时向下游发送 e: 错误块并终止流。
| @ExceptionHandler(Exception.class) | ||
| public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception) { | ||
| exception.printStackTrace(); // 建议在开发阶段打印堆栈,生产环境应使用日志框架 | ||
| return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) | ||
| .body(ApiResponse.fail("服务器内部错误")); |
There was a problem hiding this comment.
handleUnexpected() 中直接 exception.printStackTrace() 会把异常堆栈输出到标准输出,生产环境下不利于日志治理且可能泄露内部信息。建议改用统一的日志框架记录(例如 logger.error("...", exception)),并由日志配置控制不同环境的输出级别。
| 前端 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(匿名) | ||
| ``` |
There was a problem hiding this comment.
文档这里描述请求携带 x-satoken header,但当前后端配置的 token-name 是 satoken,且测试也使用 satoken header。若确实只支持 satoken,应统一文档表述;若需要兼容 x-satoken,请补充后端读取该 header 的配置/适配层。
| ``` | ||
| 用户点击登录 | ||
| → 前端跳转 /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 | ||
| ``` |
There was a problem hiding this comment.
这里写“URL 携带 ?token=xxx”,但 OAuthController 实际重定向使用的是 fragment 形式 /#token=...。建议统一文档与实现,避免前端按 querystring 解析导致拿不到 token。
| @PostConstruct | ||
| void warnIfNotConfigured() { | ||
| if (!StringUtils.hasText(properties.apiKey())) { | ||
| log.warn("[OpenAI] OPENAI_API_KEY 未配置,/openai/responses/stream 调用时将返回 400 错误"); | ||
| } | ||
| } |
There was a problem hiding this comment.
warnIfNotConfigured() 的日志提示“调用时将返回 400 错误”,但当前 OpenAiStreamController 固定返回 200 + StreamingResponseBody,且 OpenAiStreamService 会捕获 validateConfiguration/openStream 的异常并写入 e: 错误块而不抛出异常,因此通常不会触发全局异常处理返回 400。建议统一行为:要么在 controller 层先 validate 并抛出以返回 400,要么把日志文案改为“将返回 e: 错误块”。
| 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"; | ||
| } |
There was a problem hiding this comment.
buildHttpRequest() 目前对任何不以 /chat/completions 结尾的 apiUrl 都直接追加 "/chat/completions"。如果现有配置仍是旧值(例如测试配置里使用过的 https://api.openai.com/v1/responses),会被拼成 https://.../v1/responses/chat/completions,导致请求 404。建议明确约束 apiUrl 为 base(如 .../v1)并在校验时拒绝包含旧路径,或在这里对以 /responses 结尾的情况做兼容替换。
New test suites: - JdbcUserAccountRepositoryTests: 13 tests covering all CRUD operations, null handling, and error paths with H2 in-memory database - AuthServiceTests: 13 unit tests for login/loginByGithub/logout/currentUser using Mockito with mockStatic for Sa-Token isolation - OAuthControllerIntegrationTests: 4 tests covering GitHub OAuth redirect URL validation and callback failure paths Infrastructure fixes: - AbstractWebIntegrationTest: add @SpringBootTest(properties) override to prevent SPRING_DATASOURCE_URL env var from overriding H2 test config - BackendApplicationTests: same H2 override fix for context load test - test-schema.sql: add missing avatar_url, email, github_id columns that JdbcUserAccountRepository.ROW_MAPPER reads Production bug fixes revealed by tests: - SaTokenPermissionImpl: implement StpInterface so @SaCheckPermission annotations can resolve permissions from the database (was always 403) - GlobalExceptionHandler: use e.getPermission() instead of e.getCode() in handleNotPermissionException (getCode() returned -1, not the permission) Pre-existing test fixes: - AuthControllerIntegrationTests: update expected Sa-Token error messages from old generic message to current specific messages ("未提供 Token") - UserCenterControllerIntegrationTests: fix stale URL paths (/users instead of /api/user-center/users) and update expected permission error messages - OpenAiStreamControllerIntegrationTests: update for renamed DTO field (message -> messages), fix asyncDispatch pattern for StreamingResponseBody, and align stub gateway with OpenAI API format expected by relayEvents() Result: 78/78 tests pass (was 40/78 with 36 errors + 2 failures baseline) https://claude.ai/code/session_016Z9qEQdrSXSTAhCp1YMgnk
Root causes and fixes: 1. SaTokenPermissionImpl (NEW) - Without StpInterface, Sa-Token returns empty permissions for all users, causing every @SaCheckPermission check to unconditionally fail with 403. - loginId must be parsed via toString() first: Sa-Token serializes it as String internally even when StpUtil.login(Long) was called. 2. GlobalExceptionHandler - NotPermissionException.getCode() returns the integer scene code (-1), not the permission string. Fixed to getPermission() which returns the actual missing permission name (e.g. "user:center:read"). 3. AbstractWebIntegrationTest + BackendApplicationTests - SPRING_DATASOURCE_URL env var (pointing to Neon PostgreSQL) has higher Spring priority than application-test.properties, causing H2 context load failure. Fixed via @SpringBootTest(properties) which overrides all env vars. - JustAuth UrlValidator rejects localhost redirect URIs; overridden with a syntactically valid placeholder URL. 4. AuthControllerIntegrationTests - Sa-Token NOT_TOKEN scenario now maps to "未提供 Token", not the old generic "未登录或登录状态已失效" message. 5. UserCenterControllerIntegrationTests - All URLs updated: /api/user-center/* paths were removed; current routes are /auth/me, /users, /users/{id}, /users/{id}/authorization. - Permission error message updated to match GlobalExceptionHandler output: "拒绝访问: 缺少权限 [<permission>]". 6. OpenAiStreamControllerIntegrationTests - DTO field renamed: message (String) -> messages (List), aligning with Vercel AI SDK payload format. - Async test uses asyncDispatch two-step pattern (required for StreamingResponseBody); polling getContentAsString() never sees data. - Stub returns OpenAI SSE format choices[0].delta.content so relayEvents() can extract the text and emit Vercel Stream prefix "0:". All 78 tests now pass. https://claude.ai/code/session_016Z9qEQdrSXSTAhCp1YMgnk
Removed all <h3>, <h4>, <p>, <ul>, <li>, <ol> tags from Javadoc blocks. Rewrote everything in plain Chinese as a developer would naturally write it. https://claude.ai/code/session_016Z9qEQdrSXSTAhCp1YMgnk
refactor: 重构ai对话方法