Compare commits

..

3 Commits

Author SHA1 Message Date
8632dcd282 docs(readme): 重写项目文档,更新技术栈与功能说明
feat(character): 新增人设详情接口并优化获取逻辑
2026-01-07 16:17:57 +08:00
090cb65c0b fix(controller): 修复文件上传参数绑定与异常处理
- FileController:把 @RequestParam 改成 @RequestPart 并去掉多余注解
- GlobalExceptionHandler:新增 MissingServletRequestPartException 拦截,返回 FILE_IS_EMPTY 错误码
- RequestBodyCacheFilter:跳过 multipart 请求,避免文件上传被缓存过滤器破坏
- UserServiceImpl:修正更新语句为 updateById,防止字段丢失
2026-01-05 20:26:37 +08:00
a73a92c0c2 fix(config): 修复包名大小写并优化 Maven 构建配置
- 统一 interceptor 包名为小写
- 修正测试接口拼写 testSearchText
- 升级编译插件并显式声明 JDK17 与 Lombok 版本
- 将本地 Claude 记录文件加入忽略列表
2026-01-04 15:51:40 +08:00
12 changed files with 199 additions and 33 deletions

1
.gitignore vendored
View File

@@ -38,3 +38,4 @@ build/
/.claude/agents/backend-architect.md /.claude/agents/backend-architect.md
/.dockerignore /.dockerignore
/Dockerfile /Dockerfile
/.claude/ralph-loop.local.md

141
README.md
View File

@@ -1,26 +1,123 @@
# SpringBoot 项目初始模板 # Keyborad Backend
> Java SpringBoot 项目初始模板,整合了常用框架和示例代码,大家可以在此基础上快速开发自己的项目 基于 Spring Boot 3.5.5 的后端服务,集成了 AI 能力、向量搜索、Apple 登录等功能
## 模板功能 ## 技术栈
- Spring Boot 2.7.0(贼新) - **Java 17** + **Spring Boot 3.5.5**
- Spring MVC - **Spring AI** - LLM 对话和文本嵌入OpenAI 兼容 API
- MySQL 驱动 - **Qdrant** - 向量数据库,支持语义搜索
- MyBatis - **PostgreSQL** - 关系型数据库
- MyBatis Plus - **MyBatis Plus** - ORM 框架
- Spring Session Redis 分布式登录 - **Redis** - 会话存储和缓存
- Spring AOP - **Sa-Token** - 认证授权框架
- Apache Commons Lang3 工具类 - **Knife4j** - API 文档
- Lombok 注解 - **X-File-Storage** - 文件上传Cloudflare R2
- Swagger + Knife4j 接口文档 - **MailerSend** - 邮件服务
- Spring Boot 调试工具和项目处理器
- 全局请求响应拦截器(记录日志)
- 全局异常处理器
- 自定义错误码
- 封装通用响应类
- 示例用户注册、登录、搜索功能
- 示例单元测试类
- 示例 SQL用户表
访问 localhost:7529/api/doc.html 就能在线调试接口了,不需要前端配合啦~ ## 核心功能
### 认证系统
- Apple Sign-In JWT 验证
- Sa-Token 会话管理
- 请求签名校验(防篡改/防重放)
### AI 能力
- LLM 对话(支持流式响应)
- 文本嵌入1536 维向量)
- 语义搜索Qdrant 向量检索)
### 通用功能
- 统一响应格式
- 全局异常处理
- 国际化支持i18n
- 请求日志记录
## 快速开始
### 环境要求
- JDK 17+
- Maven 3.8+
- PostgreSQL 14+
- Redis 6+
### 本地运行
1. 克隆项目
```bash
git clone <repository-url>
cd keyborad-backend
```
2. 配置数据库和 Redis
```yaml
# 修改 src/main/resources/application-dev.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/keyborad_db
username: your_username
password: your_password
redis:
host: localhost
port: 6379
```
3. 启动应用
```bash
mvn spring-boot:run
```
4. 访问 API 文档
```
http://localhost:7529/api/doc.html
```
## 项目结构
```
src/main/java/com/yolo/keyborad/
├── controller/ # REST API 端点
├── service/ # 业务逻辑层
│ └── impl/ # 服务实现
├── mapper/ # MyBatis 数据库映射
├── model/
│ ├── entity/ # 数据库实体
│ ├── dto/ # 请求数据传输对象
│ └── vo/ # 响应视图对象
├── config/ # Spring 配置类
├── aop/ # AOP 拦截器
├── Interceptor/ # 请求拦截器
├── filter/ # Servlet 过滤器
├── exception/ # 异常处理
├── common/ # 通用工具类
└── utils/ # 工具类
```
## 配置说明
| 配置项 | 说明 | 默认值 |
|-------|------|-------|
| `server.port` | 服务端口 | 7529 |
| `server.servlet.context-path` | 上下文路径 | /api |
| `spring.profiles.active` | 激活配置文件 | dev |
## API 认证
### Sa-Token 认证
需要在请求头中携带 `satoken` 字段。
### 请求签名
部分接口需要签名校验,请求头需包含:
- `X-App-Id` - 应用 ID
- `X-Timestamp` - 时间戳
- `X-Nonce` - 随机数
- `X-Sign` - 签名
## 开发指南
详细的开发指南请参考 [CLAUDE.md](./CLAUDE.md)。
## License
MIT License

21
pom.xml
View File

@@ -280,6 +280,27 @@
<build> <build>
<plugins> <plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
</path>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${spring-boot.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>

View File

@@ -1,4 +1,4 @@
package com.yolo.keyborad.Interceptor; package com.yolo.keyborad.interceptor;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yolo.keyborad.utils.SignUtils; import com.yolo.keyborad.utils.SignUtils;

View File

@@ -5,7 +5,7 @@ import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaHttpMethod; import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter; import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.yolo.keyborad.Interceptor.SignInterceptor; import com.yolo.keyborad.interceptor.SignInterceptor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -64,7 +64,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/demo/embed", "/demo/embed",
"/demo/testSaveEmbed", "/demo/testSaveEmbed",
"/demo/testSearch", "/demo/testSearch",
"/demo/tsetSearchText", "/demo/testSearchText",
"/file/upload", "/file/upload",
"/user/logout", "/user/logout",
"/tag/list", "/tag/list",
@@ -73,6 +73,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/character/listByUser", "/character/listByUser",
"/user/detail", "/user/detail",
"/user/register", "/user/register",
"/user/updateInfo",
"/character/updateUserCharacterSort", "/character/updateUserCharacterSort",
"/character/delUserCharacter", "/character/delUserCharacter",
"/user/sendVerifyMail", "/user/sendVerifyMail",

View File

@@ -49,8 +49,7 @@ public class CharacterController {
@GetMapping("/detail") @GetMapping("/detail")
@Operation(summary = "人设详情", description = "人设详情接口") @Operation(summary = "人设详情", description = "人设详情接口")
public BaseResponse<KeyboardCharacterRespVO> detail(@RequestParam("id") Long id) { public BaseResponse<KeyboardCharacterRespVO> detail(@RequestParam("id") Long id) {
KeyboardCharacter character = characterService.getById(id); return ResultUtils.success(characterService.getDetailById(id));
return ResultUtils.success(BeanUtil.copyProperties(character, KeyboardCharacterRespVO.class));
} }
@GetMapping("/listByTag") @GetMapping("/listByTag")

View File

@@ -27,8 +27,7 @@ public class FileController {
@PostMapping("/upload") @PostMapping("/upload")
@Operation(summary = "上传文件", description = "上传文件接口") @Operation(summary = "上传文件", description = "上传文件接口")
@Parameter(name = "file",required = true,description = "上传的文件") public BaseResponse<String> upload(@RequestPart("file") MultipartFile file){
public BaseResponse<String> upload(@RequestParam("file") MultipartFile file){
String fileUrl = fileService.upload(file); String fileUrl = fileService.upload(file);
return ResultUtils.success(fileUrl); return ResultUtils.success(fileUrl);
} }

View File

@@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
/** /**
* 全局异常处理器 * 全局异常处理器
@@ -25,6 +26,20 @@ public class GlobalExceptionHandler {
this.i18nService = i18nService; this.i18nService = i18nService;
} }
@ExceptionHandler(MissingServletRequestPartException.class)
public BaseResponse<?> missingServletRequestPartExceptionHandler(MissingServletRequestPartException e, HttpServletRequest request) {
log.error("missingServletRequestPartException: " + e.getMessage(), e);
String acceptLanguage = request.getHeader("Accept-Language");
String errorMessage = i18nService.getMessageWithAcceptLanguage(String.valueOf(ErrorCode.FILE_IS_EMPTY.getCode()), acceptLanguage);
if (errorMessage == null) {
errorMessage = ErrorCode.FILE_IS_EMPTY.getMessage();
}
return ResultUtils.error(ErrorCode.FILE_IS_EMPTY.getCode(), errorMessage);
}
@ExceptionHandler(BusinessException.class) @ExceptionHandler(BusinessException.class)
public BaseResponse<?> businessExceptionHandler(BusinessException e, HttpServletRequest request) { public BaseResponse<?> businessExceptionHandler(BusinessException e, HttpServletRequest request) {
log.error("businessException: " + e.getMessage(), e); log.error("businessException: " + e.getMessage(), e);

View File

@@ -18,15 +18,27 @@ public class RequestBodyCacheFilter extends OncePerRequestFilter {
FilterChain filterChain) FilterChain filterChain)
throws ServletException, IOException { throws ServletException, IOException {
// 只缓存一次 // 获取请求的内容类型
String contentType = request.getContentType();
// 跳过 multipart 请求,避免破坏文件上传功能
if (contentType != null && contentType.toLowerCase().startsWith("multipart/")) {
// 对于文件上传请求,直接放行,不进行请求体缓存
filterChain.doFilter(request, response);
return;
}
// 检查是否已经进行过请求体缓存,避免重复缓存
if (!(request instanceof CachedBodyHttpServletRequest)) { if (!(request instanceof CachedBodyHttpServletRequest)) {
// 创建缓存请求对象,包装原始请求以支持多次读取请求体
CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(request); CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(request);
// 使用缓存的请求对象继续执行过滤器链
filterChain.doFilter(cachedRequest, response); filterChain.doFilter(cachedRequest, response);
return; return;
} }
// 如果已经是缓存过的请求,则直接执行过滤器链
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
} }

View File

@@ -35,4 +35,6 @@ public interface KeyboardCharacterService extends IService<KeyboardCharacter>{
List<KeyboardCharacterRespVO> selectListByTagWithNotLogin(Long tagId); List<KeyboardCharacterRespVO> selectListByTagWithNotLogin(Long tagId);
void addDefaultUserCharacter(Long userId); void addDefaultUserCharacter(Long userId);
KeyboardCharacterRespVO getDetailById(Long id);
} }

View File

@@ -288,4 +288,22 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
this.addUserCharacter(keyboardUserCharacterAddDTO, userId); this.addUserCharacter(keyboardUserCharacterAddDTO, userId);
}); });
} }
@Override
public KeyboardCharacterRespVO getDetailById(Long id) {
// 根据ID获取人设信息优先从缓存获取
KeyboardCharacter character = this.getById(id);
// 将实体对象转换为响应对象
KeyboardCharacterRespVO respVO = BeanUtil.copyProperties(character, KeyboardCharacterRespVO.class);
// 判断当前用户是否已添加该人设
long userId = StpUtil.getLoginIdAsLong();
// 查询用户人设关联表,判断当前用户是否已添加该人设
KeyboardUserCharacter userCharacter = keyboardUserCharacterMapper.selectOne(
new LambdaQueryWrapper<KeyboardUserCharacter>()
.eq(KeyboardUserCharacter::getCharacterId, id)
.eq(KeyboardUserCharacter::getUserId, userId));
// 设置是否已添加的标记
respVO.setAdded(userCharacter != null);
return respVO;
}
} }

View File

@@ -191,7 +191,8 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
} }
KeyboardUser keyboardUser = BeanUtil.copyProperties(keyboardUserReq, KeyboardUser.class); KeyboardUser keyboardUser = BeanUtil.copyProperties(keyboardUserReq, KeyboardUser.class);
Integer i = keyboardUserMapper.updateByuid(keyboardUser); keyboardUser.setId(loginIdAsLong);
int i = keyboardUserMapper.updateById(keyboardUser);
if (i <=0 ) { if (i <=0 ) {
throw new BusinessException(ErrorCode.USER_INFO_UPDATE_FAILED); throw new BusinessException(ErrorCode.USER_INFO_UPDATE_FAILED);
} }