# ai_picture **Repository Path**: duyus/ai_picture ## Basic Information - **Project Name**: ai_picture - **Description**: 壁纸分享系统用来统一管理 编辑 分享 云图空间 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2025-06-16 - **Last Updated**: 2025-07-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 灵境云项目 redis -- root /root mysql -- root / 123 api -- localhost:5173 secerate -- 12345678 TODO:是否添加对第三方API服务调用的步骤 ## 架构 ![image-20250729091914889](image/image-20250729091914889.png) ## 项目初始化 ### 整合依赖 #### Mybatis plus依赖 #### Hutools #### 接口文档knife4j ### 基础通用代码 #### 自定义异常 `exception包` #### 响应包装类 `common包` #### 全局异常处理器 `exception包` #### 全局跨域配置 `config包: public class CorsConfig implements WebMvcConfigurer ` ### 多个用户权限如何设置 通过 模板方法设计模式,有一种可以构造不同 ### 门面设计模式 高内聚低耦合,实现内部细节不对外暴露 ### 解决循环依赖的问题 通过 `@Lazy`延迟加载 ### 统一的鉴权中心 Sa-Taken 利用框架实现AOP之外的空间鉴权,单独的对空间管理进行(空间的创建 团队的管理等) ### 分库分表 通过 独立的框架实现分库分表 ### 值得注意的问题 在从请求体中,通过servlet读取的时候,因为是流式的原因,只能读取一次,所以会出现这样的错误,我们通过修改配置即可 因为sa-token的报错问题,我们是通过全局异常控制 捕获到之后,在进行自定义的异常打印 ``getReader() has already been called for this request`` ### 策略模式 解决参数相同的重载方法 ### 异步线程 websorcket 解决 websorcket 同步消费的问题,使用了一个继承框架 来实现异步处理 #### websorcket 在于前端进行交互的时候 websorcket同样与Http请求一样 针对 long类型的变量,会有精度损失 要通过 配置 序列化器进行更改 ## 图片存储管理相关 ### 利用腾讯oss进行文件的管理上下载。 通过配置文件获取oss的属性 `@ConfigurationProperties(prefix = "cos.client")` 以图搜图,根据百度提供的API进行实现 AI扩图功能:根据阿里提供的API实现 图片压缩 图片压缩(转成 webp 格式) 上传的时候可以根据 tencent对于图片处理规则 进行压缩规则和缩略图规则的应用 颜色相似度查找:欧氏距离 有一个注意的点是 将颜色的16进制转化为 RGB三色来进行计算相似度 ### 支持 url和本地文件上传 url是下载到本地进行上传 通过调用同一个接口实现 两种方式的调用,具体实现是,首先默认为文件方式,根据参数的类别 如果是String类型就将方法调用变成 url上传的函数 #### 模板方法模式 FileManager 文件内写了两种不同的上传文件的 方法,但是我们会发现,这两种方法的 流程完全一致、而且 大多数代码都是相同的。 模板方法模式是行为型设计模式,适用于具有通用处理流 程、但处理细节不同的情况。通过定义一个抽象模板类,提 供通用的业务流程处理逻辑,并将不同的部分定义为抽象方法,由子类具体实现。 #### 批量摘取图片 Jsoup实现类似爬虫,jsoup 支持使用跟前端一致的选择器语法来定位 HTML 的元素,比如类选择器、CSS 选择器。 ### 缓存优化 #### 本地缓存 Caffeine, 缓存5分钟后删除, #### Redis缓存 1. 先从本地缓存中查询 2. 本地缓存没有从数据库中查询 #### 缓存一致性的问题 ## 空间 ### 需求分析 1)公共图库的访问权限与私有空间不同公共图库中的图片无需登录就能查看,任何人都可以访问,不需要进行用户认证或成员管理。 私有空间则要求用户登录,且访问权限严格控制,通常只有空间管理员(或团队成员)才能查看或修改空间内容。 2)公共图库没有额度限制:私有空间会有图片大小、数量等方面的限制,从而管理用户的存储资源和空间配额;而公共图库完全不受这些限制。 公共图库和私有空间在数据结构、图片存储、权限控制、额度管理等方面存在本质区别,如果混合设计,会增加系统的复杂度并影响维护与扩展性。举个例子:公共图库应该上传到对象存储的 public 目录,该目录里的文件可以公开访问;但私有图片应该上传到单独的 space 目录,该目录里的文件可以进一步设置访问权限。 ### 用户可以自主创建私有空间,但是必须要加限制,最多只能创建一个。 **如何保证同一用户只能创建一个私有空间呢?** 最粗暴的方式是给空间表的 userId 加上唯一索引,但由于后续用户还可以创建团队空间,这种方式不利于扩展。所以我们采用 加锁 + 事务 的方式实现。 ```java // 将用户ID转换为字符串并调用 intern() 方法,确保同一个 userId 使用的是同一个字符串锁对象 // 这样可以保证在分布式系统中使用相同的锁对象(前提是 JVM 内部缓存一致) String lock = String.valueOf(userId).intern(); // 使用 synchronized 加锁,确保同一时间只有一个线程可以执行该用户的创建空间操作 synchronized (lock) { // 使用事务模板执行数据库操作,确保整个流程在事务中进行 Long newSpaceId = transactionTemplate.execute(status -> { // 判断当前用户是否已经存在相同类型的空间 boolean exists = this.lambdaQuery() .eq(Space::getUserId, userId) // 查询条件:用户ID匹配 .eq(Space::getSpaceType, space.getSpaceType()) // 查询条件:空间类型匹配 .exists(); // 检查是否存在记录 // 如果已存在同类型空间,则抛出异常,不允许重复创建 ThrowUtils.throwIf(exists, ErrorCode.OPERATION_ERROR, "每个用户每类空间只能创建一个"); // 保存新空间到数据库 boolean result = this.save(space); // 如果保存失败,抛出异常 ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "保存空间到数据库失败"); // 如果是团队空间类型,需要额外处理团队成员关系 if (SpaceTypeEnum.TEAM.getValue() == space.getSpaceType()) { // 创建一个空间成员对象,表示该用户是这个团队空间的管理员 SpaceUser spaceUser = new SpaceUser(); spaceUser.setSpaceId(space.getId()); // 设置空间ID spaceUser.setUserId(userId); // 设置用户ID spaceUser.setSpaceRole(SpaceRoleEnum.ADMIN.getValue()); // 设置角色为管理员 // 保存团队成员信息到数据库 result = spaceUserService.save(spaceUser); // 如果保存失败,抛出异常 ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "创建团队成员记录失败"); } // 【注释部分】原本用于动态创建分表(仅对团队空间生效),但为了方便部署暂时禁用 // dynamicShardingManager.createSpacePictureTable(space); // 返回新创建的空间ID return space.getId(); }); } ``` 注意,上述代码中,我们使用本地 `synchronized` 锁对 `userId` 进行加锁,这样不同的用户可以拿到不同的锁,对性能的影响较低。在加锁的代码中,我们使用 Spring 的 编程式事务管理器 `transactionTemplate` 封装跟数据库有关的查询和插入操作,而不是使用 `@Transactional` 注解来控制事务,这样可以保证事务的提交在加锁的范围内。 虽然 `@Transactional` 注解提供了一种简单的方式来管理事务,但在某些特定场景下,直接使用 `TransactionTemplate` 提供了更大的灵活性和控制力。 上述代码中,我们是对字符串常量池(intern)进行加锁的,数据并不会及时释放。如果还要使用本地锁,可以按需选用另一种方式 —— 采用 ConcurrentHashMap 来存储锁对象。 ```java Map lockMap = new ConcurrentHashMap<>(); public long addSpace(SpaceAddRequest spaceAddRequest, User user) { Long userId = user.getId(); Object lock = lockMap.computeIfAbsent(userId, key -> new Object()); synchronized (lock) { try { } finally { lockMap.remove(userId); } } } ``` ## 协同编辑 通过本节,你将学习到多人实时协作功能的设计开发,涉及 WebSocket、事件驱动设计、Disruptor 无锁队列等技术知识 ```java ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ │ 用户 A │ │ 服务端 │ │ 用户 B/C │ │ (操作发起者) │ │ (事件处理与转发) │ │ (事件接收者) │ └──────┬───────┘ └─────────┬────────┘ └──────┬───────┘ │ │ │ │ 1. 本地操作生成事件 │ │ │ ┌───────────────────────┐ │ │ │ │ type: ZOOM_IMAGE │ │ │ │ │ payload: { scale: 1.5 }│ │ │ │ └───────────────────────┘ │ │ │ │ │ │ 2. WebSocket 发送事件 │ │ ├────────────────────────▶│ │ │ │ │ │ │ 3. 接收并验证事件 │ │ │ │ │ │ 4. 查找房间内其他用户 │ │ │ │ │ │ 5. 广播事件给 B / C │ │ │◀──────────────────────┤ │ │ │ │ │ │ │ │ 6. 收到事件 │ │ │ ┌───────────────────┐ │ │ │ │ type: ZOOM_IMAGE │ │ │ │ │ payload: { scale:1.5}│ │ │ │ └───────────────────┘ │ │ │ │ │ │ 7. 执行本地逻辑:放大图片 │ │ │ 8. 更新 UI 显示 │ │ │ │ ``` 相比于生产者直接调用消费者,事件驱动模型的主要优点在于 解耦 和 异步性。在事件驱动模型中,生产者和消费者不需要直接依赖于彼此的实现,生产者只需触发事件并将其发送到事件分发器,消费者则根据事件类型处理逻辑。这样多个消费者可以独立响应同一事件(比如一个用户旋转了图片,其他用户都能同步),系统更加灵活,可扩展性更强。此外,事件驱动还可以提升系统的 并发性 和 实时性,可以理解为多引入了一个中介来帮忙,通过异步消息传递,减少了阻塞和等待,能够更高效地处理多个并发任务。 ### WebSocket 和 http 首先要明确,WebSocket 是建立在 HTTP 基础之上的!WebSocket 的连接需要通过 HTTP 协议发起一个握手(称为 HTTP Upgrade 请求),这个握手请求是 WebSocket 建立连接的前提,表明希望切换协议;服务器如果支持 WebSocket,会返回一个 HTTP 101 状态码,表示协议切换成功。 ### 业务流程 * 建立连接之前,先进行用户权限校验;校验通过后,将登录用户信息、要编辑的图片信息保存到要建立的 WebSocket 连接的会话属性中。 * 建立连接成功后,将 WebSocket 会话保存到该图片对应的会话集合中,便于后续分发消息给其他会话。 * 前端将消息发送到后端,后端根据消息类型分发到对应的处理器。 * 处理器处理消息,将处理结果作为消息发送给需要的 WebSocket 客户端。 * 当前端断开连接时,删除会话集合中的 WebSocket 会话,释放资源。 ### WebSocket 拦截器 - 权限校验 由于 HTTP 和 WebSocket 的区别,我们不能在后续收到前端消息时直接从 request 对象中获取到登录用户信息,因此也需要通过 WebSocket 拦截器,为即将建立连接的 WebSocket 会话指定一些属性,比如登录用户信息、编辑的图片 id 等 从请求中获取参数,来判断是什么业务 获取当前用户是否有编辑的权限 ### WebSocket 处理器 WebSocket 处理器类,在连接成功、连接关闭、接收到客户端消息时进行相应的处理 使用Map来维护每个图片编辑的用户 以及维护每个图片所加入的用户也就是session ```java // 每张图片的编辑状态,key: pictureId, value: 当前正在编辑的用户 ID private final Map pictureEditingUsers = new ConcurrentHashMap<>(); // 保存所有连接的会话,key: pictureId, value: 用户会话集合 private final Map> pictureSessions = new ConcurrentHashMap<>(); ``` ### 解决协作冲突 同一时刻只允许一位用户进入编辑图片的状态,此时其他用户只能实时浏览到修改效果,但不能参与编辑;进入编辑状态的用户可以退出编辑,其他用户才可以进入编辑状态。类似于给图片编辑这个动作加了一把锁,直接从源头上解决了编辑冲突的问题。 ### 策略模式 由于处理器的代码并不复杂,而且处理逻辑中使用到了当前类的全局变量,所以鱼皮没有选择将每个处理器封装为单独的类。大家也可以将每个处理器封装为单独的类(相当于设计模式中的策略模式),并且根据消息类别调用不同的处理器类。 ### Disruptor 优化 Websocket #### WebSocket 的潜在缺点 **高并发处理:** 当大量客户端同时连接时,服务器需要处理大量的并发连接和消息传递,这可能会导致性能瓶颈。 **线程模型:** 传统的基于线程池的并发处理模型,在高并发情况下可能无法有效地利用系统资源,因为创建和销毁线程的成本较高,并且过多的线程会导致上下文切换频繁,降低系统效率。 **锁竞争:** 在多线程环境下访问共享资源(如消息队列)时,使用锁机制可能导致阻塞,影响系统的响应速度和吞吐量。 **内存管理:** 高频的消息传递可能导致频繁的垃圾回收(GC),这对应用程序性能有负面影响。 WebSocket 通常是长连接,每个客户端都需要占用服务器资源。在 Spring WebSocket 中,每个 WebSocket 连接(客户端)对应一个独立的 WebSocketSession,消息的处理是在该 WebSocketSession 所属的线程中执行。 如果 同一个 WebSocket 连接(客户端)连续发送多条消息,服务器会 按照接收的顺序依次同步处理,而不是并发执行。这是为了保证每个客户端的消息处理是线程安全的。 可以在 handleTextMessage 方法中增加 Thread.sleep 来测试一下。连续点击多次编辑操作,会发现每隔一段时间方法才会执行一次。 虽然多个客户端的消息处理是可以并发执行的,但是接受消息和具体处理某个消息使用的是 同一个线程。如果处理消息的耗时比较长,并发量又比较高,可能会导致系统响应时间变长,甚至因为资源耗尽而服务崩溃。 ### 解决方案 1. 异步操作 + 从任务队列取任务执行,使用线程池就可以实现了。 2. 但对于协同编辑场景,需要尽可能地保证低延迟,因此我们选用一种高级技术 Disruptor 无锁队列来减少线程上下文的切换,能够在高并发场景下保持低延迟和高吞吐量。 此外,使用 Disruptor 还有一个优点,可以将任务放到队列中,通过优雅停机机制,在服务停止前执行完所有的任务,再退出服务,防止消息丢失。 img.png ### TODO:了解Disruptor ## 用户中心相关 用户登陆采用的框架是 使用jwt令牌 + redis保存,针对密码进行加密处理,这里采用的是md5加盐 加密 保存用户的登录态到session环境中,同时利用redis保存session 过期时间为30天 记录用户登录态到 Sa-token,便于空间鉴权时使用,注意保证该用户信息与 SpringSession 中的信息过期时间一致 判断用户是否登陆,从session中获取,退出登陆,则是从session中移除用户信息 `` // 移除登录态 request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE); `` ### Aop实现鉴权 1. 定义一个注解,确定注解的生命周期和生效方式,并且制定一个角色属性 2. 定义一个 AuthInterceptor 切面类(Aspect),用于拦截所有标注了 @AuthCheck 注解的方法。 3. 在方法执行前,获取注解中指定的角色要求,获取当前登录用户信息,判断用户是否有权限访问该方法。如果权限校验通过,则继续执行目标方法;否则返回权限错误。 #### Aroud介绍 通知类型 执行时机 是否能控制目标方法执行 @Before 目标方法执行前 不能阻止目标方法执行 @After 目标方法执行后(无论是否异常) 不能阻止目标方法执行 @AfterReturning 目标方法正常返回后 不能阻止目标方法执行 @AfterThrowing 目标方法抛异常后 不能阻止目标方法执行 @Around 目标方法执行前后都可控制 可以决定是否执行目标方法 #### @Around 的执行流程 * 进入环绕通知方法 AOP 框架拦截目标方法调用,进入环绕通知。 * 执行前置逻辑 在调用 proceed() 之前,可以执行任何自定义逻辑(如权限校验、日志记录、事务开启等)。 * 调用目标方法 通过 joinPoint.proceed() 执行目标方法。 该方法会返回目标方法的返回值,或者抛出异常。 * 执行后置逻辑 在 proceed() 返回后,可以对返回值进行处理,或者执行清理工作。 * 返回结果或抛出异常 返回目标方法的结果,或者根据业务需求修改返回值。 也可以捕获异常并处理,或者重新抛出。 #### 使用 (切面表达式) 1) 按注解匹配 ```@Around("@annotation(com.example.AuthCheck)")``` 拦截所有标注了 @AuthCheck 注解的方法。 也可以用参数绑定: ```java @Around("@annotation(authCheck)") public Object around(ProceedingJoinPoint joinPoint, AuthCheck authCheck) { ... }``` 2) 按方法执行匹配 ` @Around("execution(* com.example.service.*.*(..))")` 拦截 com.example.service 包下所有类的所有方法。 语法:execution(modifiers? 返回类型 包名.类名.方法名(参数)) \* 表示通配符。 3) 按包匹配 `@Around("within(com.example.service..*)")` 拦截 com.example.service 包及其子包下所有类的所有方法。 4) 结合多个条件 `@Around("execution(* com.example..*Service.*(..)) && @annotation(authCheck)")` 只拦截 com.example 包下所有以 Service 结尾的类中,标注了 @AuthCheck 注解的方法。 ### Sa-token 鉴权 本项目没有采用sa-token实现登陆,仅仅用了其鉴权能力 1. 实现`StpInterface`接口 获取当前账号权限码集合 1. 空间成员权限管理实现对权限的具体获取,通过上面接口来调用,输入角色获得权限list `SaSpaceCheckPermission`是一个自定义注解,用于在方法或类上标注权限校验规则,确保只有具备指定权限的用户才能访问被标注的方法或类 1. **基础功能**: - 该注解基于`SaCheckPermission`(来自`sa-token`框架),并指定了权限类型为`StpKit.SPACE_TYPE`。 - 可以标注在方法或类上。如果标注在类上,则等同于标注在该类的所有方法上。 2. **核心属性**: - `value()`:需要校验的权限码数组。如果未指定,则默认为空数组。 - `mode()`:权限校验模式,支持`AND`(默认)或`OR`。`AND`表示需要同时满足所有权限,`OR`表示满足任意一个权限即可。 - `orRole()`:在权限校验不通过时的次要选择。可以指定角色数组,只要用户具备其中一个角色即可通过校验。 3. **使用示例**: - 示例1:`@SaSpaceCheckPermission(value = "user-add")` 表示用户需要具备`user-add`权限才能访问该方法。 - 示例2:`@SaSpaceCheckPermission(value = {"user-add", "user-delete"}, mode = SaMode.OR)` 表示用户需要具备`user-add`或`user-delete`权限之一即可访问。 - 示例3:`@SaSpaceCheckPermission(value = "user-add", orRole = "admin")` 表示用户需要具备`user-add`权限,或者具备`admin`角色。 4. **实现细节**: - 该注解通过`@AliasFor`将属性委托给`SaCheckPermission`,确保与`sa-token`框架的权限校验逻辑兼容。 - 适用于需要严格区分空间权限的场景,例如多租户系统中的资源访问控制。