OK啊,也是终于在前两天的时候完成了小学期的作业了,把ppt和word都整理了一下,然后也是最后在报告的前一天把所有的东西都串起来了做了个样板项目出来。然后老师也是给了一些我项目上的建议告诉我之后可以往架构优化上面再加些东西,然后我之后也会继续琢磨一下怎么把RAG检索和ai助手联系起来,前面其实都把Agent的开发都学的七七八八了,现在就是要开始想想怎么去优化和调整,让Ai更加的智能。然后休整了两天,今天开始整合复习整个项目,然后明天把这个写在简历上面了呕吼,想想都爽了。
一 数据库表的设计
为啥把这个放到第一位置呢?就是我觉得这个对于后续的开发真的很重要,很重要,整个的业务逻辑都是从这块出发的,库表没建好后续改起来都很麻烦,但是虽说麻烦,其实也要会去改造业务也不是一蹴而就的总要是不断的去更改整体的一个项目库表的但是注释一定要写好要不然还是白搭。自己在写的过程中遇到的真的很麻烦而且设计过一次就知道了,没有教训学不会🤡。
❗建表注意点
-
每个的表和字段都采用下划线的命名方法(avatar_url)
-
对于表的id都是用biging的数据类型,也不用去弄自增的策略,因为后续id都是用雪花算法生成唯一标识id的Long类型(Java中),然后这块就联想到了前端,前端没有Long类型的字段去接收,这是一个问题会造成精度缺少,后续这块我也查网上的资料解决了这个问题,就是传给前端都是一个string类型的字段就行了,建一个工具类去封装这个方法。还有一个方法就是前端接受的时候去修正,要不就是前端改,要不就是后端处理都行,然后对于那种非常固定的表中的id就采用int类型就行了,因为后续也不可能修改这个表中的数据了。
-
对于表中非”是/否“的字段采用的是varchar(20) 的这种类型,注释要写好要不然后续开发也挺麻烦的,然后在后端中使用枚举类去代表都是些什么意思。
-
对于时间类型datetime和timestamp的选择上可以看看,MySQL 中 datetime 和 timestamp 的区别与选择-腾讯云开发者社区-腾讯云但是简单上来讲就是如果想要自动插入时间或者自动更新时间功能的话就选择**timestamp类型就行,如果在时间上要超过Linux时间的,或者服务器时区不一样的就建议选择 datetime。如果想要datetime类型的数据也具有自动的插入当前时间的这个功能的话可以有个默认的值default CURRENT_TIMESTAMP** 这样去创建字段就可以实现。ON UPDATE CURRENT_TIMESTAMP这个的话就是说一旦表中的这条数据被更改就会自动的更新时间
-
外键约束的使用一般都不会去写,都是后端那边必须去写清楚,因为外键约束会影响表的性能,因为数据库必须对每个写操作执行额外的检查。反正我是觉得外键约束真的没啥用一般都是后端严谨些基本都没有问题,然后具体的公司里面是什么样的我也不清楚所以这个只是我的个人的看法,仅供参考。对mysql的外键使用不明确的可以看看这篇文章MySQL中外键的使用及外键约束策略-CSDN博客
**重要知识讲解:**修复进度缺失方法,写个Json的Config类
package com.rainbowsea.yupicturebackend.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.context.annotation.Bean;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
/**
* Spring MVC Json 配置
*/
@JsonComponent
public class JsonConfig {
/**
* 添加 Long 转 json 精度丢失的配置
*/
@Bean
public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
ObjectMapper objectMapper = builder.createXmlMapper(false).build();
SimpleModule module = new SimpleModule();
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
objectMapper.registerModule(module);
return objectMapper;
}
}
这些注意点都是我的来时路😭,基本就是这些了后续建完这些就很好了,后端这边可以直接采用mybatis的插件直接生成model和mapper和serrvice这些东西,可以根据自己的需求去选择按照下图选择基本没问题,使用之前记得按照好mybatisX的这个插件
二 基本后端的搭建
完成了数据库的设计之后就可以开始主线的一个任务了,开发了<( ̄︶ ̄)↗[GO!]。这块也有些坑需要去注意了,就是想要用springboot3.X的版本就必须要采用jdk11还是jdk17以上的版本,然后依赖管理的maven也要3.8的版本,像我现在用的jdk版本是21的话就是基本上都可以满足了,maven也要是3.9的以上版本,不要用jdk8了抓紧换吧,现在公司很少用了,基本都是jdk11往上了,普遍都是17版本了,就是为了开发更加的方便,springboot2.X的版本连mybatisplus都不支持,可想而知啊,然后这个也是我试过的一个特别坑的地方,(用过了mybaitsplus感觉换不回去mybatis了,这个真的好用)之前用若依去开发的时候就是这样,它的老版本不支持mybatisplus我还是自己去看了下博文才切换成了mybatisplus的支持版本的。
POM文件:下面就是一些我的基本的依赖配置的版本的pom文件了
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>net.xdclass</groupId>
<artifactId>dcloud-aipan</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>dcloud-aipan</name>
<description>dcloud-aipan</description>
<properties>
<java.version>21</java.version>
<aws-java-sdk-s3.version>1.12.730</aws-java-sdk-s3.version>
<mybatisplus.version>3.5.6</mybatisplus.version>
<hutool-all.version>5.8.27</hutool-all.version>
<common-io.version>2.8.0</common-io.version>
<fastjson.version>2.0.42</fastjson.version>
<mysql.version>8.0.27</mysql.version>
<lombok.version>1.18.30</lombok.version>
<jjwt.version>0.12.3</jjwt.version>
<knife4j.vesrion>4.4.0</knife4j.vesrion>
<velocity.version>2.0</velocity.version>
</properties>
<dependencies>
<!--Spring Boot Web 依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 切面 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- 数据库连接 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<!-- Lombook -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- AWS S3 SDK -->
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
<version>${aws-java-sdk-s3.version}</version>
</dependency>
<!-- JWT支持 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
<!-- MyBatis Plus 启动器 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<!-- 代码自动生成依赖 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>${mybatisplus.version}</version>
</dependency>
<!-- velocity -->
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>${velocity.version}</version>
</dependency>
<!-- 代码自动生成依赖 end-->
<!-- Hutool 依赖 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool-all.version}</version>
</dependency>
<!--Fastjson 依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<!-- knife4j 依赖 接口文档工具 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.vesrion}</version>
</dependency>
<!--webflux -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- 不用原生的 minio 依赖 -->
<!-- <dependency>-->
<!-- <groupId>io.minio</groupId>-->
<!-- <artifactId>minio</artifactId>-->
<!-- <version>8.3.7</version>-->
<!-- </dependency>-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>dashscope-sdk-java</artifactId>
<version>2.18.2</version>
<exclusions>
<exclusion>
<artifactId>lombok</artifactId>
<groupId>org.projectlombok</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.19.1</version>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
</plugins>
</build>
</project>
基本项目的一个目录结构
QPJ_AIPan [ddoud-alipan] // 项目根目录
├── src // 源码目录
│ ├── main
│ │ ├── java/net/dcloud // 主代码基础包(统一命名空间)
│ │ │ ├── annotation // 自定义注解(如权限、日志注解)
│ │ │ ├── aspect // AOP 切面(日志、事务、权限拦截逻辑)
│ │ │ ├── component // Spring 组件(工具类 Bean、自定义功能组件)
│ │ │ ├── config // 配置类(数据库、Redis、Swagger 等配置)
│ │ │ ├── controller // 控制器(处理 HTTP 请求,映射接口路径)
│ │ │ │ └── req // 控制器专用的请求参数 VO(入参对象)
│ │ │ ├── dto // 通用数据传输对象(响应体、跨模块数据交互)
│ │ │ ├── enums // 枚举类(状态码、业务类型、错误码等)
│ │ │ ├── exception // 自定义异常 + 全局异常处理器
│ │ │ ├── interceptor // 拦截器(登录校验、参数签名校验等)
│ │ │ ├── mapper // MyBatis Mapper 接口(数据库 CRUD 操作)
│ │ │ ├── model // 数据库实体类(对应表结构,含注解映射)
│ │ │ ├── service // 业务层接口(定义业务逻辑规范)
│ │ │ │ ├── common // 公共业务模块(复用性高的逻辑)
│ │ │ │ └── impl // 业务接口实现类(具体逻辑实现)
│ │ │ ├── util // 工具类(日期、加密、文件处理等通用工具)
│ │ │ └── DcloudAipanApplication // 应用启动类(Spring Boot 入口)
│ │ └── resources // 资源文件(application.yml、静态资源、模板等)
│ └── test // 测试目录(单元测试、集成测试代码)
├── pom.xml // Maven 依赖配置(管理项目依赖)
└── README.md // 项目说明文档(功能、部署、使用指南)
上面的基本的一个项目结构弄完了基本上就可以开始一个正常的开发了,这块的步骤其实都还挺固定的了,我相信公司的话是不太可能就是说从0开始开发把,都是有一个自己的一个基本的开发基建的,直接都是弄好的一些基本内容工具啥的,直接上手就可以直接快速的开发也可以节省很多的一些时间,无非就是多加点东西的事情。
然后接下来就是要开始完成基本的基建部分了
- 第一步就是去完成一个基础的配置yaml文件
server:
port: 8081
spring:
application:
name: dcloud-aipan
# 数据库
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://“URL”:"Port"/dcloud_aipan?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC&useSSL=false
username: XXX
password: XXX
# redis??
data:
redis:
host: XXX
port: XXX
password: XXX
# mybatis-plus
mybatis-plus:
global-config:
db-config:
logic-delete-field: del # 逻辑删除字段名
logic-delete-value: 1 # 逻辑已删除值
logic-not-delete-value: 0 # 逻辑未删除值
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true # 开启驼峰命名
# minio
minio:
endpoint: XXX
access-key: XXX
access-secret: XXX
bucket-name: dcloud-aipan
avater-bucket-name: avatar
# 雪花算法
snowflake:
node-id: 1 # 可根据部署实例设置不同值(0~1023)
# 配置阿里云百炼key
ai:
key: XXX
# 模型服务的配置
stream:
base-url: http://localhost:8000
chat-stream-path: /api/chat/stream
timeout:
connect: 30s
response: 5m
read: 5m
write: 5m
pool:
max-connections: 100
max-idle-time: 5m
max-life-time: 10m
pending-acquire-timeout: 30s
evict-in-background: 120s
- 创建基本的公共通用工具类
package net.xdclass.util;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.io.PrintWriter;
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description 通用工具类
*/
@Slf4j
public class CommonUtil {
/**
* 响应json数据给前端
*
* @param response
* @param obj
*/
public static void sendJsonMessage(HttpServletResponse response, Object obj){
response.setContentType("application/json");
try (PrintWriter writer = response.getWriter()){
writer.print(JsonUtil.obj2Json(obj));
response.flushBuffer();
} catch (IOException e){
log.warn("响应Json数据给前端异常:{}",e);
}
}
/**
* 获取文件后缀
* @param fileName
* @return
*/
public static String gerFileSuffix(String fileName){
return fileName.substring(fileName.lastIndexOf(".") + 1);
}
/**
* 根据文件后缀,生成文件存储:/yyyy/MM/dd/uuid.suffix 格式
* @param fileName
* @return
*/
public static String getFilePath(String fileName) {
String suffix = gerFileSuffix(fileName);
return StrUtil.format("{}/{}/{}/{}.{}", DateUtil.thisYear(),DateUtil.thisMonth() + 1, DateUtil.thisDayOfMonth(), IdUtil.randomUUID(), suffix);
}
}
- 特别重要的是响应前端请求的JsonData(utile)
package net.xdclass.util;
import com.alibaba.fastjson2.JSON;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import net.xdclass.enums.BizCodeEnum;
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description JsonData
* 封装数据返回给前端
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class JsonData {
/* 状态码 */
private int code;
/* 数据 */
private Object data;
/* 描述信息 */
private String msg;
/**
* 获取远程调用数据
* @param typeReference
* @param <T>
*/
public <T> T getData(Class<T> typeReference){
return JSON.parseObject(JSON.toJSONString(data), typeReference);
}
/**
* 成功,不传入数据
* @return
*/
public static JsonData buildSuccess(){
return new JsonData(0, null, "success");
}
/**
* 成功,传入数据
* @param data
* @return
*/
public static JsonData buildSuccess(Object data){
return new JsonData(0, data, "success");
}
/**
* 失败,传入描述信息
* @param msg
* @return
*/
public static JsonData buildError(String msg){
return new JsonData(-1, null, msg);
}
/**
* 自定义状态码和错误信息
* @param code
* @param msg
* @return
*/
public static JsonData buildError(int code, String msg){
return new JsonData(code, null, msg);
}
/**
* 自定义状态码和错误信息
* @param bizCodeEnum
* @return
*/
public static JsonData buildResult(BizCodeEnum bizCodeEnum){
return new JsonData(bizCodeEnum.getCode(), null, bizCodeEnum.getMessage());
}
public boolean isSuccess(){
return this.code == 0;
}
}
- 上面有提到自定义状态码和对应的信息,其实就是用到了个枚举的类
package net.xdclass.enums;
import lombok.Getter;
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description 业务枚举类
*/
public enum BizCodeEnum {
/**
* 账号
*/
ACCOUNT_REPEAT(250001,"账号已存在"),
ACCOUNT_UNREGISTER(250002,"账号不存在"),
ACCOUNT_ERROR(250003,"账号或密码错误"),
ACCOUNT_UNLOGIN(250004,"账号未登录"),
/**
* 文件操作相关
*/
FILE_NOT_EXIST(220404,"文件不存在"),
FILE_RENANE_REPEAT(220405,"文件名重复"),
FILE_DEL_BATCH_ILLEGAL(220406,"批量删除文件参数非法"),
FILE_TYPE_ERROR(220407,"文件类型错误"),
FILE_CHUNK_TASK_NOT_EXIST(230408,"分片任务不存在"),
FILE_CHUNK_NOT_ENOUGH(230409,"分片数量不足"),
FILE_STORAGE_NOT_ENOUGH(240403,"存储空间不足"),
FILE_TARGET_PARENT_ILLEGAL(250403,"目标父级目录不合法"),
SHARE_CANCEL_ILLEGAL(260403,"取消分享失败,参数不合法"),
SHARE_CODE_ILLEGAL(260404,"分享码不合法"),
SHARE_NOT_EXIST(260405,"分享不存在"),
SHARE_CANCELED(260406,"分享已取消"),
SHARE_EXPIRED(260407,"分享已过期"),
SHARE_FILEILLEGAL(260408,"分享文件不合规"),
FILE_NOT_BELONG_TO_USER(250404, "文件不属于当前用户"),
FILE_ID_IS_NULL(250405, "文件id为空"),
ACCOUNT_FILE_NOT_EXIST(250406, "账号文件不存在"),
TARGET_FILE_NOT_DIR(250407, "目标不是文件夹"),
FILE_MOVE_ERORE(250408,"文件移动部分错误" ),
FILE_CHUNMK_MERGE_FAIL(230404,"文件分片合并失败"),
SHARE_FILE_CANCEL_ERROR(260105,"取消分享失败"),
SHARE_FILE_NOT_EXIST(260106,"分享的文件不存在了" ),
SHARE_CODE_ERROR(260107,"分享码错误" ),
RECYCLE_FILE_DEL_ERROR(270101,"删除回收站文件错误" ),
RECYCLE_FILE_NOT_EXIST(270102,"回收站文件不存在" ),
RECYCLE_FILE_RESTORE_ERROR(270103,"恢复回收文件错误" );
@Getter
private String message;
@Getter
private int code;
private BizCodeEnum(int code, String message) {
this.code = code;
this.message = message;
}
}
- OK弄完了上面这两个自定义的响应数据之后,联想可知啊,如果说在业务的流程中有某块报错了,那就要返回一个错误的信息根据BizeCode编码,那么就可以自定义一个业务异常的Exception
package net.xdclass.exception;
import lombok.Data;
import net.xdclass.enums.BizCodeEnum;
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description 自定义业务异常
*/
@Data
public class BizException extends RuntimeException {
private int code;
private String msg;
private String detail;
public BizException(Integer code, String message){
super(message);
this.code = code;
this.msg = message;
}
public BizException(BizCodeEnum bizCodeEnum){
super(bizCodeEnum.getMessage());
this.code = bizCodeEnum.getCode();
this.msg = bizCodeEnum.getMessage();
}
public BizException(BizCodeEnum bizCodeEnum, Exception e){
super(bizCodeEnum.getMessage());
this.code = bizCodeEnum.getCode();
this.msg = bizCodeEnum.getMessage();
this.detail = e.getMessage();
}
}
package net.xdclass.exception;
import lombok.extern.slf4j.Slf4j;
import net.xdclass.util.JsonData;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description 自定义异常处理
*/
@ControllerAdvice
@Slf4j
public class CustomExceptionHandler {
@ExceptionHandler(value = Exception.class)
@ResponseBody
public JsonData handler(Exception e){
if (e instanceof BizException bizException){
log.error("业务异常:{}",e);
return JsonData.buildError(bizException.getCode(),bizException.getMsg());
}else{
log.error("系统异常:{}",e);
return JsonData.buildError("系统异常");
}
}
}
- OK啊现在就可以弄个SpringBeanUtil工具了,去实现MODEL转换为DTO类型一个方法工具
package net.xdclass.util;
import org.springframework.beans.BeanUtils;
import java.util.ArrayList;
import java.util.List;
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description SpringBeanUtil
*/
public class SpringBeanUtil {
/**
* 拷贝对象属性
* @param source
* @param target
* @param <T>
* @return
*/
public static <T> T copyProperties(Object source, Class<T> target){
try{
T t = target.getConstructor().newInstance();
BeanUtils.copyProperties(source, t);
return t;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 拷贝一份具有相同属性的列表
* @param sourceList
* @param target
* @param <T>
* @return
*/
public static <T> List<T> copyProperties(List<?> sourceList, Class<T> target){
ArrayList<T> targetList = new ArrayList<>();
sourceList.forEach(source -> {
T t = copyProperties(source, target);
targetList.add(t);
});
return targetList;
}
public static void copyProperties(Object source, Object target){
BeanUtils.copyProperties(source, target);
}
}
- 基础的配置类—AccountConfig(可以写在yaml配置文件中进行装配也可以直接写在config类中)
package net.xdclass.config;
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description 账号配置
*/
public class AccountConfig {
/**
* 账号密码密码盐
*/
public static final String ACCOUNT_SALT = "QPJ_ZUI_SHUAI";
/**
* 默认存储空间大小
*/
public static final Long DEFAULT_STORAGE_SIZE = 1024 * 1024 * 100L;
/**
* 根文件加名称
*/
public static final String ROOT_FOLDER_NAME = "全部文件夹";
/**
* 根文件夹的父id
*/
public static final Long ROOT_FOLDER_PARENT_ID = 0L;
/**
* 网盘的前端项目地址
*/
public static final String PAN_FRONT_DOMAIN_SHARE_API = "127.0.0.1:8080";
}
- Knife4jConfig
package net.xdclass.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description Knife4j配置类
*/
@Slf4j
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI openAPI(){
return new OpenAPI().info(new Info()
.title("AI云盘系统")
.version("1.0")
.description("AI云盘系统接口文档")
.termsOfService("https://xdclass.net")
.license(new License().name("Apache 2.0").url("https://xdclass.net"))
// 添加作者信息
.contact(new Contact()
.name("QPJ")
.email("1121390284@qq.com")
.url("https://xdclass.net")
)
);
}
}
OK了完成这些基本的Java的后端搭建就基本完成了,后续就可以开始一个业务的续写了。。。。
三 基本的业务逻辑
开始之前要先明白为了防止一个垂直和横向的数据泄露需要注意好数据的隔离安全性,以及要明白事务的回滚的时间是需要保持ACID四大特性(因为在数据库层面并没有做对应的外键约束处理)要不然数据保持不了一致。
登录/注册的逻辑(后续学习下怎么接入QQ/微信快捷登录吧)
这块的话密码都是采用的MD5 + salt 的一个方式去保护密码的一个安全性的,注册的时候会去校验一下手机号是否重复掉,然后前端去完成一个表单的校验,处理密码的时候就是会直接采用之前配置的用户salt + password 去完成MD5的加密处理,会对于新注册的用户会自动分配一个空的根目录和初始的一个存储内存。然后对于登录的Token也会有subject和密钥,所以对于登录这块的token校验也需要单独的写一个工具去完成。登录来说会先进行输入的密码加密处理以便于和后端数据库对比,然后查询条件再带上电话号码就行了,然后再将这个封装成一个AccountDTO的类型对象,通过JwtUtil生成一个token值,在校验并返回token值,这就是一个登录的逻辑了
- JwtUtil
package net.xdclass.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecureDigestAlgorithm;
import lombok.extern.slf4j.Slf4j;
import net.xdclass.dto.AccountDTO;
import javax.crypto.SecretKey;
import java.util.Date;
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description JWT工具类
*/
@Slf4j
public class JwtUtil {
/**
* 像下面这种一般都可以写在配置Config中或者直接在环境的yaml文件中配置也可以
*/
// 登录的JWT主题
private static final String LOGIN_SUBJECT = "QPJLEARN";
// 分享的JWT主题
private static final String SHARE_SUBJECT = "QPJAIPAN";
// 分享token过期时间, 1h
private static final long SHARE_TOKEN_EXPIRE = 1000 * 60 * 60;
// 密钥长度要足够长,从环境变量中获取
private static final String SECRET_KEY = "qpj_wu_di_ha_ha_ha_ha_ha_qpj_wu_di_ha_ha_ha_ha_ha_qpj_wu_di_ha_ha_ha_ha_ha";
// 分享的JWT自定义key
public static final String CLAIM_SHARE_KEY = "qpj.net666.qpj.net666.qpj.net666.qpj.net666.qpj.net666";
//签名算法
private static final SecureDigestAlgorithm<SecretKey,SecretKey> ALGORITHM = Jwts.SIG.HS256;
//使用密钥
private static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
//token过期时间,7天
private static final long EXPIRED = 1000 * 60 * 60 * 24;
/**
* 生成JWT
* @param accountDTO
* @return
*/
public static String geneLoginJWT(AccountDTO accountDTO){
if (accountDTO == null){
throw new NullPointerException("对象为空");
}
// 创建JWT token
String token = Jwts.builder()
.subject(LOGIN_SUBJECT)
.claim("accountId", accountDTO.getId())
.claim("username", accountDTO.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRED))
.signWith(KEY, ALGORITHM) // 直接使用密钥签名
.compact();
// 添加自定义前缀
return addPrefix(token,LOGIN_SUBJECT);
}
/**
* 校验JWT
* @param token
* @return
*/
public static Claims checkLoginJWT(String token){
try{
log.debug("开始校验 JWT:{}",token);
// 校验token是否为空
if (token == null || token.trim().isEmpty()){
throw new NullPointerException("token为空");
}
token = removePrefix(token,LOGIN_SUBJECT);
log.debug("移除前缀后的 Token: {}", token);
//解析JWT
Claims payload = Jwts.parser()
.verifyWith(KEY)
.build()
.parseSignedClaims(token)
.getPayload();
log.info("解析成功 !JWT payload: {}", payload);
return payload;
} catch (IllegalArgumentException e) {
log.error("JWT校验失败:{}", e.getMessage());
} catch (io.jsonwebtoken.security.SignatureException e){
log.error("JWT签名验证失败:{}", e.getMessage());
} catch (io.jsonwebtoken.ExpiredJwtException e){
log.error("JWT已过期:{}", e.getMessage());
} catch (Exception e){
log.error("JWT解析失败:{}", e.getMessage());
}
return null;
}
/**
* 添加自定义前缀
* @param token
* @return
*/
public static String addPrefix(String token, String prefix){
return prefix + token;
}
/**
* 移除自定义前缀
* @param token
* @return
*/
public static String removePrefix(String token, String prefix){
if (token.startsWith(prefix)){
return token.replace(prefix, "").trim();
}
return token;
}
/**
* 创建分享的令牌
*/
public static String geneShareJWT(Object claimValue) {
// 创建 JWT token
String compact = Jwts.builder()
.subject(SHARE_SUBJECT)
.claim(CLAIM_SHARE_KEY, claimValue)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + SHARE_TOKEN_EXPIRE))
.signWith(KEY, ALGORITHM) // 直接使用KEY即可
.compact();
return compact;
}
/**
* 解析分享的令牌
*/
public static Claims checkShareJWT(String token) {
try {
log.debug("开始校验 Share JWT: {}", token);
// 校验 Token 是否为空
if (token == null || token.trim().isEmpty()) {
log.error("Share Token 不能为空");
return null;
}
token = token.trim();
// 解析 JWT
Claims payload = Jwts.parser()
.verifyWith(KEY) // 设置签名的密钥,使用相同的 KEY
.build()
.parseSignedClaims(token).getPayload();
log.info("Share JWT 解密成功, Claims: {}", payload);
return payload;
} catch (IllegalArgumentException e) {
log.error("JWT 校验失败: {}", e.getMessage(), e);
} catch (io.jsonwebtoken.security.SignatureException e) {
// 这里原图片未显示完整,假设按逻辑补充处理
log.error("JWT 签名异常: {}", e.getMessage(), e);
} catch (io.jsonwebtoken.ExpiredJwtException e) {
log.error("JWT 已过期: {}", e.getMessage(), e);
} catch (Exception e){
log.error("JWT 解析失败: {}", e.getMessage(), e);
}
// 若有其他异常或处理,可补充返回逻辑,这里按图片现有内容假设
return null;
}
}
然后看到上方的一个JwtUtil中构建token主要是采用了accountId和username 再加上自定义的前缀完成的。所以可以想想知道可以通过拦截器校验token获取对应的accountid以便于做到信息安全性的一个线程隔离
登录拦截器
这块开始肯定需要去配置一下基本的拦截对象路径啥的,在程序被访问的时候就会先走装配的配置那块,不会说就是直接访问到了对应请求的路径中去。然后就可以写登录拦截器的逻辑了,就是解析token获取里面的一个accountid这个就是进程进行的时候首先执行获取到的一个值,当线程释放的时候就会消失。
- InterceptorConfig
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description 拦截器配置
*/
@Configuration
@Slf4j
public class InterceptorConfig implements WebMvcConfigurer {
@Resource
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
//添加拦截的路径 "/api/account/*/**", "/api/file/*/**", "/api/share/*/**" 先不添加拦截路径全部开放(开发环境)
.addPathPatterns( "/api/account/*/**", "/api/file/*/**", "/api/share/*/**", "/api/recycle/*/**")
//排除不拦截
.excludePathPatterns(
"/api/account/*/register",
"/api/account/*/login",
"/api/account/*/upload_avatar",
"/api/share/*/check_share_code",
"/api/share/*/visit",
"/api/share/*/detail_no_code",
"/api/share/*/detail_with_code"
);
}
}
- LoginInterceptor
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description 登录拦截器
*/
@Slf4j
@Component
public class LoginInterceptor implements HandlerInterceptor {
public static ThreadLocal<AccountDTO> threadLocal = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(PathItem.HttpMethod.OPTIONS.name().equalsIgnoreCase(request.getMethod())){
response.setStatus(HttpStatus.NO_CONTENT.value());
return true;
}
String token = request.getHeader("token");
if(StringUtils.isBlank(token)){
token = request.getParameter("token");
}
//如果存在token,则解析
if(StringUtils.isNotBlank(token)){
Claims claims = JwtUtil.checkLoginJWT(token);
if(claims == null){
log.info("token解析失败");
CommonUtil.sendJsonMessage(response, JsonData.buildResult(BizCodeEnum.ACCOUNT_UNLOGIN));
return false;
}
Long accountId = Long.valueOf(claims.get("accountId") +"");
String username = (String)claims.get("username");
//创建accountDTO
AccountDTO accountDTO = AccountDTO.builder()
.id(accountId)
.username(username)
.build();
threadLocal.set(accountDTO);
return true;
}
//没有token、未登录
CommonUtil.sendJsonMessage(response, JsonData.buildResult(BizCodeEnum.ACCOUNT_UNLOGIN));
return false;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清除threadLocal,避免内存泄露
threadLocal.remove();
}
}
重命名文件(包括文件夹)
基本的逻辑就是检查文件是否存在,然后判断下新旧文件名是否一致。再处理下同层级目录下的文件是否重名(就是查询parentId 和 fileName 和 isDir是否一致),如果查询到了一个数据都代表有重复的文件,然后就可以开始对文件夹和文件进行区分的一个重命名的处理了(这块的逻辑还需考虑下万一重命名之后的文件和同层级目录下的文件又有了冲突需要怎么处理文件名会更好些),
/**
* 文件重名处理
* @param accountFileDO
*/
@Override
public Long processFileNameDuplicate(AccountFileDO accountFileDO) {
Long selectCount = accountFileMapper.selectCount(new QueryWrapper<AccountFileDO>()
.eq("parent_id", accountFileDO.getParentId())
.eq("account_id", accountFileDO.getAccountId())
.eq("file_name", accountFileDO.getFileName())
.eq("is_dir", accountFileDO.getIsDir()));
if (selectCount > 0) {
// 处理重复的文件夹
if(Objects.equals(accountFileDO.getIsDir(), FolderFlagEnum.YES.getCode())){
accountFileDO.setFileName(accountFileDO.getFileName() + "_" + System.currentTimeMillis());
}
// 处理重复的文件
else{
// 处理重复的文件,提取文件的扩展名
String[] split = accountFileDO.getFileName().split("\\.");
accountFileDO.setFileName(split[0] + "(1)." + split[1]);
}
}
return selectCount;
}
构建文件夹树(两种方式方法)
@Override
public List<FolderNodeTreeDTO> folderTree(Long accountId) {
// 1.查询出当前用户所有文件夹
List<AccountFileDO> folderList = accountFileMapper.selectList(new QueryWrapper<AccountFileDO>()
.eq("account_id", accountId)
.eq("is_dir", FolderFlagEnum.YES.getCode())
);
if(CollectionUtils.isEmpty(folderList)){
return List.of();
}
// 2.构建数据源 Map key是文件id value是文件对象
Map<Long , FolderNodeTreeDTO> folderMap = folderList.stream()
.collect(Collectors.toMap(AccountFileDO::getId, accountFileDO ->
FolderNodeTreeDTO.builder()
.id(accountFileDO.getId())
.parentId(accountFileDO.getParentId())
.label(accountFileDO.getFileName())
.children(new ArrayList<>())
.build()
));
// 构建文件树,遍历数据源 为每个文件夹找到子文件夹
for (FolderNodeTreeDTO node : folderMap.values()){
Long parentId = node.getParentId();
if (parentId != null && folderMap.containsKey(parentId)){
// 获取父文件
FolderNodeTreeDTO parentNode = folderMap.get(parentId);
// 获取父文件夹的子节点
List<FolderNodeTreeDTO> children = parentNode.getChildren();
// 添加子节点
children.add(node);
}
}
// 过滤掉根节点
return folderMap.values().stream()
.filter(node -> node.getParentId().equals(0L))
.collect(Collectors.toList());
}
@Override
public List<FolderNodeTreeDTO> folderTreeV2(Long accountId) {
// 1.获取所有的文件夹
List<AccountFileDO> folderlist = accountFileMapper.selectList(new QueryWrapper<AccountFileDO>()
.eq("account_id", accountId)
.eq("is_dir", FolderFlagEnum.YES.getCode())
);
if(CollectionUtils.isEmpty(folderlist)){
return List.of();
}
// 2.将每个文件夹转换为FolderNodeTreeDTO类型数据
List<FolderNodeTreeDTO> FolderNodeTreeDTOList = folderlist.stream().map(file -> {
return FolderNodeTreeDTO.builder()
.label(file.getFileName())
.id(file.getId())
.parentId(file.getParentId())
.children(new ArrayList<FolderNodeTreeDTO>())
.build();
}).toList();
// 3.构建数据源 Key 是parentId, Value 是文件对象List
Map<Long, List<FolderNodeTreeDTO>> collect = FolderNodeTreeDTOList.stream()
.collect(Collectors.groupingBy(FolderNodeTreeDTO::getParentId));
// 4.处理拼装文件树
for (FolderNodeTreeDTO node : FolderNodeTreeDTOList) {
List<FolderNodeTreeDTO> children = collect.get(node.getId());
if(CollectionUtils.isEmpty(children)){
node.setChildren(children);
}
}
// 过滤空节点
return FolderNodeTreeDTOList.stream().filter(node -> Objects.equals(node.getParentId(), 0L)).toList();
}
检查目标文件夹id是否属于该用户的
这块除了作目标文件夹的归属校验之外,还需要进行是否是需要转移的文件夹的id查找判断。(这块除了判断文件也可以递归查询所有的子文件夹)
/**
* 检查目标文件id是否属于该用户的文件
* @param req
*/
@Override
public void validateFolderBelongToUser(FolderOperationRequest req, List<AccountFileDO> sourceFiles) {
// 1. 校验目标文件夹归属
AccountFileDO targetFolder = accountFileMapper.selectOne(new QueryWrapper<AccountFileDO>()
.eq("id", req.getTargetFolderId())
.eq("account_id", req.getAccountId())
.eq("is_dir", FolderFlagEnum.YES.getCode()));
if (targetFolder == null) {
throw new BizException(BizCodeEnum.FILE_NOT_EXIST);
}
// 2. 防止目标文件夹是源文件的子级(避免循环引用)
List<AccountFileDO> allSourceFolders = new ArrayList<>();
findAllcountFileDOWithRecur(allSourceFolders, sourceFiles, false);
if (allSourceFolders.stream().anyMatch(f -> f.getId().equals(req.getTargetFolderId()))) {
throw new BizException(BizCodeEnum.FILE_MOVE_ERORE); // 可复用或定义新错误码
}
}
/**
* 递归查找所有的子文件夹
* @param allAcountFileDOList
* @param existFileList
* @param
*/
@Override
public void findAllcountFileDOWithRecur(List<AccountFileDO> allAcountFileDOList, List<AccountFileDO> existFileList, boolean onlyFolder) {
for(AccountFileDO accountFileDO : existFileList){
if (Objects.equals(accountFileDO.getIsDir(), FolderFlagEnum.YES.getCode())){
List<AccountFileDO> childAccountFileDOList = accountFileMapper.selectList(new QueryWrapper<AccountFileDO>()
.eq("parent_id", accountFileDO.getId())
);
// 递归查找
findAllcountFileDOWithRecur(allAcountFileDOList, childAccountFileDOList, onlyFolder);
}
// 存储相关内容,避免重复
if (!onlyFolder || Objects.equals(accountFileDO.getIsDir(), FolderFlagEnum.YES.getCode())){
allAcountFileDOList.add(accountFileDO);
}
}
}
大文件上传
就是三步走,主要是为了防止文件过大,在传输的途中要是断网了那就前功尽弃了,所以方式就是把它打散成一个个的小文件上传,最后如果说上传完成那么就可以进行一个合并所有上传的文件成一个整体。为了体现上传是否完成也可以写一个查询上传进度的逻辑。在这块采用的数据存储是minio的,java这边就是一个AS3上传的一个协议,不采用minio上传是因为它原生的有点不太支持一个前端调用接口的逻辑。而且把它用as3写的话后续对于想要更换存储的硬件可以直接实现StoreEngine存储引擎这个接口类
- 首先是Minio的基本配置Config类
// 之前在yaml配置文件中有过minio的基本配置这边就是加载其中的就可以了
@Component
@Data
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
@Value("endpoint")
private String endpoint;
@Value("access-key")
private String accessKey;
@Value("access-secret")
private String accessSecret;
@Value("bucket-name")
private String bucketName;
@Value("avater-bucket-name")
private String avatarBucketName;
// 预签名 过期时间 ms
private Long preSignUrlExpireTime = 100 * 60 * 1000L; // 100 min
- 就是一个基础的存储引擎接口类
/**
* @author QPJ
* @date 2025/5/23 18:00
* @description 存储引擎接口
*/
public interface StoreEngine {
//==========Bucket相关============
/**
* 检查指定的存储桶中是否存在与当前的存储系统中
*
* @param bucketName
* @return
*/
boolean bucketExist(String bucketName);
/**
* 删除指定名称的存储桶
*
* @param bucketName
* @return
*/
boolean removeBucket(String bucketName);
/**
* 创建存储桶
*
* @param bucketName
* @return
*/
void createBucket(String bucketName);
/**
* 列出所有的存储桶
*
* @return
*/
List<Bucket> getAllBuckets();
//==========文件处理相关============
/**
* 列出指定存储桶下的所有文件
*
* @param bucketName
* @return
*/
List<S3ObjectSummary> listObjects(String bucketName);
/**
* 检查指定存储桶下是否存在指定名称的文件
*
* @param bucketName
* @param objectName
* @return
*/
boolean doesObjectExist(String bucketName, String objectName);
/**
* 上传文件
*
* @param bucketName
* @param objectName
* @param localFileName
* @return
*/
boolean upload(String bucketName, String objectName, String localFileName);
/**
* 将multipart上传文件
*
* @param bucketName
* @param objectName
* @param file
* @return
*/
boolean upload(String bucketName, String objectName, MultipartFile file);
/**
* 删除指定名称的文件
*
* @param bucketName
* @param objectKey
* @return
*/
boolean delete(String bucketName, String objectKey);
//========下载相关============
/**
* 获取指定名称文件的下载地址
*
* @param bucketName
* @param remoteFileName
* @param timeout
* @param unit
* @return
*/
String getDownloadUrl(String bucketName, String remoteFileName, long timeout, TimeUnit unit);
/**
* 下载文件到HTTP响应中
*
* @param bucketName
* @param objectKey
* @param response
*/
void download2Response(String bucketName, String objectKey, HttpServletResponse response);
//==========大文件上传接口================
/**
* 查询分片列表
*
* @param bucketName 存储桶名称
* @param objectKey 对象名称
* @param UploadId 上传ID
* @return 初始化分片上传的返回结果
*/
PartListing listMultiplePart(String bucketName, String objectKey, String UploadId);
/**
* 初始化分片上传,获取uploadId,如果初始化有uploadId说明是断点续传
*
* @param bucketName 存储桶名称
* @param objectKey 存储对象名称
* @param metadata 元数据
* @return 初始化分片上传的返回结果
*/
InitiateMultipartUploadResult initiaeMultipartUpload(String bucketName, String objectKey, ObjectMetadata metadata);
/**
* 生成预签名URL
*
* @param bucketName 存储桶名称
* @param objectKey 存储对象名称
* @param httpMethod HTTP方法
* @param expiration 签名过期时间(单位:秒)
* @param params 请求参数
* @return 预签名URL
*/
URL genePreSignedUrl(String bucketName, String objectKey, HttpMethod httpMethod, Date expiration, Map<String, Object> params);
/**
* 合并分片
*
* @param bucketName 存储桶名称
* @param objectKey 对象名称
* @param uploadId 上传ID
* @param partETags 分片ETag列表
* @return 合并分片上传的返回结果
*/
CompleteMultipartUploadResult mergeChunks(String bucketName, String objectKey, String uploadId, List<PartETag> partETags);
}
- 实现对应的存储接口的minio实现类
/**
* MinIO 文件存储引擎实现类
*/
@Component
@Slf4j
public class MinIOFileStoreEngine implements StoreEngine {
@Resource
private AmazonS3Client amazonS3Client;
/**
* 检查存储桶是否存在
* @param bucketName 存储桶名称
* @return 存在返回 true,否则 false
*/
@Override
public boolean bucketExist(String bucketName) {
if (StringUtils.isBlank(bucketName)) {
log.warn("存储桶名称为空");
return false;
}
try {
return amazonS3Client.doesBucketExistV2(bucketName);
} catch (Exception e) {
log.error("检查存储桶是否存在异常, bucketName={}", bucketName, e);
return false;
}
}
/**
* 删除存储桶
* @param bucketName 存储桶名称
* @return 删除成功返回 true,否则 false
*/
@Override
public boolean removeBucket(String bucketName) {
if (StringUtils.isBlank(bucketName)) {
log.warn("存储桶名称为空");
return false;
}
try {
if (bucketExist(bucketName)) {
amazonS3Client.deleteBucket(bucketName);
return true;
}
return false;
} catch (Exception e) {
log.error("删除存储桶异常, bucketName={}", bucketName, e);
return false;
}
}
/**
* 创建存储桶
* @param bucketName 存储桶名称
* @return 创建成功或已存在返回 true,否则 false
*/
@Override
public void createBucket(String bucketName) {
if (StringUtils.isBlank(bucketName)) {
log.warn("存储桶名称为空");
}
try {
if (!bucketExist(bucketName)) {
amazonS3Client.createBucket(bucketName);
} else {
log.warn("存储桶:{}已存在", bucketName);
}
} catch (Exception e) {
log.error("创建存储桶异常, bucketName={}", bucketName, e);
}
}
/**
* 获取所有存储桶列表
* @return 存储桶列表
*/
@Override
public List<Bucket> getAllBuckets() {
try {
return amazonS3Client.listBuckets();
} catch (Exception e) {
log.error("获取所有存储桶异常", e);
return List.of();
}
}
/**
* 列出存储桶中的文件
* @param bucketName 存储桶名称
* @return 文件摘要列表
*/
@Override
public List<S3ObjectSummary> listObjects(String bucketName) {
if (StringUtils.isBlank(bucketName)) {
log.warn("存储桶名称为空");
return List.of();
}
try {
if (!bucketExist(bucketName)) {
return List.of();
}
return amazonS3Client.listObjects(bucketName).getObjectSummaries();
} catch (Exception e) {
log.error("列出存储桶文件异常, bucketName={}", bucketName, e);
return List.of();
}
}
/**
* 检查文件是否存在
* @param bucketName 存储桶名称
* @param objectName 文件名称
* @return 存在返回 true,否则 false
*/
@Override
public boolean doesObjectExist(String bucketName, String objectName) {
if (StringUtils.isBlank(bucketName) || StringUtils.isBlank(objectName)) {
log.warn("存储桶或文件名为空");
return false;
}
try {
return bucketExist(bucketName) && amazonS3Client.doesObjectExist(bucketName, objectName);
} catch (Exception e) {
log.error("检查文件存在性异常, bucketName={}, objectName={}", bucketName, objectName, e);
return false;
}
}
/**
* 上传本地文件到存储桶
* @param bucketName 存储桶名称
* @param objectName 目标文件名
* @param localFileName 本地文件路径
* @return 上传成功返回 true,否则 false
*/
@Override
public boolean upload(String bucketName, String objectName, String localFileName) {
if (StringUtils.isBlank(bucketName) || StringUtils.isBlank(objectName) || StringUtils.isBlank(localFileName)) {
log.warn("参数为空");
return false;
}
try {
if (!bucketExist(bucketName)) {
return false;
}
amazonS3Client.putObject(bucketName, objectName, new File(localFileName));
return true;
} catch (Exception e) {
log.error("上传文件异常, bucketName={}, localFileName={}", bucketName, localFileName, e);
return false;
}
}
/**
* 上传 MultipartFile 到存储桶
* @param bucketName 存储桶名称
* @param objectName 目标文件名
* @param file 上传文件
* @return 上传成功返回 true,否则 false
*/
@Override
public boolean upload(String bucketName, String objectName, MultipartFile file) {
if (StringUtils.isBlank(bucketName) || StringUtils.isBlank(objectName) || file == null || file.isEmpty()) {
log.warn("参数为空或文件为空");
return false;
}
try {
if (!bucketExist(bucketName)) {
return false;
}
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentLength(file.getSize());
objectMetadata.setContentType(file.getContentType());
amazonS3Client.putObject(bucketName, objectName, file.getInputStream(), objectMetadata);
return true;
} catch (Exception e) {
log.error("上传 MultipartFile 异常, bucketName={}, objectName={}", bucketName, objectName, e);
return false;
}
}
/**
* 删除存储桶中的文件
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @return 删除成功返回 true,否则 false
*/
@Override
public boolean delete(String bucketName, String objectKey) {
if (StringUtils.isBlank(bucketName) || StringUtils.isBlank(objectKey)) {
log.warn("参数为空");
return false;
}
try {
if (!bucketExist(bucketName)) {
return false;
}
amazonS3Client.deleteObject(bucketName, objectKey);
return true;
} catch (Exception e) {
log.error("删除文件异常, bucketName={}, objectKey={}", bucketName, objectKey, e);
return false;
}
}
/**
* 生成文件的预签名下载 URL
* @param bucketName 存储桶名称
* @param remoteFileName 文件名
* @param timeout 超时时间
* @param unit 时间单位
* @return 预签名 URL,失败返回 null
*/
@Override
public String getDownloadUrl(String bucketName, String remoteFileName, long timeout, TimeUnit unit) {
if (StringUtils.isBlank(bucketName) || StringUtils.isBlank(remoteFileName) || timeout <= 0 || unit == null) {
log.warn("参数无效");
return null;
}
try {
Date expiration = new Date(System.currentTimeMillis() + unit.toMillis(timeout));
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucketName, remoteFileName)
.withExpiration(expiration)
.withMethod(HttpMethod.GET);
return amazonS3Client.generatePresignedUrl(request).toString();
} catch (Exception e) {
log.error("生成下载 URL 异常, bucketName={}, remoteFileName={}", bucketName, remoteFileName, e);
return null;
}
}
/**
* 下载文件到 HttpServletResponse
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @param response HTTP 响应
*/
@Override
public void download2Response(String bucketName, String objectKey, HttpServletResponse response) {
if (StringUtils.isBlank(bucketName) || StringUtils.isBlank(objectKey) || response == null) {
log.warn("参数为空");
return;
}
try (S3Object s3Object = amazonS3Client.getObject(bucketName, objectKey)) {
response.setHeader("Content-Disposition", "attachment;filename=" + objectKey.substring(objectKey.lastIndexOf("/") + 1));
response.setContentType("application/force-download");
response.setCharacterEncoding("UTF-8");
IOUtils.copy(s3Object.getObjectContent(), response.getOutputStream());
} catch (Exception e) {
log.error("文件下载异常, bucketName={}, objectKey={}", bucketName, objectKey, e);
try {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件下载失败");
} catch (Exception ex) {
log.error("发送错误响应失败", ex);
}
}
}
/**
* 获取分片列表
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @param UploadId 分片上传 ID
* @return 分片列表
*/
@Override
public PartListing listMultiplePart(String bucketName, String objectKey, String UploadId) {
try{
ListPartsRequest request = new ListPartsRequest(bucketName, objectKey, UploadId);
return amazonS3Client.listParts(request);
} catch (Exception e){
log.error("errorMsg={}", e);
return null;
}
}
/**
* 初始化分片上传
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @param metadata 文件元数据
* @return 初始化分片上传结果
*/
@Override
public InitiateMultipartUploadResult initiaeMultipartUpload(String bucketName, String objectKey, ObjectMetadata metadata) {
try {
InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(bucketName, objectKey, metadata);
return amazonS3Client.initiateMultipartUpload(request);
} catch (Exception e) {
log.error("errorMsg={}", e);
return null;
}
}
/**
* 生成预签名 URL
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @param httpMethod HTTP 方法
* @param expiration 过期时间
* @param params 参数
* @return 预签名 URL
*/
@Override
public URL genePreSignedUrl(String bucketName, String objectKey, HttpMethod httpMethod, Date expiration, Map<String, Object> params) {
try {
GeneratePresignedUrlRequest genePreSignedUrlReq =
new GeneratePresignedUrlRequest(bucketName, objectKey, httpMethod)
.withExpiration(expiration);
for (Map.Entry<String , Object> entry : params.entrySet()){
genePreSignedUrlReq.addRequestParameter(entry.getKey(), entry.getValue().toString());
}
return amazonS3Client.generatePresignedUrl(genePreSignedUrlReq);
} catch (Exception e){
log.error("errorMsg={}", e);
return null;
}
}
/**
* 合并分片
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @param uploadId 分片上传 ID
* @param partETags 分片列表
* @return 合并分片结果
*/
@Override
public CompleteMultipartUploadResult mergeChunks(String bucketName, String objectKey, String uploadId, List<PartETag> partETags) {
try {
CompleteMultipartUploadRequest request = new CompleteMultipartUploadRequest(bucketName, objectKey, uploadId, partETags);
return amazonS3Client.completeMultipartUpload(request);
} catch (Exception e) {
log.error("errorMsg={}", e);
throw new BizException(BizCodeEnum.FILE_CHUNMK_MERGE_FAIL);
}
}
}
- 初始化任务分片任务
/**
* 1-初始化文件分块上传任务
* @param req
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public FileChunkDTO initFileChunkTask(FileChunkInitTaskReq req) {
// 1.- 检查存储空间是否足够(合并文件的时候进行文件校验更新存储空间)
StorageDO storageDO = storageMapper.selectOne(new QueryWrapper<StorageDO>().eq("account_id", req.getAccountId()));
if (storageDO == null){
throw new BizException(BizCodeEnum.FILE_NOT_BELONG_TO_USER);
}
if (storageDO.getUsedSize() + req.getTotalSize() > storageDO.getTotalSize()){
throw new BizException(BizCodeEnum.FILE_STORAGE_NOT_ENOUGH);
}
// 2- 根据文件名推断内容类型
String objectKey = req.getFileName();
String contentType = MediaTypeFactory.getMediaType(objectKey).orElse(MediaType.APPLICATION_OCTET_STREAM).toString();
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(contentType);
// 3- 初始化分片上传,获取上传任务的ID
InitiateMultipartUploadResult uploadResult = storeEngine.initiaeMultipartUpload(minioConfig.getBucketName(), objectKey, metadata);
String uploadId = uploadResult.getUploadId();
// 4- 创建上传任务实体并设置相关属性
int chunkNum = (int)Math.ceil(req.getTotalSize() * 1.0 / req.getChunkSize());
FileChunkDO task = new FileChunkDO();
task.setBucketName(minioConfig.getBucketName())
.setChunkSize(req.getChunkSize())
.setChunkNum(chunkNum)
.setFileName(req.getFileName())
.setIdentifier(req.getIdentifier())
.setObjectKey(objectKey)
.setTotalSize(req.getTotalSize())
.setUploadId(uploadId)
.setAccountId(req.getAccountId())
;
// 5- 将任务插入数据库,构建并返回任务信息DTO
fileChunkMapper.insert(task);
return new FileChunkDTO(task);
}
- 通过fileName和文件的一个metadata数据去构建任务的上传ID–里面第三步骤的initiaeMultiparUpload
/**
* 初始化分片上传
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @param metadata 文件元数据
* @return 初始化分片上传结果
*/
@Override
public InitiateMultipartUploadResult initiaeMultipartUpload(String bucketName, String objectKey, ObjectMetadata metadata) {
try {
InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(bucketName, objectKey, metadata);
return amazonS3Client.initiateMultipartUpload(request);
} catch (Exception e) {
log.error("errorMsg={}", e);
return null;
}
}
- 获取分片上传的地址返回minio的一个临时签名地址
/**
* 2-获取预签名上传地址
* @param accountId
* @param identifier
* @param partNumber
* @return
*/
@Override
public String getPreSignUploadUrl(Long accountId, String identifier, Integer partNumber) {
// 根据accountId和identifier查询任务
FileChunkDO task = fileChunkMapper.selectOne(new QueryWrapper<FileChunkDO>()
.eq("account_id", accountId)
.eq("identifier", identifier)
);
if(task == null){
throw new BizException(BizCodeEnum.FILE_CHUNK_TASK_NOT_EXIST);
}
// 配置预签名的过期时间
DateTime expiration = DateUtil.offsetMillisecond(new Date(), minioConfig.getPreSignUrlExpireTime().intValue());
// 生成签名URL
Map<String, Object> params = new HashMap<>();
params.put("uploadId", task.getUploadId());
params.put("identifier", identifier);
params.put("partNumber", partNumber);
// 获得预签名的URL
return storeEngine.genePreSignedUrl(task.getBucketName(), task.getObjectKey(), HttpMethod.PUT, expiration, params).toString();
}
- 其中获取预签名的URLgenePreSignedUrl()
/**
* 生成预签名 URL
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @param httpMethod HTTP 方法
* @param expiration 过期时间
* @param params 参数
* @return 预签名 URL
*/
@Override
public URL genePreSignedUrl(String bucketName, String objectKey, HttpMethod httpMethod, Date expiration, Map<String, Object> params) {
try {
GeneratePresignedUrlRequest genePreSignedUrlReq =
new GeneratePresignedUrlRequest(bucketName, objectKey, httpMethod)
.withExpiration(expiration);
for (Map.Entry<String , Object> entry : params.entrySet()){
genePreSignedUrlReq.addRequestParameter(entry.getKey(), entry.getValue().toString());
}
return amazonS3Client.generatePresignedUrl(genePreSignedUrlReq);
} catch (Exception e){
log.error("errorMsg={}", e);
return null;
}
}
- 发送合并所有分片的请求,以到达上传文件的目的,首先第一步就是要校验分片上传的这个任务是否属于该用户,然后校验下分片的数量和分片任务是否匹配,再检查用户是否有足够多的一个存储空间,然后最后的一个文件大小还是会由minio中实际存储的空间大小去判定,不会说就是你说多少就是多少的,合并分片,判断合并的结果并存储文件和关系信息到数据库中,最后删除分片的任务
/**
* 3-合并文件分块
* @param req
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void mergeChunk(FileChunkMergeReq req) {
// 根据accountId和identifier查询任务
FileChunkDO task = fileChunkMapper.selectOne(new QueryWrapper<FileChunkDO>()
.eq("account_id", req.getAccountId())
.eq("identifier", req.getIdentifier())
);
if(task == null){
throw new BizException(BizCodeEnum.FILE_CHUNK_TASK_NOT_EXIST);
}
// 检查分块数量是否一致
PartListing partListing = storeEngine.listMultiplePart(task.getBucketName(), task.getObjectKey(), task.getUploadId());
List<PartSummary> parts = partListing.getParts();
if(parts.size() != task.getChunkNum()){
// 分片数量不匹配,需要删除上传的分片,释放存储空间。 合并失败
throw new BizException(BizCodeEnum.FILE_CHUNK_NOT_ENOUGH);
}
// 检查用户是否有足够多的空间存储,没有的话就上传失败
StorageDO storageDO = storageMapper.selectOne(new QueryWrapper<StorageDO>()
.eq("account_id", req.getAccountId())
);
// 从minio中获取分片的所size统计出来存储空间大小
Long realSize = parts.stream().map(PartSummary::getSize).mapToLong(Long::valueOf).sum();
// 检查存储空间是否足够
if (storageDO.getUsedSize() + realSize > storageDO.getTotalSize()){
throw new BizException(BizCodeEnum.FILE_STORAGE_NOT_ENOUGH);
}
// 更新用户存储空间
storageDO.setUsedSize(storageDO.getUsedSize() + realSize);
storageMapper.updateById(storageDO);
// 合并分片
CompleteMultipartUploadResult result = minIOFileStoreEngine.mergeChunks(task.getBucketName(), task.getObjectKey(), task.getUploadId(),
parts.stream().map(part ->
new PartETag(part.getPartNumber(), part.getETag()))
.collect(Collectors.toList())
);
// 判断合并结果 存储文件和关联信息到数据库中
if(result.getETag() != null){
FileUploadReq fileUploadReq = new FileUploadReq();
fileUploadReq.setAccountId(req.getAccountId())
.setFileName(task.getFileName())
.setParentId(req.getParentId())
.setIdentifier(req.getIdentifier())
.setFileSize(task.getTotalSize())
.setFile(null);
// 保存文件信息(包括了实际文件的数据库file表的存储)
accountFileService.saveFileAndAccountFile(fileUploadReq, task.getObjectKey());
// 删除分片任务
fileChunkMapper.deleteById(task.getId());
}else{
// 分片任务合并失败
throw new BizException(BizCodeEnum.FILE_CHUNMK_MERGE_FAIL);
}
}
- 查询上传任务的分片列表
/**
* 获取分片列表
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @param UploadId 分片上传 ID
* @return 分片列表
*/
@Override
public PartListing listMultiplePart(String bucketName, String objectKey, String UploadId) {
try{
ListPartsRequest request = new ListPartsRequest(bucketName, objectKey, UploadId);
return amazonS3Client.listParts(request);
} catch (Exception e){
log.error("errorMsg={}", e);
return null;
}
}
- 合并分片
/**
* 合并分片
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @param uploadId 分片上传 ID
* @param partETags 分片列表
* @return 合并分片结果
*/
@Override
public CompleteMultipartUploadResult mergeChunks(String bucketName, String objectKey, String uploadId, List<PartETag> partETags) {
try {
CompleteMultipartUploadRequest request = new CompleteMultipartUploadRequest(bucketName, objectKey, uploadId, partETags);
return amazonS3Client.completeMultipartUpload(request);
} catch (Exception e) {
log.error("errorMsg={}", e);
throw new BizException(BizCodeEnum.FILE_CHUNMK_MERGE_FAIL);
}
}
查询分片上传的进度
先是判断上传的这个任务是否属于对应的登录用户的,然后判断文件在服务器中是否存在,存在的话就可以调用listMultiplePart去获取那个minio端上传完成的一些任务分片,然后和存储在数据库中的chunkNum进行对比看是否相等,相等就返回true就行了
@Override
public FileChunkDTO listFileChunk(Long accountId, String identifier) {
FileChunkDO task = fileChunkMapper.selectOne(new QueryWrapper<FileChunkDO>()
.eq("identifier", identifier)
.eq("account_id", accountId)
);
if(task == null){
throw new BizException(BizCodeEnum.FILE_CHUNK_TASK_NOT_EXIST);
}
FileChunkDTO result = new FileChunkDTO(task);
// 判断文件在服务器那块是否存在
boolean objectExist = minIOFileStoreEngine.doesObjectExist(task.getBucketName(), task.getObjectKey());
PartListing partListing = minIOFileStoreEngine.listMultiplePart(task.getBucketName(), task.getObjectKey(), task.getUploadId());
if (!objectExist){
if (partListing.getParts().size() == task.getChunkNum()){
// 已经上传完毕可以合并
result.setFinished(true).setExistingParts(partListing.getParts());
} else{
// 未完成上传
result.setFinished(false).setExistingParts(partListing.getParts());
}
}
return result;
}
- 查询在minio端是否存在这个分片上传任务
/**
* 检查文件是否存在
* @param bucketName 存储桶名称
* @param objectName 文件名称
* @return 存在返回 true,否则 false
*/
@Override
public boolean doesObjectExist(String bucketName, String objectName) {
if (StringUtils.isBlank(bucketName) || StringUtils.isBlank(objectName)) {
log.warn("存储桶或文件名为空");
return false;
}
try {
return bucketExist(bucketName) && amazonS3Client.doesObjectExist(bucketName, objectName);
} catch (Exception e) {
log.error("检查文件存在性异常, bucketName={}, objectName={}", bucketName, objectName, e);
return false;
}
}
- 获取上传的完成的分片数量
/**
* 获取分片列表
* @param bucketName 存储桶名称
* @param objectKey 文件键
* @param UploadId 分片上传 ID
* @return 分片列表
*/
@Override
public PartListing listMultiplePart(String bucketName, String objectKey, String UploadId) {
try{
ListPartsRequest request = new ListPartsRequest(bucketName, objectKey, UploadId);
return amazonS3Client.listParts(request);
} catch (Exception e){
log.error("errorMsg={}", e);
return null;
}
}
获取多文件的下载路径
首先肯定也是校验文件的一个归属,然后一个个调用getDownloadUrl获取minio的服务器下载地址
/**
* 批量下载文件
* @param accountId
* @param req
* @return
*/
@Override
public List<FileDownDTO> batchDownloadUrl(Long accountId, FileBatchDownloadReq req) {
List<AccountFileDO> accountFileDOList = accountFileMapper.selectList(new QueryWrapper<AccountFileDO>()
.eq("account_id", accountId)
.eq("is_dir", FolderFlagEnum.NO.getCode())
.in("id", req.getFileIds())
);
List<FileDownDTO> fileDownDTOList = new ArrayList<>();
accountFileDOList.forEach(accountFileDO -> {
String objectKey = fileMapper.selectOne(new QueryWrapper<FileDO>()
.eq("id", accountFileDO.getFileId())
).getObjectKey();
//获取下载路径
String downLoadUrl = storeEngine.getDownloadUrl(minioConfig.getBucketName(), objectKey, minioConfig.getPreSignUrlExpireTime(), TimeUnit.MILLISECONDS);
// 创建FileDownDTO对象
FileDownDTO fileDownDTO = FileDownDTO.builder()
.fileName(accountFileDO.getFileName())
.fileUrl(downLoadUrl)
.build();
// 添加到集合中
fileDownDTOList.add(fileDownDTO);
});
return fileDownDTOList;
}
- 获取下载地址
/**
* 生成文件的预签名下载 URL
* @param bucketName 存储桶名称
* @param remoteFileName 文件名
* @param timeout 超时时间
* @param unit 时间单位
* @return 预签名 URL,失败返回 null
*/
@Override
public String getDownloadUrl(String bucketName, String remoteFileName, long timeout, TimeUnit unit) {
if (StringUtils.isBlank(bucketName) || StringUtils.isBlank(remoteFileName) || timeout <= 0 || unit == null) {
log.warn("参数无效");
return null;
}
try {
Date expiration = new Date(System.currentTimeMillis() + unit.toMillis(timeout));
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucketName, remoteFileName)
.withExpiration(expiration)
.withMethod(HttpMethod.GET);
return amazonS3Client.generatePresignedUrl(request).toString();
} catch (Exception e) {
log.error("生成下载 URL 异常, bucketName={}, remoteFileName={}", bucketName, remoteFileName, e);
return null;
}
}
基本上比较有特点和难点的业务基本上都是上面的这些,这样看起来我真的没感觉有多少,但是对于一个不懂网盘业务的人来说这些理解起来还是很费解的,反正当时学的时候就很懵懂不知道要去做些什么,但是现在回过头来看感觉也就那样了,没啥东西,不过看到很多的博客中也有人文就是类似的存储该怎么去设计数据库和代码的写的时候其实感觉这些东西真的很有用,至少是让你明白了其实windows的系统其实也就是那样就是文件的存储哈哈😂,写到这里就已经下午3.40了,我从早上的8.00开始的中午休息了两个小时,感觉东西写到这里真的很多啊。<( ̄︶ ̄)↗[GO!]加油
四 Agent的业务逻辑
对于这块我是用python端去写的业务,所以整体上没有说像java一样那么的死板吧,然后整体的结构框架也不一定按照我的来,可以和java的一样也可以按自己的心意来构建整体看起来规范就行了。
各个文件夹的作用:
-
agent:就是主要的一个智能体的构建和流式输出响应编写的逻辑地址,不会有处理用户输入的信息的逻辑
-
services:就是各个种类的智能体处理输入,和初始化模型的一个逻辑
-
app:就是主程序入口
-
core:核心的主要配置
-
logs:日志记录
-
routers:请求的路径和发送的逻辑
-
tools:就是大模型调用工具的集合
这块请求的响应是采用了FastApi的框架去实现的,Ai的模块就是采用LangChain的生态框架去完成的。
整体的项目框架就是先去构建主要的函数入口mian.py
import uvicorn,logging
from fastapi import FastAPI
from core.config import settings
from core.exceptions import ApiException, api_exception_handler
from routers import chat,doc,pan
from fastapi.middleware.cors import CORSMiddleware
# 开启日志存储在本地中
logging.basicConfig(
level=logging.INFO,
filename="logs/app.log",
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
encoding="utf-8",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 配置类似于swagger的一个api请求中心信息
app = FastAPI(
title=settings.APP_NAME,
description="AI智能体中心API服务",
version="0.1.0",
)
# 开启跨域配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 异常处理
app.add_exception_handler(ApiException, api_exception_handler)
# 路由
app.include_router(chat.router)
app.include_router(doc.router)
app.include_router(pan.router)
@app.get("/", description="获取API服务信息", tags=["root目录请求"])
async def root():
return {
"message": "欢迎使用AI智能体中心API服务",
"version": "0.1.0",
"available_agents": ["chat","doc","pan"], # 列出可用的智能体
}
# 运行主程序
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
自定义异常处理
from fastapi import HTTPException, status
from fastapi.responses import JSONResponse
from typing import Any
from models.json_response import JsonData
class ApiException(Exception):
""" API异常"""
def __init__(
self,
msg: str = "操作失败",
code: int = -1,
data: Any = None,
):
self.msg = msg
self.code = code
self.data = data
super().__init__(msg)
async def api_exception_handler(request, exc: Exception) -> JSONResponse:
""" 异常处理 """
print(f"Api处理错误❌:{str(exc)}")
if isinstance(exc, ApiException):
response = JsonData.error(msg=exc.msg, code=exc.code)
elif isinstance(exc, HTTPException):
response = JsonData.error(msg=exc.detail, code=exc.status_code)
else:
# 处理其他所有异常、包括验证错误
response = JsonData.error(msg=str(exc), code=-1)
return JSONResponse(
status_code=status.HTTP_200_OK,
content=response.model_dump(),
)
大语言模型的一个初始化LLM
from langchain_openai import ChatOpenAI
from core.config import settings
def get_deafault_llm():
return ChatOpenAI(
model_name=settings.LLM_MODEL_NAME,
base_url=settings.LLM_BASE_URL,
api_key=settings.LLM_API_KEY,
temperature=settings.LLM_TEMPERATURE,
stream_usage=settings.LLM_STREAMING,
)
定义一个登录类(校验token是否正确,然后获取对应的accountId)这块和登录拦截器其实很像都是去解析token获取accountid
import logging,os,sys
from fastapi import Depends, FastAPI,Header
from jose import JWTError, jwt
from pydantic import BaseModel
from datetime import datetime, timedelta, UTC
from typing import Optional,Dict,Any
from core.config import settings
from core.exceptions import ApiException
# 创建日志,
logger = logging.getLogger(__name__)
def remove_prefix(token: str):
"""去除token前缀"""
# 去除前缀,替换成空字串
return token.replace(settings.JWT_LOGIN_SUBJECT, "").strip() if token.startswith(settings.JWT_LOGIN_SUBJECT) else token
def get_current_user(token:Optional[str] = Header(None)) -> Dict[str, Any]:
"""
获取当前用户信息
Args:
token: JWT token字符串,从请求头中获取
Returns:
Dict[str, Any]: 用户信息
Raises:
ApiException: 如果token为空或者无效,则抛出ApiException异常
"""
try:
# 验证token是否存在
logger.info(f"开始验证token{token}")
if not token or not token.strip():
logger.info("token获取不到")
raise ApiException(msg="请先登录",code=-1)
# 移出token前缀
token = remove_prefix(token)
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
# 验证token是否正确
if payload.get("sub") != settings.JWT_LOGIN_SUBJECT or not payload.get("accountId"):
logger.error("无效的认证凭证")
raise ApiException(msg="无效的认证凭证",code=-1)
# 记录登录信息
logger.info(f"用户登录成功: {payload.get('accountId')} , 用户名: {payload.get('username')}")
# 获取用户信息
return {"account_id" : payload.get("accountId"), "username" : payload.get("username")}
except Exception as e:
raise ApiException(msg="请先登录",code=-1)
构建基本的响应
from pydantic import BaseModel
from typing import Any,Optional,Literal
class JsonData(BaseModel):
"""json数据响应"""
code: int = 0
data: Optional[Any] = None
msg: Optional[str] = None
type: Literal["stream","text"] # 会强制限定变量值必须严格等于指定的字面量
"""创建各种的请求响应"""
# 创建成功响应
@classmethod
def success(cls,data:Any)->"JsonData":
return cls(code=0,data=data,type="text")
# 创建错误响应
@classmethod
def error(cls,code:int = -1,msg:str="Error")->"JsonData":
return cls(code=code,msg=msg,type="text")
# 创建流式数据响应
@classmethod
def stream_data(cls,data:Any,msg:str="")->"JsonData":
return cls(code=0,data=data,msg=msg,type="stream")
构建Ai智能体的请求model
from typing import Optional
from pydantic import BaseModel
class ChatRequest(BaseModel):
"""聊天请求"""
message: str
stream: Optional[bool] = True
构建基本的请求路径和请求的逻辑
from fastapi import Depends,APIRouter
from fastapi.responses import StreamingResponse
from core.auth import get_current_user
from services.chat_service import ChatService
from models.chat_schemas import ChatRequest
from typing import Dict,Any
from agent.chat_agent import generate_stream_response
import logging
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/chat",
tags=["Ai聊天助手"],
dependencies=[],
responses={404: {"description": "Not found"}},
)
@router.post("/stream")
async def chat_stream(
request: ChatRequest,
chat_service: ChatService = Depends(ChatService),
current_user: Dict[str, Any] = Depends(get_current_user),
):
account_id = current_user.get("account_id")
logger.info(f"accout_id = {account_id}")
return StreamingResponse(
generate_stream_response(chat_service,account_id,request.message),
media_type="text/event-stream"
)
然后构建Service层的逻辑就可以了,在这之前记得要去构建对应Agent的一个类,去初始化和对应响应的一个逻辑层就ok了整体的链路就是这么多了,这块我还得要再多琢磨一下,目前就先记录这么多吧,然后后续今天我再统筹下,之前得文章做个每天都将得主要内容,要不然后续复习的时候倒回来看有点懵,把整个的都讲清楚就行,然后前面讲完了整个的Agent的开发的每一个步骤细节调优的体系了,后面没有说再更新fastapi的内容主要是因为当时没有时间去写总结了,然后确实这块的内容也比较的简单我也觉得没有必要去学习了,所以说就没弄了,这块自己学习我觉得就ok了,和java对比一下其实都差不太多。