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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 调用示例接口
内置种子账号:
Expand All @@ -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"}'
```
Expand Down Expand Up @@ -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"}` 表示服务正常运行。
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions docs/dev1.md
Original file line number Diff line number Diff line change
@@ -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
```
29 changes: 28 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<!-- spring security & oauth2 -->
<!-- spring security & oauth2 (Temporarily Commented Out for JustAuth Migration) -->
<!--
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
Expand All @@ -74,6 +75,25 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
-->

<!-- JustAuth Core & Http Client -->
<dependency>
<groupId>me.zhyd.oauth</groupId>
<artifactId>JustAuth</artifactId>
<version>1.16.6</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-http</artifactId>
<version>5.8.25</version>
</dependency>
<!-- 添加 JustAuth 必须的 json 解析器 (Hutool 的依赖) -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-json</artifactId>
<version>5.8.25</version>
</dependency>

<!-- postgresql -->
<dependency>
Expand All @@ -82,6 +102,13 @@
<scope>runtime</scope>
</dependency>

<!-- Sa-Token 权限认证 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.39.0</version>
</dependency>

<!-- &lt;!&ndash; redis &ndash;&gt;-->
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
Expand Down
Original file line number Diff line number Diff line change
@@ -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("/**");
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,30 +16,52 @@
@RestControllerAdvice
public class GlobalExceptionHandler {

// ==========================================
// Sa-Token 异常拦截
// ==========================================

/**
* 将未登录异常转换为 401 响应
*/
/**
* 将认证不足异常转换为 401 响应
* Sa-Token: 拦截未登录异常
*/
@ExceptionHandler(InsufficientAuthenticationException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthentication(InsufficientAuthenticationException exception) {
@ExceptionHandler(NotLoginException.class)
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> handleNotPermissionException(NotPermissionException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.fail("拒绝访问: 缺少权限 [" + e.getCode() + "]"));
}

/**
* 将权限不足异常转换为 403 响应 (Spring Security 版)
* Sa-Token: 拦截角色不足异常
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException exception) {
@ExceptionHandler(NotRoleException.class)
public ResponseEntity<ApiResponse<Void>> handleNotRoleException(NotRoleException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.fail("拒绝访问: 权限不足"));
.body(ApiResponse.fail("拒绝访问: 缺少角色 [" + e.getRole() + "]"));
}

// ==========================================
// 业务与通用异常拦截
// ==========================================

/**
* 将参数校验异常转换为 400 响应
*/
Expand All @@ -65,6 +88,7 @@ public ResponseEntity<ApiResponse<Void>> handleBusiness(Exception exception) {
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception) {
exception.printStackTrace(); // 建议在开发阶段打印堆栈,生产环境应使用日志框架
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail("服务器内部错误"));
}
Expand All @@ -91,4 +115,4 @@ private String resolveValidationMessage(Exception exception) {
}
return "请求参数不合法";
}
}
}
Loading