Compare commits

...

35 Commits

Author SHA1 Message Date
88d9463e25 Merge branch 'refs/heads/facebody_async'
Some checks are pending
ZhenTu-BE/pipeline/head Build queued...
2025-12-01 10:39:11 +08:00
590a7c6191 feat(printer): 打印任务更新后推送至打印机
- 在任务更新后调用推送服务将任务发送至指定打印机
- 记录任务更新完成的日志信息
2025-12-01 10:09:11 +08:00
d590286b13 feat(printer): 实现打印机任务WebSocket实时推送功能
- 新增PrinterTaskPushService接口及实现,负责任务推送逻辑
- 在PrinterServiceImpl中集成WebSocket推送,在任务创建和审核通过时主动推送
- 新增WebSocket配置类和处理器,支持打印机通过WebSocket连接接收任务
- 实现连接管理器,维护打印机在线状态并支持心跳保活
- 添加相关模型类如WsMessage、WsMessageType等,规范通信协议
- 在PrinterMapper中增加查询待处理任务列表的方法
- 完善异常处理和日志记录,确保推送可靠性
2025-12-01 09:59:27 +08:00
b92568b842 feat(face): 实现账号级人脸识别调度器
- 新增账号级别调度器管理器,支持多账号QPS隔离控制
- 为阿里云和百度云适配器添加配置getter方法
- 移除原有阻塞式限流逻辑,交由外层调度器统一管控
- 创建QPS调度器实现精确的任务频率控制
- 新增监控接口用于查询各账号调度器运行状态
- 重构人脸识别Kafka消费服务,集成账号调度机制
- 优化线程池资源配置,提升多账号并发处理效率
- 增强错误处理与状态更新的安全性
- 删除旧版全局线程池配置类
- 完善任务提交与状态流转的日志记录
2025-11-29 23:50:24 +08:00
1de760fc87 fix(image): 修复JPEG文件上传路径问题
- 从文件名中提取扩展名并标准化为小写
- 将.jpg扩展名统一转换为.jpeg
- 更新上传路径以包含正确的图像类型目录
- 保持原有公共读取权限设置
2025-11-29 19:41:25 +08:00
4e9aac4cf3 chore(threadpool): 调整人脸识别线程池配置
- 将核心线程数从32增加到128
- 将最大线程数从128增加到256
- 将空闲线程存活时间从60秒减少到10秒
- 将任务队列容量从1000调整为1024
2025-11-29 12:41:17 +08:00
aa43d14316 fix(printer): 处理空人脸样本导致的异常
- 添加空人脸样本检查,避免空指针异常
- 当人脸样本不存在时,设置响应状态为404
- 提前返回,防止后续逻辑执行
2025-11-29 12:07:27 +08:00
a2d87e7fdc refactor(product): 移除商品类型能力缓存配置
- 删除类级别的缓存配置注解
- 移除方法上的缓存注解
- 简化缓存刷新逻辑
- 更新相关方法签名
- 清理缓存策略文档注释
- 调整依赖注入方式以适应无缓存场景
2025-11-28 13:37:39 +08:00
57be6aa983 fix(redis): 配置Redis缓存管理器以防止ClassCastException
- 添加BasicPolymorphicTypeValidator以处理多态类型
- 在ObjectMapper中激活默认类型检查
- 更新Redis序列化配置以支持类型安全
- 防止因类型转换导致的运行时异常
- 确保JavaTimeModule与类型检查兼容
- 统一Redis缓存和模板的序列化配置
2025-11-28 13:37:26 +08:00
cacb22a7bd feat(cache): 配置Redis缓存管理器以支持Java时间序列化
- 添加Jackson ObjectMapper和JavaTimeModule依赖
- 配置RedisCacheConfiguration使用Jackson2JsonRedisSerializer序列化值
- 在RedisTemplate中设置值和哈希值的序列化器为Jackson2JsonRedisSerializer
- 启用对LocalDateTime等Java 8时间类型的序列化支持
2025-11-28 12:58:13 +08:00
300edbe582 refactor(device): 整合设备相关Feign客户端接口
- 将DefaultConfigClient、DeviceConfigV2Client、DeviceStatusClient的功能整合到DeviceV2Client
- 更新DeviceConfigIntegrationService、DeviceDefaultConfigIntegrationService和DeviceStatusIntegrationService依赖为DeviceV2Client
- 移除独立的设备配置与状态客户端接口文件
- 保留原有API路径结构并调整为统一前缀管理

refactor(render): 整合渲染工作器相关Feign客户端接口

- 将RenderWorkerConfigV2Client功能整合到RenderWorkerV2Client
- 更新RenderWorkerConfigIntegrationService依赖为RenderWorkerV2Client
- 移除独立的渲染工作器配置客户端接口文件
- 保留原有API路径结构并调整为统一前缀管理

refactor(scenic): 整合景区相关Feign客户端接口

- 将ScenicConfigV2Client和DefaultConfigClient的功能整合到ScenicV2Client
- 更新ScenicConfigIntegrationService和ScenicDefaultConfigIntegrationService依赖为ScenicV2Client
- 移除独立的景区配置与默认配置客户端接口文件
- 保留原有API路径结构并调整为统一前缀管理
2025-11-28 11:51:04 +08:00
9219ea4ab0 feat(price): 新增根据商品类型查询简化商品列表接口
- 在 PriceBiz 中新增 listSimpleGoodsByScenic 方法,支持按 productType 查询不同数据源
- 新增对多种商品类型的处理逻辑,包括 VLOG_VIDEO、PHOTO、PHOTO_SET 等
- 为兼容旧逻辑,增加 listAllSimpleGoods 方法轮询所有启用的商品类型
- 在 PriceConfigController 中修改 goodsList 接口,支持 productType 参数并返回简化商品列表
- 引入 SimpleGoodsRespVO 用于简化商品信息响应结构
- 注入 PuzzleTemplateMapper 和 IProductTypeCapabilityManagementService 依赖以支持新功能
2025-11-28 11:19:00 +08:00
e292a0798d refactor(order): 重构重复购买检查策略
- 移除SetIdDuplicateChecker和VideoIdDuplicateChecker两个具体策略类
- 更新DuplicateCheckStrategy枚举,将CHECK_BY_SET_ID和CHECK_BY_VIDEO_ID
  替换为更通用的UNIQUE_RESOURCE和PARENT_RESOURCE策略
- 修改ProductTypeCapabilityManagementServiceImpl中的策略分配逻辑
- UNIQUE_RESOURCE适用于照片、视频等独立资源的重复购买检查
- PARENT_RESOURCE适用于套餐类商品的重复购买检查
- 打印类商品现在正确设置为允许重复购买且不检查
- 其他类别商品默认设置为不检查重复购买
2025-11-28 00:56:41 +08:00
4244b42d4b Merge branch 'refs/heads/order_v2' 2025-11-28 00:35:33 +08:00
8058bc21f5 fix(utils): 修正图片旋转角度参数
- 将旋转角度从270度更正为90度
- 保持旋转后宽高的正确计算逻辑
- 确保测试场景覆盖正确的旋转角度
2025-11-27 22:25:17 +08:00
6dd08ac4e7 feat(product): 实现商品类型能力配置管理功能
- 新增商品类型能力配置的增删改查接口
- 实现分页查询、分类查询、状态筛选等功能
- 支持批量初始化默认配置和缓存刷新
- 提供定价模式、重复检查策略等枚举选项接口
- 实现完整的参数校验和业务逻辑处理
- 添加详细的日志记录和异常处理机制
2025-11-27 20:52:32 +08:00
610a183be1 feat(image): 添加图像超分处理功能
- 新增 ImageSRStage 类实现图像超分辨率处理
- 在 AioDeviceController 中启用图像超分和增强的 Stage
- 修改 ImageEnhanceStage 配置检查逻辑,增加空值和占位符检测
- 为图像处理 Pipeline 添加超分 Stage
- 增加 ImageSRStage 的单元测试覆盖各种配置和执行情况
- 实现百度云图像超分 API 的调用和结果处理逻辑
2025-11-27 18:45:10 +08:00
e9a59cd466 feat(pricing): 添加商品分类枚举并扩展商品类型枚举
- 新增 ProductCategory 枚举类,定义商品分类
- 为 ProductType 枚举增加分类关联字段
- 扩展 ProductType 枚举值并按分类分组注释
- 添加获取分类代码和描述的方法
- 实现根据代码查找枚举的静态方法
- 完善枚举类的文档注释和类型安全引用
2025-11-27 18:39:43 +08:00
d60d7d9ad8 feat(image): 增强图片处理流程并优化水印逻辑
- 在PhotoProcessContext中新增Stage管理相关方法,支持启用、禁用及批量设置Stage状态
- 新增ImageEnhanceStage并整合到图片处理流水线中
- 重构重打印流程,复用普通照片处理流水线
- 生成订单二维码并用于水印配置
- 移除冗余的水印配置和增强配置代码
- 优化Stage控制逻辑,支持动态启用或禁用特定处理阶段
2025-11-27 18:17:19 +08:00
d483c222d0 fix(face): 调整任务状态为正在生成时的锁定类型值
- 将任务状态为正在生成时的lockType从0修改为-9
- 确保正在生成状态能被正确识别和处理
2025-11-27 17:14:50 +08:00
a7ef2cb35a feat(printer): 实现带图像增强选项的重新打印功能
- 在 ReprintRequest 中新增 needEnhance 字段以支持图像增强
- 将 reprint 接口的实现从 controller 下移到 printerService
- 实现 handleReprint 方法,支持根据 needEnhance 条件性添加图像增强阶段
- 重构 reprint 流程,引入 Pipeline 处理图像下载、旋转、增强、水印等步骤
- 增强 reprint 异常处理,失败时回退到原始裁剪图
- 移除 ImageEnhanceStage 中对 TODO 占位符的判断逻辑
- 提供 updateTaskStatusAndPrinter 兜底方法用于无 MemberPrint 场景
2025-11-27 16:04:55 +08:00
cbc0584706 feat(face): 添加人脸识别防重复调用机制
- 引入 FaceMatchDedupService 用于防止短时间内重复调用
- 在匹配前检查是否应跳过本次调用
- 匹配完成后标记已处理,避免重复执行
- 增强系统稳定性与性能,减少无效计算
2025-11-27 16:04:23 +08:00
67932c374b fix(order): 修复订单商品类型处理逻辑
- 在视频和照片原素材处理后添加break语句
- 防止switch语句穿透导致重复执行
- 确保每种商品类型只处理一次
- 清理订单缓存前确保所有商品处理完成
2025-11-27 13:57:14 +08:00
8a88c74df2 feat(pricing): 支持景区维度的价格配置和优惠策略控制
- 新增按景区ID查询商品配置和阶梯价格配置的方法
- 扩展价格计算服务以支持景区级别的优惠策略
- 更新优惠券和代金券提供者以使用景区维度配置
- 修改商品配置服务实现多级查询优先级(景区特定->景区默认->全局特定->全局默认)
- 添加商品类型能力服务测试用例
- 增强价格计算逻辑的容错性和向后兼容性
2025-11-27 13:55:51 +08:00
3ce3972875 refactor(order): 重构重复购买检查和定价逻辑
- 引入商品类型能力配置,替代硬编码的商品类型判断
- 实现策略模式处理不同商品类型的重复购买检查
- 抽象定价模式,支持固定价格和数量计价等不同方式
- 新增策略工厂自动注册各类检查器实现
- 添加缓存机制提升商品类型配置查询性能
- 解耦订单服务与具体商品类型的紧耦合关系
- 提高代码可维护性和扩展性,便于新增商品类型
2025-11-27 09:34:10 +08:00
1945639f90 refactor(image): 重构图片旋转和恢复逻辑
- 将 needRotation 标志重命名为 rotationApplied
- 修改条件旋转阶段的执行逻辑,基于实际旋转角度判断
- 实现通用的图片恢复旋转功能,支持90/180/270度恢复
- 添加恢复旋转角度计算方法 getRestoreAngle
- 更新水印阶段的旋转状态检查逻辑
- 完善单元测试覆盖各种旋转场景
- 优化日志记录和错误处理流程
2025-11-26 20:15:02 +08:00
40d5874560 refactor(image): 重构图片旋转和恢复逻辑
- 将 needRotation 标志重命名为 rotationApplied
- 修改条件旋转阶段的执行逻辑,基于实际旋转角度判断
- 实现通用的图片恢复旋转功能,支持90/180/270度恢复
- 添加恢复旋转角度计算方法 getRestoreAngle
- 更新水印阶段的旋转状态检查逻辑
- 完善单元测试覆盖各种旋转场景
- 优化日志记录和错误处理流程
2025-11-26 16:05:12 +08:00
95419fee66 refactor(image): 调整水印偏移量处理逻辑
- 将 PORTRAIT 偏移量常量重命名为 PRINTER
- 根据图像旋转角度动态设置左右偏移量
- 优化旋转状态下水印位置计算逻辑
2025-11-26 14:58:44 +08:00
333c4d3ca7 refactor(image): 重构水印处理逻辑以提高可维护性
- 移除 PhotoProcessContext 中的水印相关字段
- 新增 WatermarkConfig 类封装水印配置
- 修改 WatermarkStage 通过构造函数注入配置
- 调整 PrinterServiceImpl 中水印配置的传递方式
- 更新单元测试以适应新的配置注入方式
- 统一从配置对象读取水印参数而非上下文
- 优化日志记录与偏移量计算逻辑
2025-11-26 14:56:37 +08:00
90efc908c5 feat(image): 支持多角度图片旋转及方向判断
- 在 PhotoProcessContext 中新增 imageRotation 字段用于存储旋转角度
- 修改 ConditionalRotateStage 支持 90、180、270 度旋转
- 优化 ImageOrientationStage 综合判断图片方向逻辑
- 新增 NoOpStage 作为空操作阶段占位符
- 解除 DeviceVideoContinuityCheckTask 的生产环境限制
- 添加完整的单元测试覆盖各种旋转场景和边界情况
2025-11-26 14:34:17 +08:00
d2846e6d8e fix(core): 修复 StageResult 中 nextStages 的不可变性问题
- 将 nextStages 初始化改为使用 Collections.unmodifiableList 包装
- 防止外部代码修改 nextStages 列表内容
- 保证 StageResult 的不可变性和线程安全性
- 添加完整的单元测试覆盖各种构造场景
2025-11-26 09:03:36 +08:00
7b18d7c2af feat(image): 实现源图片超分辨率增强流水线
- 引入Pipeline模式重构图片处理流程
- 新增SourcePhotoUpdateStage用于上传并更新源图片URL
- 扩展PhotoProcessContext支持超分场景配置
- 增加SOURCE_PHOTO_SUPER_RESOLUTION枚举值
- 修改各Stage判断逻辑适配新的图片类型系统
- 调整SourceService接口支持File类型参数
- 优化超分处理日志记录和异常处理机制
2025-11-25 19:17:55 +08:00
bcebe5defe feat(image): 实现图像增强与质量检测功能
- 新增ImageEnhancerFactory工厂类,支持创建不同类型的图像增强器
- 添加图像清晰度增强和超分辨率两种增强模式
- 实现ImageEnhanceStage图像增强处理阶段
- 新增ImageQualityCheckStage图像质量检测阶段
- 支持根据图片质量动态添加图像增强处理
- 完善Stage配置注解和可选性控制机制
- 优化Pipeline执行流程,支持动态插入Stage
- 增加Stage执行计数和循环依赖防护机制
- 改进StageResult结构,支持携带后续Stage列表
- 统一抽象Stage的执行条件判断逻辑
2025-11-25 11:21:03 +08:00
4a86849372 feat(image): 引入图片来源和处理场景枚举支持
- 新增 ImageSource 枚举定义图片来源类型(IPC、相机、手机等)
- 新增 PipelineScene 枚举定义管线处理场景(打印、增强等)
- 在 PhotoProcessContext 中添加 scenicConfigManager、scene 和 source 字段
- 在 PrinterServiceImpl 中根据 sourceId 判断并设置图片来源
- 在 PrinterServiceImpl 中设置默认管线场景为图片打印
- 修改 prepareNormalPhotoContext 和 prepareStorageAdapter 方法签名
- 优化配置获取逻辑,统一从 context 中获取 scenicConfigManager
2025-11-24 23:54:22 +08:00
e418a5ccdb feat(printer): 引入照片处理管线机制
- 新增Crop和PrinterOrderItem模型用于封装裁剪信息和打印订单项
- 实现基于Pipeline模式的照片处理流程,支持普通照片和拼图处理
- 添加多个处理阶段:下载、方向检测、条件旋转、水印、恢复方向、上传和清理
- 创建PipelineBuilder用于动态构建处理管线
- 实现抽象Stage基类和具体Stage实现类
- 添加Stage执行结果管理和异常处理机制
- 优化照片处理逻辑,使用管线替代原有复杂的嵌套处理代码
- 支持通过景区配置管理水印类型、存储适配器等参数
- 提供临时文件管理工具确保处理过程中文件及时清理
- 增强日志记录和错误处理能力,提升系统可维护性
2025-11-24 21:18:35 +08:00
129 changed files with 10677 additions and 891 deletions

View File

@@ -75,6 +75,12 @@
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Nacos服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>

View File

@@ -259,9 +259,11 @@ public class OrderBiz {
switch (item.getGoodsType()) {
case 0: // vlog视频
videoRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsId());
break;
case 1: // 视频原素材
case 2: // 照片原素材
sourceRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId());
break;
}
});
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
@@ -281,9 +283,11 @@ public class OrderBiz {
switch (item.getGoodsType()) {
case 0: // vlog视频
videoRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsId());
break;
case 1: // 视频原素材
case 2: // 照片原素材
sourceRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId());
break;
}
});
orderRepository.clearOrderCache(orderId); // 更新完了,清理下

View File

@@ -7,9 +7,14 @@ import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.order.entity.OrderEntity;
import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity;
import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
import com.ycwl.basic.model.pc.price.resp.SimpleGoodsRespVO;
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity;
import com.ycwl.basic.product.capability.ProductTypeCapability;
import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.PriceRepository;
@@ -46,6 +51,10 @@ public class PriceBiz {
private CouponBiz couponBiz;
@Autowired
private MemberRelationRepository memberRelationRepository;
@Autowired
private PuzzleTemplateMapper puzzleTemplateMapper;
@Autowired
private IProductTypeCapabilityManagementService productTypeCapabilityManagementService;
public List<GoodsListRespVO> listGoodsByScenic(Long scenicId) {
List<GoodsListRespVO> goodsList = new ArrayList<>();
@@ -70,6 +79,103 @@ public class PriceBiz {
return goodsList;
}
/**
* 根据景区ID和商品类型查询简化的商品列表
*
* @param scenicId 景区ID
* @param productType 商品类型(可选,为空时返回所有商品)
* @return 简化的商品列表
*/
public List<SimpleGoodsRespVO> listSimpleGoodsByScenic(Long scenicId, String productType) {
List<SimpleGoodsRespVO> goodsList = new ArrayList<>();
// 如果 productType 为空,兼容旧逻辑
if (productType == null || productType.isEmpty()) {
return listAllSimpleGoods(scenicId);
}
// 根据 productType 查询不同数据源
switch (productType) {
case "VLOG_VIDEO":
// 从 template 表查询视频模板
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(scenicId);
templateList.stream()
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
.forEach(goodsList::add);
break;
case "PHOTO_VLOG":
// TODO
goodsList.add(new SimpleGoodsRespVO(scenicId, "【待实现】pLog视频", productType));
break;
case "PHOTO":
goodsList.add(new SimpleGoodsRespVO(scenicId, "单张照片", productType));
break;
case "PHOTO_SET":
// 返回固定的照片集条目
goodsList.add(new SimpleGoodsRespVO(scenicId, "照片集", productType));
break;
case "PHOTO_LOG":
// 从 template 表查询pLog模板
List<PuzzleTemplateEntity> puzzleList = puzzleTemplateMapper.list(scenicId, null, null);
puzzleList.stream()
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
.forEach(goodsList::add);
break;
case "RECORDING_SET":
// 返回固定的录像集条目
goodsList.add(new SimpleGoodsRespVO(scenicId, "录像集", productType));
break;
case "PHOTO_PRINT":
// 打印类返回单一通用条目
goodsList.add(new SimpleGoodsRespVO(scenicId, "照片打印", productType));
break;
case "PHOTO_PRINT_MU":
// 打印类返回单一通用条目
goodsList.add(new SimpleGoodsRespVO(scenicId, "手机照片打印", productType));
break;
case "PHOTO_PRINT_FX":
// 打印类返回单一通用条目
goodsList.add(new SimpleGoodsRespVO(scenicId, "效果图片打印", productType));
break;
case "MACHINE_PRINT":
// 打印类返回单一通用条目
goodsList.add(new SimpleGoodsRespVO(scenicId, "一体机打印", productType));
break;
default:
// 不支持的 productType,返回空列表
break;
}
return goodsList;
}
/**
* 兼容旧逻辑:返回所有商品
* 通过查询系统中所有已知的 productType,将结果综合到一起
*/
private List<SimpleGoodsRespVO> listAllSimpleGoods(Long scenicId) {
List<SimpleGoodsRespVO> goodsList = new ArrayList<>();
// 从 ProductTypeCapability 服务查询所有已知的商品类型(仅包含启用的)
List<ProductTypeCapability> capabilities = productTypeCapabilityManagementService.queryAll(false);
// 轮询每个商品类型,获取对应的商品列表
for (ProductTypeCapability capability : capabilities) {
String productType = capability.getProductType();
List<SimpleGoodsRespVO> typeGoodsList = listSimpleGoodsByScenic(scenicId, productType);
goodsList.addAll(typeGoodsList);
}
return goodsList;
}
public List<GoodsListRespVO> queryGoodsList(Integer configId) {
PriceConfigEntity priceConfig = priceRepository.getPriceConfig(configId);
if (priceConfig == null) {

View File

@@ -1,5 +1,9 @@
package com.ycwl.basic.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
@@ -24,7 +28,17 @@ public class CustomRedisCacheManager extends CachingConfigurerSupport {
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
// Configure type handling to prevent ClassCastException
PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class)
.build();
objectMapper.activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL);
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig();
configuration = configuration.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer)).entryTtl(Duration.ofMinutes(1));
return configuration;
@@ -45,10 +59,23 @@ public class CustomRedisCacheManager extends CachingConfigurerSupport {
final StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
// Configure Jackson2JsonRedisSerializer with JavaTimeModule for value serialization
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
// Configure type handling to prevent ClassCastException
PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class)
.build();
objectMapper.activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL);
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}

View File

@@ -4,6 +4,14 @@ import cn.hutool.http.HttpUtil;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.image.enhancer.adapter.BceImageEnhancer;
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
import com.ycwl.basic.image.pipeline.core.Pipeline;
import com.ycwl.basic.image.pipeline.core.PipelineBuilder;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.stages.DownloadStage;
import com.ycwl.basic.image.pipeline.stages.ImageEnhanceStage;
import com.ycwl.basic.image.pipeline.stages.ImageSRStage;
import com.ycwl.basic.image.pipeline.stages.SourcePhotoUpdateStage;
import com.ycwl.basic.image.pipeline.stages.CleanupStage;
import com.ycwl.basic.mapper.AioDeviceMapper;
import com.ycwl.basic.mapper.MemberMapper;
import com.ycwl.basic.model.aio.entity.AioDeviceBannerEntity;
@@ -41,7 +49,9 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@@ -129,27 +139,38 @@ public class AioDeviceController {
redisTemplate.opsForValue().set("aio:faceId:"+resp.getFaceId().toString()+":pass", "1", 1, TimeUnit.DAYS);
return;
}
log.info("超分开始!");
log.info("超分开始!共{}张图片待处理", sourcePhotoList.size());
sourcePhotoList.forEach(photo -> {
if (StringUtils.contains(photo.getUrl(), "_q_")) {
log.debug("跳过已增强的图片: {}", photo.getUrl());
return;
}
try {
File dstFile = new File(photo.getGoodsId()+".jpg");
long fileSize = HttpUtil.downloadFile(photo.getUrl(), dstFile);
log.info("超分开始:{}", fileSize);
BceImageEnhancer enhancer = getEnhancer();
MultipartFile enhancedFile = enhancer.enhance(dstFile.getName());
log.info("超分结束:{}", photo.getUrl());
String url = sourceService.uploadAndUpdateUrl(photo.getGoodsId(), enhancedFile);
log.info("上传结束:->{}", url);
// 创建超分Pipeline
Pipeline<PhotoProcessContext> superResolutionPipeline = createSuperResolutionPipeline(photo.getGoodsId());
// 使用静态工厂方法创建Context
PhotoProcessContext context = PhotoProcessContext.forSuperResolution(
photo.getGoodsId(), photo.getUrl(), photo.getScenicId()
);
// 启用图像增强和超分的Stage
context.enableStage("image_enhance");
context.enableStage("image_sr");
// 执行Pipeline
boolean success = superResolutionPipeline.execute(context);
if (success) {
log.info("超分成功: {} -> {}", photo.getUrl(), context.getResultUrl());
} else {
log.error("超分失败: {}", photo.getGoodsId());
}
} catch (Exception e) {
log.error("超分失败:{}", photo.getGoodsId(), e);
} finally {
File _file = new File(photo.getGoodsId()+".jpg");
if (_file.exists()) {
_file.delete();
}
}
});
redisTemplate.opsForValue().set("aio:faceId:"+sourcePhotoList.getFirst().getFaceId().toString()+":pass", "1", 1, TimeUnit.DAYS);
@@ -206,6 +227,28 @@ public class AioDeviceController {
return ApiResponse.success(orderService.queryOrder(orderId));
}
/**
* 创建源图片超分辨率增强Pipeline
*
* @param sourceId 源图片ID
* @return 超分Pipeline
*/
private Pipeline<PhotoProcessContext> createSuperResolutionPipeline(Long sourceId) {
// 创建带有百度云配置的ImageEnhanceStage
BceEnhancerConfig config = new BceEnhancerConfig();
config.setQps(1);
config.setAppId("119554288");
config.setApiKey("OX6QoijgKio3eVtA0PiUVf7f");
config.setSecretKey("dYatXReVriPeiktTjUblhfubpcmYfuMk");
return new PipelineBuilder<PhotoProcessContext>("SourcePhotoSuperResolutionPipeline")
.addStage(new DownloadStage()) // 1. 下载图片
.addStage(new ImageEnhanceStage(config)).addStage(new ImageSRStage(config)) // 2. 图像增强(超分)
.addStage(new SourcePhotoUpdateStage(sourceService, sourceId)) // 3. 上传并更新数据库
.addStage(new CleanupStage()) // 4. 清理临时文件
.build();
}
private BceImageEnhancer getEnhancer() {
BceImageEnhancer enhancer = new BceImageEnhancer();
BceEnhancerConfig config = new BceEnhancerConfig();

View File

@@ -0,0 +1,50 @@
package com.ycwl.basic.controller.monitor;
import com.ycwl.basic.integration.kafka.scheduler.AccountFaceSchedulerManager;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 人脸识别监控接口
* 提供调度器状态查询功能
*/
@RestController
@RequestMapping("/api/monitor/face-recognition")
@RequiredArgsConstructor
public class FaceRecognitionMonitorController {
private final AccountFaceSchedulerManager schedulerManager;
/**
* 获取所有账号的调度器统计信息
* <p>
* 示例返回:
* {
* "LTAI5xxx": {
* "accountKey": "LTAI5xxx",
* "cloudType": "ALI",
* "activeThreads": 3,
* "executorQueueSize": 12,
* "schedulerQueueSize": 45
* },
* "245xxx": {
* "accountKey": "245xxx",
* "cloudType": "BAIDU",
* "activeThreads": 8,
* "executorQueueSize": 5,
* "schedulerQueueSize": 20
* }
* }
*
* @return 所有账号的调度器状态
*/
@GetMapping("/schedulers")
public ApiResponse<Map<String, AccountFaceSchedulerManager.AccountSchedulerStats>> getAllSchedulerStats() {
return ApiResponse.success(schedulerManager.getAllStats());
}
}

View File

@@ -2,6 +2,7 @@ package com.ycwl.basic.controller.pc;
import com.ycwl.basic.biz.PriceBiz;
import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
import com.ycwl.basic.model.pc.price.resp.SimpleGoodsRespVO;
import com.ycwl.basic.utils.ApiResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -16,8 +17,10 @@ public class PriceConfigController {
private PriceBiz priceBiz;
@GetMapping("/goodsList")
public ApiResponse<List<GoodsListRespVO>> goodsList(@RequestParam Long scenicId) {
return ApiResponse.success(priceBiz.listGoodsByScenic(scenicId));
public ApiResponse<List<SimpleGoodsRespVO>> goodsList(
@RequestParam Long scenicId,
@RequestParam(required = false) String productType) {
return ApiResponse.success(priceBiz.listSimpleGoodsByScenic(scenicId, productType));
}
}

View File

@@ -71,7 +71,7 @@ public class PrinterController {
// 重新打印(将状态设置为0-未开始,并更新打印机名称)
@PostMapping("/task/reprint/{id}")
public ApiResponse<Integer> reprint(@PathVariable("id") Integer id, @RequestBody ReprintRequest request) {
int result = printTaskMapper.updateStatusAndPrinter(id, 0, request.getPrinterName());
int result = printerService.handleReprint(id, request);
return ApiResponse.success(result);
}

View File

@@ -72,6 +72,10 @@ public class PrinterTvController {
File qrcode = new File("qrcode_"+sampleId+".jpg");
try {
FaceSampleEntity faceSample = faceRepository.getFaceSample(sampleId);
if (faceSample == null) {
response.setStatus(404);
return;
}
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(faceSample.getScenicId());
WxMpUtil.generateUnlimitedWXAQRCode(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), "pages/printer/from_sample", sampleId.toString(), qrcode);

View File

@@ -45,6 +45,7 @@ public class AliFaceBodyAdapter implements IFaceBodyAdapter {
private static final Map<String, IRateLimiter> deleteDbLimiters = new ConcurrentHashMap<>();
private static final Map<String, IRateLimiter> deleteEntityLimiters = new ConcurrentHashMap<>();
@Getter // 添加getter,支持获取accessKeyId
private AliFaceBodyConfig config;
public boolean setConfig(AliFaceBodyConfig config) {
@@ -184,10 +185,8 @@ public class AliFaceBodyAdapter implements IFaceBodyAdapter {
addFaceRequest.setImageUrl(faceUrl);
addFaceRequest.setExtraData(extData);
AddFaceResp respVo = new AddFaceResp();
try {
addFaceLimiter.acquire();
} catch (InterruptedException ignored) {
}
// QPS控制已由外层调度器管理,这里不再需要限流
// 移除阻塞等待: addFaceLimiter.acquire()
try {
AddFaceResponse acsResponse = client.getAcsResponse(addFaceRequest);
respVo.setScore(acsResponse.getData().getQualitieScore());

View File

@@ -8,6 +8,7 @@ import com.ycwl.basic.facebody.entity.SearchFaceResp;
import com.ycwl.basic.facebody.entity.SearchFaceResultItem;
import com.ycwl.basic.utils.ratelimiter.FixedRateLimiter;
import com.ycwl.basic.utils.ratelimiter.IRateLimiter;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -40,6 +41,8 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
private static final Map<String, IRateLimiter> deleteDbLimiters = new ConcurrentHashMap<>();
private static final Map<String, IRateLimiter> deleteEntityLimiters = new ConcurrentHashMap<>();
private static final Map<String, IRateLimiter> deleteFaceLimiters = new ConcurrentHashMap<>();
@Getter // 添加getter,支持获取appId和addQps
private BceFaceBodyConfig config;
public boolean setConfig(BceFaceBodyConfig config) {
@@ -149,10 +152,8 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
options.put("user_info", extData);
// options.put("quality_control", "LOW");
options.put("action_type", "REPLACE");
try {
addEntityLimiter.acquire();
} catch (InterruptedException ignored) {
}
// QPS控制已由外层调度器管理,这里不再需要限流
// 移除阻塞等待: addEntityLimiter.acquire()
JSONObject response = client.addUser(faceUrl, "URL", dbName, entityId, options);
int errorCode = response.getInt("error_code");
if (errorCode == 0) {
@@ -164,10 +165,7 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
log.warn("无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
String base64Image = downloadImageAsBase64(faceUrl);
if (base64Image != null) {
try {
addEntityLimiter.acquire();
} catch (InterruptedException ignored) {
}
// 重试时也不需要限流,由外层调度器控制
JSONObject retryResponse = client.addUser(base64Image, "BASE64", dbName, entityId, options);
if (retryResponse.getInt("error_code") == 0) {
log.info("使用base64重试添加人脸成功,entityId: {}", entityId);

View File

@@ -1,4 +1,62 @@
package com.ycwl.basic.image.enhancer;
import com.ycwl.basic.image.enhancer.adapter.BceImageEnhancer;
import com.ycwl.basic.image.enhancer.adapter.BceImageSR;
import com.ycwl.basic.image.enhancer.adapter.IEnhancer;
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
/**
* 图像增强器工厂
* 用于创建不同类型的图像增强器实例
*/
public class ImageEnhancerFactory {
/**
* 增强器类型枚举
*/
public enum EnhancerType {
/**
* 图像清晰度增强
* 使用imageDefinitionEnhance接口,适合提升整体清晰度
*/
DEFINITION_ENHANCE,
/**
* 图像超分辨率
* 使用imageQualityEnhance接口,适合放大图片同时保持质量
*/
SUPER_RESOLUTION
}
/**
* 创建图像增强器
*
* @param type 增强器类型
* @param config 百度云配置
* @return 图像增强器实例
*/
public static IEnhancer createEnhancer(EnhancerType type, BceEnhancerConfig config) {
IEnhancer enhancer = switch (type) {
case DEFINITION_ENHANCE -> new BceImageEnhancer();
case SUPER_RESOLUTION -> new BceImageSR();
};
if (enhancer instanceof BceImageEnhancer) {
((BceImageEnhancer) enhancer).setConfig(config);
} else if (enhancer instanceof BceImageSR) {
((BceImageSR) enhancer).setConfig(config);
}
return enhancer;
}
/**
* 创建默认的图像增强器(清晰度增强)
*
* @param config 百度云配置
* @return 图像增强器实例
*/
public static IEnhancer createDefaultEnhancer(BceEnhancerConfig config) {
return createEnhancer(EnhancerType.DEFINITION_ENHANCE, config);
}
}

View File

@@ -0,0 +1,604 @@
# Image Pipeline 图片处理管线
## 概述
Image Pipeline 是一个通用的、可扩展的图片处理管线框架,用于组织和执行一系列图片处理操作(Stage)。
### 核心特性
- **责任链模式**: 将图片处理流程拆分为独立的 Stage,按顺序执行
- **Builder 模式**: 灵活组装管线,支持条件性添加 Stage
- **动态 Stage 添加**: 支持在运行时根据条件动态添加后续 Stage
- **降级策略**: 支持多级降级执行,确保管线在异常情况下的稳定性
- **配置驱动**: 支持通过外部配置控制 Stage 的启用/禁用
- **类型安全**: 使用泛型和枚举确保类型安全
- **解耦设计**: Context 独立于业务模型,支持多种使用场景
- **自动清理**: 无论成功或失败都会在 finally 中兜底调用 `context.cleanup()`
## 包结构
```
com.ycwl.basic.image.pipeline
├── annotation/ # 注解定义
│ └── StageConfig # Stage 配置注解
├── core/ # 核心类
│ ├── AbstractPipelineStage # Stage 抽象基类
│ ├── PhotoProcessContext # 管线上下文
│ ├── Pipeline # 管线执行器
│ ├── PipelineBuilder # 管线构建器
│ ├── PipelineStage # Stage 接口
│ └── StageResult # Stage 执行结果
├── enums/ # 枚举定义
│ ├── ImageSource # 图片来源枚举
│ ├── ImageType # 图片类型枚举
│ ├── PipelineScene # 管线场景枚举
│ └── StageOptionalMode # Stage 可选模式枚举
├── exception/ # 异常类
│ ├── PipelineException # 管线异常
│ └── StageExecutionException # Stage 执行异常
├── stages/ # 具体 Stage 实现
│ ├── CleanupStage # 清理临时文件
│ ├── ConditionalRotateStage # 条件性旋转
│ ├── DownloadStage # 下载图片
│ ├── ImageEnhanceStage # 图像增强(超分)
│ ├── ImageOrientationStage # 图像方向检测
│ ├── ImageQualityCheckStage # 图像质量检测
│ ├── PuzzleBorderStage # 拼图边框处理
│ ├── RestoreOrientationStage # 恢复图片方向
│ ├── SourcePhotoUpdateStage # 源图片更新
│ ├── UploadStage # 上传图片
│ └── WatermarkStage # 水印处理
└── util/ # 工具类
└── TempFileManager # 临时文件管理器
```
## 核心组件
### 1. Pipeline - 管线执行器
**职责**: 按顺序执行一系列 Stage,管理执行流程和异常处理。
**关键特性**:
- 顺序执行所有 Stage
- 支持动态添加后续 Stage
- 循环检测(最大执行 100 个 Stage)
- 详细的日志输出(带状态图标)
**使用示例**:
```java
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("MyPipeline")
.addStage(new DownloadStage())
.addStage(new WatermarkStage())
.addStage(new UploadStage())
.addStage(new CleanupStage())
.build();
boolean success = pipeline.execute(context);
```
### 2. PhotoProcessContext - 管线上下文
**职责**: 在各个 Stage 之间传递状态和数据,提供临时文件管理。
**核心字段**:
- `processId`: 处理过程唯一标识,用于隔离临时文件
- `originalUrl`: 原图 URL
- `scenicId`: 景区 ID
- `imageType`: 图片类型(普通照片/拼图/手机上传)
- `tempFileManager`: 临时文件管理器
**静态工厂方法**:
```java
// 从打印订单创建(打印场景)
PhotoProcessContext context = PhotoProcessContext.fromPrinterOrderItem(orderItem, scenicId);
// 为超分辨率场景创建
PhotoProcessContext context = PhotoProcessContext.forSuperResolution(itemId, url, scenicId);
// 使用 Builder 自定义创建
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("custom-id")
.originalUrl("https://example.com/image.jpg")
.scenicId(12345L)
.imageType(ImageType.NORMAL_PHOTO)
.source(ImageSource.IPC)
.scene(PipelineScene.IMAGE_PRINT)
.build();
```
**重要方法**:
- `getCurrentFile()`: 获取当前处理中的文件
- `updateProcessedFile(File)`: 更新处理后的文件
- `setResultUrl(String)`: 设置最终结果 URL(会触发回调)
- `cleanup()`: 清理所有临时文件
- `isStageEnabled(stageId, default)`: 判断 Stage 是否启用
### 3. AbstractPipelineStage - Stage 抽象基类
**职责**: 提供 Stage 的通用实现和模板方法。
**执行流程**:
```
shouldExecute() → beforeExecute() → doExecute() → afterExecute()
```
**子类需要实现**:
- `getName()`: 返回 Stage 名称
- `doExecute(context)`: 实现具体处理逻辑
- `shouldExecuteByBusinessLogic(context)`: (可选)实现条件判断
**Stage 执行判断逻辑**:
1. 检查 `@StageConfig` 注解
2. 根据 `optionalMode` 决定是否检查外部配置
- `FORCE_ON`: 强制执行,不检查外部配置
- `SUPPORT`: 检查外部配置(`context.isStageEnabled()`
- `UNSUPPORT`: 不检查外部配置
3. 执行业务逻辑判断(`shouldExecuteByBusinessLogic()`
### 4. StageResult - Stage 执行结果
**状态类型**:
- `SUCCESS`: 执行成功
- `SKIPPED`: 跳过执行
- `FAILED`: 执行失败(会终止管线)
- `DEGRADED`: 降级执行(继续管线但记录警告)
**静态工厂方法**:
```java
// 成功
StageResult.success();
StageResult.success("处理完成");
// 成功并动态添加后续 Stage
StageResult.successWithNext("质量不佳,添加增强", new ImageEnhanceStage());
// 跳过
StageResult.skipped("条件不满足");
// 失败
StageResult.failed("下载失败");
StageResult.failed("处理失败", exception);
// 降级
StageResult.degraded("使用备用方案");
```
### 5. @StageConfig - Stage 配置注解
**字段**:
- `stageId`: Stage 唯一标识(用于外部配置控制)
- `optionalMode`: 可选模式
- `FORCE_ON`: 强制执行(如 DownloadStage、CleanupStage)
- `SUPPORT`: 支持外部控制(如 WatermarkStage、ImageEnhanceStage)
- `UNSUPPORT`: 不支持外部控制(如 RestoreOrientationStage)
- `defaultEnabled`: 默认是否启用
- `description`: 描述信息
**示例**:
```java
@StageConfig(
stageId = "watermark",
optionalMode = StageOptionalMode.SUPPORT,
description = "水印处理",
defaultEnabled = true
)
public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
// ...
}
```
## Stage 列表
### 核心 Stage
| Stage | 职责 | Optional Mode | 执行条件 |
|-------|------|---------------|---------|
| DownloadStage | 从 URL 下载图片到本地 | FORCE_ON | 总是执行 |
| CleanupStage | 清理所有临时文件 | FORCE_ON | 总是执行(优先级 999) |
### 图片处理 Stage
| Stage | 职责 | Optional Mode | 执行条件 |
|-------|------|---------------|---------|
| ImageOrientationStage | 检测图片方向(横竖) | UNSUPPORT | 仅普通照片 |
| ConditionalRotateStage | 条件性旋转(竖图变横图) | UNSUPPORT | 仅竖图 |
| RestoreOrientationStage | 恢复图片方向(横图变回竖图) | UNSUPPORT | 需要旋转的照片 |
| WatermarkStage | 添加水印 | SUPPORT | 仅普通照片 |
| PuzzleBorderStage | 处理拼图边框 | UNSUPPORT | 仅拼图 |
| ImageEnhanceStage | 图像增强(超分) | SUPPORT | 可配置 |
| ImageQualityCheckStage | 图像质量检测 | SUPPORT | 仅普通照片 |
> **提示**:`ImageEnhanceStage` 的默认构造函数会尝试从 `BCE_IMAGE_APP_ID/BCE_IMAGE_API_KEY/BCE_IMAGE_SECRET_KEY` 环境变量读取百度云凭据;若未配置则自动跳过执行。
### 存储 Stage
| Stage | 职责 | Optional Mode | 执行条件 |
|-------|------|---------------|---------|
| UploadStage | 上传图片到存储服务 | FORCE_ON | 总是执行 |
| SourcePhotoUpdateStage | 更新源图片记录 | UNSUPPORT | 总是执行 |
### 辅助 Stage
| Stage | 职责 | Optional Mode | 执行条件 |
|-------|------|---------------|---------|
| NoOpStage | 调试/占位 Stage,不做任何处理 | UNSUPPORT | 仅用于保持流程完整或调试 |
## 典型管线示例
### 1. 打印照片处理管线
```java
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("PrintPipeline")
.addStage(new DownloadStage()) // 1. 下载
.addStage(new ImageOrientationStage()) // 2. 检测方向
.addStage(new ConditionalRotateStage()) // 3. 旋转竖图
.addStage(new WatermarkStage()) // 4. 添加水印
.addStage(new RestoreOrientationStage()) // 5. 恢复方向
.addStage(new UploadStage()) // 6. 上传
.addStage(new CleanupStage()) // 7. 清理
.build();
```
### 2. 拼图处理管线
```java
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("PuzzlePipeline")
.addStage(new DownloadStage()) // 1. 下载
.addStage(new PuzzleBorderStage()) // 2. 添加拼图边框
.addStage(new UploadStage()) // 3. 上传
.addStage(new CleanupStage()) // 4. 清理
.build();
```
### 3. 超分辨率增强管线
```java
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("SuperResolutionPipeline")
.addStage(new DownloadStage()) // 1. 下载
.addStage(new ImageEnhanceStage(config)) // 2. 超分增强
.addStage(new SourcePhotoUpdateStage(sourceService, sourceId)) // 3. 更新记录
.addStage(new CleanupStage()) // 4. 清理
.build();
```
### 4. 带质量检测的管线(动态 Stage)
```java
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("QualityCheckPipeline")
.addStage(new DownloadStage()) // 1. 下载
.addStage(new ImageQualityCheckStage()) // 2. 质量检测(可能动态添加 ImageEnhanceStage)
.addStage(new WatermarkStage()) // 3. 水印
.addStage(new UploadStage()) // 4. 上传
.addStage(new CleanupStage()) // 5. 清理
.build();
```
## 扩展指南
### 如何创建新的 Stage
1. **继承 AbstractPipelineStage**:
```java
@Slf4j
@StageConfig(
stageId = "my_stage",
optionalMode = StageOptionalMode.SUPPORT,
description = "我的自定义 Stage",
defaultEnabled = true
)
public class MyStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "MyStage";
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
// 实现条件判断
return context.getImageType() == ImageType.NORMAL_PHOTO;
}
@Override
protected StageResult doExecute(PhotoProcessContext context) {
File currentFile = context.getCurrentFile();
try {
// 1. 获取输入文件
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
// 2. 处理图片
File outputFile = context.getTempFileManager()
.createTempFile("my_output", ".jpg");
// 执行具体处理逻辑
doSomethingWithImage(currentFile, outputFile);
// 3. 更新 Context
context.updateProcessedFile(outputFile);
// 4. 返回成功结果
return StageResult.success("处理完成");
} catch (Exception e) {
log.error("处理失败", e);
return StageResult.failed("处理失败: " + e.getMessage(), e);
}
}
private void doSomethingWithImage(File input, File output) {
// 具体实现
}
}
```
2. **添加到管线**:
```java
pipeline.addStage(new MyStage());
```
### 如何实现降级策略
参考 `WatermarkStage` 的实现,使用循环尝试多种方案:
```java
@Override
protected StageResult doExecute(PhotoProcessContext context) {
List<Strategy> strategies = Arrays.asList(
Strategy.ADVANCED,
Strategy.STANDARD,
Strategy.BASIC
);
for (int i = 0; i < strategies.size(); i++) {
Strategy strategy = strategies.get(i);
try {
StageResult result = tryStrategy(context, strategy);
if (i > 0) {
// 使用了降级策略
return StageResult.degraded("降级到: " + strategy);
}
return result;
} catch (Exception e) {
log.warn("策略 {} 失败: {}", strategy, e.getMessage());
}
}
// 所有策略都失败
return StageResult.degraded("所有策略失败,跳过处理");
}
```
### 如何动态添加后续 Stage
参考 `ImageQualityCheckStage` 的实现:
```java
@Override
protected StageResult doExecute(PhotoProcessContext context) {
boolean needsEnhancement = checkQuality(context.getCurrentFile());
if (needsEnhancement) {
ImageEnhanceStage enhanceStage = new ImageEnhanceStage();
return StageResult.successWithNext("质量不佳,添加增强", enhanceStage);
}
return StageResult.success("质量良好");
}
```
## 最佳实践
### 1. Context 管理
- **总是使用静态工厂方法或 Builder**: 避免直接调用构造函数
- **及时清理临时文件**: 在 finally 块或使用 CleanupStage
- **使用回调更新外部状态**: 通过 `resultUrlCallback` 而非直接操作业务对象
### 2. Stage 设计
- **单一职责**: 每个 Stage 只做一件事
- **可组合**: Stage 应该可以灵活组合
- **幂等性**: 相同输入应产生相同输出
- **异常处理**: 捕获异常并返回 `StageResult.failed()``StageResult.degraded()`
- **日志记录**: 在关键操作处记录 debug/info 日志
### 3. 管线构建
- **CleanupStage 总是最后**: 确保临时文件总是被清理
- **DownloadStage 总是最前**: 确保有本地文件可用
- **合理使用 optionalMode**:
- 必需的 Stage 使用 `FORCE_ON`
- 可选的 Stage 使用 `SUPPORT`
- 内部逻辑控制的 Stage 使用 `UNSUPPORT`
### 4. 性能优化
- **复用 TempFileManager**: 自动管理临时文件生命周期
- **避免重复下载**: 使用 `context.getCurrentFile()` 获取最新文件
- **及时更新 processedFile**: 使用 `context.updateProcessedFile()` 通知下一个 Stage
### 5. 错误处理
- **失败即停止**: 使用 `StageResult.failed()` 终止管线
- **降级继续执行**: 使用 `StageResult.degraded()` 记录问题但继续执行
- **跳过非关键 Stage**: 使用 `StageResult.skipped()` 表示条件不满足
- **携带异常信息**: `StageResult.failed(message, exception)` 便于排查问题
## 配置控制
### 1. 景区级配置
通过 `ScenicConfigManager` 加载景区配置:
```java
context.setScenicConfigManager(scenicConfigManager);
```
Stage 内部可以获取配置:
```java
ScenicConfigManager config = context.getScenicConfigManager();
Boolean enabled = config.getBoolean("my_feature_enabled");
String value = config.getString("my_setting");
```
### 2. 请求级配置
通过 `loadStageConfig()` 加载请求参数:
```java
Map<String, Boolean> stageConfig = new HashMap<>();
stageConfig.put("watermark", false); // 禁用水印
stageConfig.put("image_enhance", true); // 启用增强
context.loadStageConfig(scenicConfigManager, stageConfig);
```
### 3. Stage 启用判断
`AbstractPipelineStage.shouldExecute()` 中自动处理:
```java
// 对于 optionalMode = SUPPORT 的 Stage
@StageConfig(
stageId = "watermark",
optionalMode = StageOptionalMode.SUPPORT,
defaultEnabled = true
)
public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
// 如果外部配置禁用了 watermark,则不执行
}
```
## 测试指南
### 单元测试结构
```java
@Test
public void testStageSuccess() {
// 1. 准备 Context
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("test-1")
.originalUrl("https://example.com/test.jpg")
.scenicId(123L)
.build();
// 2. 创建 Stage
MyStage stage = new MyStage();
// 3. 执行
StageResult result = stage.execute(context);
// 4. 断言
assertTrue(result.isSuccess());
assertNotNull(context.getCurrentFile());
}
@Test
public void testStageSkipped() {
// 测试条件不满足时跳过
}
@Test
public void testStageFailed() {
// 测试异常情况
}
@Test
public void testStageDegraded() {
// 测试降级情况
}
```
### 管线集成测试
```java
@Test
public void testPipelineExecution() {
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("TestPipeline")
.addStage(new DownloadStage())
.addStage(new WatermarkStage())
.addStage(new UploadStage())
.addStage(new CleanupStage())
.build();
PhotoProcessContext context = createTestContext();
boolean success = pipeline.execute(context);
assertTrue(success);
assertNotNull(context.getResultUrl());
}
```
## 常见问题
### Q: 如何跳过某个 Stage?
A: 有三种方式:
1. 使用外部配置(适用于 `optionalMode = SUPPORT` 的 Stage)
2.`shouldExecuteByBusinessLogic()` 中返回 false
3. 构建管线时不添加该 Stage
### Q: 如何在运行时决定是否添加某个 Stage?
A: 使用 `PipelineBuilder.addStageIf()`:
```java
builder.addStageIf(needWatermark, new WatermarkStage());
```
或者使用动态 Stage 添加(`StageResult.successWithNext()`)。
### Q: 如何处理 Stage 执行失败?
A: 返回 `StageResult.failed()`,管线会立即终止。如果希望继续执行,使用 `StageResult.degraded()`
### Q: 临时文件什么时候被清理?
A: 由 `CleanupStage` 负责,通常放在管线最后。也可以手动调用 `context.cleanup()`。此外,`Pipeline` 在 finally 中还会再调用一次 `context.cleanup()`,保证失败或异常时也能释放所有临时文件。
### Q: 如何获取最终处理结果?
A: 使用 `context.getResultUrl()`,或者在构建 Context 时提供 `resultUrlCallback`
### Q: 如何支持新的图片来源或场景?
A: 扩展 `ImageSource``PipelineScene` 枚举,然后在 Stage 中添加相应的判断逻辑。
## 架构演进
### 已实现的特性
- ✅ 责任链模式的基础管线框架
- ✅ Builder 模式的管线构建
- ✅ 动态 Stage 添加
- ✅ 多级降级策略
- ✅ 配置驱动的 Stage 控制
- ✅ Context 与业务模型解耦
- ✅ 类型安全的图片分类
### 未来可能的改进
- 🔄 支持并行执行某些 Stage
- 🔄 支持 Stage 执行超时控制
- 🔄 支持管线执行的暂停/恢复
- 🔄 支持更细粒度的性能监控
- 🔄 支持 Stage 执行的重试机制
- 🔄 支持管线执行的可视化追踪
## 相关文档
- [ImageUtils 工具类](../utils/ImageUtils.java)
- [StorageFactory 存储工厂](../storage/StorageFactory.java)
- [WatermarkFactory 水印工厂](../image/watermark/ImageWatermarkFactory.java)
## 维护者
- 图片处理管线 - 基础架构团队

View File

@@ -0,0 +1,45 @@
package com.ycwl.basic.image.pipeline.annotation;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Stage配置注解
* 用于声明Stage的元数据和可选性控制信息
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface StageConfig {
/**
* Stage的唯一标识
* 用于外部配置引用该Stage
* 例如: "watermark", "download", "upload"
*/
String stageId();
/**
* 可选性模式
* 默认为UNSUPPORT(不支持外部控制)
*/
StageOptionalMode optionalMode() default StageOptionalMode.UNSUPPORT;
/**
* Stage描述信息
* 用于文档和日志说明
*/
String description() default "";
/**
* 默认是否启用
* 仅当optionalMode=SUPPORT时有效
* 当外部配置未明确指定时,使用此默认值
*/
boolean defaultEnabled() default true;
}

View File

@@ -0,0 +1,95 @@
package com.ycwl.basic.image.pipeline.core;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
/**
* Pipeline Stage抽象基类
* 提供默认实现和通用逻辑
*/
@Slf4j
public abstract class AbstractPipelineStage<C extends PhotoProcessContext> implements PipelineStage<C> {
/**
* 最终的shouldExecute判断
* 整合了外部配置控制和业务逻辑判断
*/
@Override
public final boolean shouldExecute(C context) {
// 1. 检查Stage配置注解
StageConfig config = getStageConfig();
if (config != null) {
String stageId = config.stageId();
StageOptionalMode mode = config.optionalMode();
// FORCE_ON:强制执行,不检查外部配置
if (mode == StageOptionalMode.FORCE_ON) {
return shouldExecuteByBusinessLogic(context);
}
// SUPPORT:检查外部配置
if (mode == StageOptionalMode.SUPPORT) {
boolean externalEnabled = context.isStageEnabled(stageId, config.defaultEnabled());
if (!externalEnabled) {
log.debug("[{}] Stage被外部配置禁用", stageId);
return false;
}
}
// UNSUPPORT:不检查外部配置,直接走业务逻辑
}
// 2. 执行业务逻辑判断
return shouldExecuteByBusinessLogic(context);
}
/**
* 子类实现业务逻辑判断
* 默认总是执行
*
* 子类可以覆盖此方法实现条件性执行
* 例如: 只有竖图才旋转, 只有普通照片才加水印等
*/
protected boolean shouldExecuteByBusinessLogic(C context) {
return true;
}
/**
* 模板方法:执行Stage前的准备工作
*/
protected void beforeExecute(C context) {
log.debug("[{}] 开始执行", getName());
}
/**
* 模板方法:执行Stage后的清理工作
*/
protected void afterExecute(C context, StageResult<C> result) {
if (result.isSuccess()) {
log.debug("[{}] 执行成功: {}", getName(), result.getMessage());
} else if (result.isSkipped()) {
log.debug("[{}] 已跳过: {}", getName(), result.getMessage());
} else if (result.isDegraded()) {
log.warn("[{}] 降级执行: {}", getName(), result.getMessage());
} else {
log.error("[{}] 执行失败: {}", getName(), result.getMessage(), result.getException());
}
}
/**
* 子类实现具体的处理逻辑
*/
protected abstract StageResult<C> doExecute(C context);
/**
* 最终执行方法(带钩子)
*/
@Override
public final StageResult<C> execute(C context) {
beforeExecute(context);
StageResult<C> result = doExecute(context);
afterExecute(context, result);
return result;
}
}

View File

@@ -0,0 +1,370 @@
package com.ycwl.basic.image.pipeline.core;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.PipelineScene;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.Crop;
import com.ycwl.basic.model.PrinterOrderItem;
import com.ycwl.basic.image.pipeline.util.TempFileManager;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import lombok.Getter;
import lombok.Setter;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
/**
* 图片处理管线上下文
* 在各个Stage之间传递状态和数据
*/
@Getter
@Setter
public class PhotoProcessContext {
// ==================== 核心字段(构造时必填)====================
/**
* 处理过程唯一标识
* 用于 TempFileManager 创建隔离的临时文件目录
*/
private final String processId;
/**
* 原图 URL
*/
private final String originalUrl;
/**
* 景区 ID
*/
private final Long scenicId;
/**
* 临时文件管理器
*/
private final TempFileManager tempFileManager;
// ==================== 图片元信息 ====================
/**
* 图片类型
*/
private ImageType imageType = ImageType.NORMAL_PHOTO;
/**
* 裁剪/旋转信息
*/
private Crop crop;
// ==================== 管线配置 ====================
/**
* 景区配置管理器,用于获取景区相关配置
*/
private ScenicConfigManager scenicConfigManager;
/**
* 管线处理场景(打印、增强等)
*/
private PipelineScene scene;
/**
* 图片来源(IPC、相机、手机等)
*/
private ImageSource source;
/**
* Stage开关配置表
* Key: stageId, Value: 是否启用
* 整合了景区配置和请求参数
*/
private Map<String, Boolean> stageEnabledMap = new HashMap<>();
// ==================== 处理过程状态 ====================
private File originalFile;
private File processedFile;
private boolean isLandscape = true;
private boolean rotationApplied = false;
private boolean cleaned = false;
/**
* 图像需要旋转的角度(用于后续Stage使用)
* 由 ImageOrientationStage 从 Crop.rotation 中提取并设置
*/
private Integer imageRotation;
private String resultUrl;
private IStorageAdapter storageAdapter;
// ==================== 回调 ====================
/**
* 结果 URL 回调
* 用于在 setResultUrl 时通知外部更新相关数据
*/
private Consumer<String> resultUrlCallback;
// ==================== 构造函数(私有)====================
private PhotoProcessContext(Builder builder) {
this.processId = builder.processId;
this.originalUrl = builder.originalUrl;
this.scenicId = builder.scenicId;
this.tempFileManager = new TempFileManager(processId);
this.imageType = builder.imageType;
this.crop = builder.crop;
this.scene = builder.scene;
this.source = builder.source;
this.resultUrlCallback = builder.resultUrlCallback;
}
// ==================== 静态工厂方法 ====================
/**
* 从 PrinterOrderItem 创建 Context(打印场景兼容方法)
*
* @param orderItem 打印订单项
* @param scenicId 景区ID
* @return PhotoProcessContext
*/
public static PhotoProcessContext fromPrinterOrderItem(PrinterOrderItem orderItem, Long scenicId) {
return PhotoProcessContext.builder()
.processId(orderItem.getId().toString())
.originalUrl(orderItem.getCropUrl())
.scenicId(scenicId)
.imageType(ImageType.fromSourceId(orderItem.getSourceId()))
.crop(orderItem.getCrop())
.resultUrlCallback(url -> orderItem.setCropUrl(url))
.build();
}
/**
* 为超分辨率场景创建 Context
*
* @param itemId 项目ID(用于临时文件隔离)
* @param url 原图URL
* @param scenicId 景区ID
* @return PhotoProcessContext
*/
public static PhotoProcessContext forSuperResolution(Long itemId, String url, Long scenicId) {
return PhotoProcessContext.builder()
.processId(itemId.toString())
.originalUrl(url)
.scenicId(scenicId)
.imageType(ImageType.NORMAL_PHOTO)
.source(ImageSource.IPC)
.scene(PipelineScene.SOURCE_PHOTO_SUPER_RESOLUTION)
.build();
}
/**
* 获取 Builder
*/
public static Builder builder() {
return new Builder();
}
// ==================== 业务方法 ====================
/**
* 从景区配置和请求参数中加载Stage开关配置
*
* @param scenicConfigManager 景区配置管理器
* @param requestParams 请求参数中的Stage配置
*/
public void loadStageConfig(ScenicConfigManager scenicConfigManager, Map<String, Boolean> requestParams) {
// 请求参数覆盖
if (requestParams != null) {
stageEnabledMap.putAll(requestParams);
}
}
/**
* 判断指定Stage是否启用
*
* @param stageId Stage唯一标识
* @param defaultEnabled 默认值(当配置未指定时使用)
* @return true-启用, false-禁用
*/
public boolean isStageEnabled(String stageId, boolean defaultEnabled) {
return stageEnabledMap.getOrDefault(stageId, defaultEnabled);
}
/**
* 判断指定Stage是否启用(默认为false)
*
* @param stageId Stage唯一标识
* @return true-启用, false-禁用
*/
public boolean isStageEnabled(String stageId) {
return stageEnabledMap.getOrDefault(stageId, false);
}
/**
* 设置指定Stage的启用状态
*
* @param stageId Stage唯一标识
* @param enabled 是否启用
* @return this(支持链式调用)
*/
public PhotoProcessContext setStageState(String stageId, boolean enabled) {
stageEnabledMap.put(stageId, enabled);
return this;
}
/**
* 启用指定Stage
*
* @param stageId Stage唯一标识
* @return this(支持链式调用)
*/
public PhotoProcessContext enableStage(String stageId) {
stageEnabledMap.put(stageId, true);
return this;
}
/**
* 禁用指定Stage
*
* @param stageId Stage唯一标识
* @return this(支持链式调用)
*/
public PhotoProcessContext disableStage(String stageId) {
stageEnabledMap.put(stageId, false);
return this;
}
/**
* 批量设置Stage启用状态
*
* @param stages Stage配置Map(stageId -> enabled)
* @return this(支持链式调用)
*/
public PhotoProcessContext setStages(Map<String, Boolean> stages) {
if (stages != null) {
stageEnabledMap.putAll(stages);
}
return this;
}
/**
* 清空所有Stage配置
*
* @return this(支持链式调用)
*/
public PhotoProcessContext clearStages() {
stageEnabledMap.clear();
return this;
}
/**
* 设置最终处理结果URL
*/
public void setResultUrl(String url) {
this.resultUrl = url;
if (resultUrlCallback != null) {
resultUrlCallback.accept(url);
}
}
/**
* 获取当前处理中的文件
* 如果有processedFile则返回,否则返回originalFile
*/
public File getCurrentFile() {
return processedFile != null ? processedFile : originalFile;
}
/**
* 更新处理后的文件
*/
public void updateProcessedFile(File newFile) {
this.processedFile = newFile;
tempFileManager.registerTempFile(newFile);
}
/**
* 清理所有临时文件
*/
public void cleanup() {
if (cleaned) {
return;
}
tempFileManager.cleanup();
cleaned = true;
}
// ==================== Builder ====================
public static class Builder {
private String processId;
private String originalUrl;
private Long scenicId;
private ImageType imageType = ImageType.NORMAL_PHOTO;
private Crop crop;
private PipelineScene scene;
private ImageSource source;
private Consumer<String> resultUrlCallback;
public Builder processId(String processId) {
this.processId = processId;
return this;
}
public Builder originalUrl(String originalUrl) {
this.originalUrl = originalUrl;
return this;
}
public Builder scenicId(Long scenicId) {
this.scenicId = scenicId;
return this;
}
public Builder imageType(ImageType imageType) {
this.imageType = imageType;
return this;
}
public Builder crop(Crop crop) {
this.crop = crop;
return this;
}
public Builder scene(PipelineScene scene) {
this.scene = scene;
return this;
}
public Builder source(ImageSource source) {
this.source = source;
return this;
}
public Builder resultUrlCallback(Consumer<String> callback) {
this.resultUrlCallback = callback;
return this;
}
public PhotoProcessContext build() {
// 参数校验
if (originalUrl == null || originalUrl.isBlank()) {
throw new IllegalArgumentException("originalUrl is required");
}
if (scenicId == null) {
throw new IllegalArgumentException("scenicId is required");
}
// processId 可以自动生成
if (processId == null || processId.isBlank()) {
processId = UUID.randomUUID().toString();
}
return new PhotoProcessContext(this);
}
}
}

View File

@@ -0,0 +1,130 @@
package com.ycwl.basic.image.pipeline.core;
import com.ycwl.basic.image.pipeline.exception.PipelineException;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
/**
* 图片处理管线
* 按顺序执行一系列Stage
*/
@Slf4j
public class Pipeline<C extends PhotoProcessContext> {
private final List<PipelineStage<C>> stages;
private final String name;
public Pipeline(String name, List<PipelineStage<C>> stages) {
this.name = name;
this.stages = new ArrayList<>(stages);
}
/**
* 执行管线
*
* @param context 管线上下文
* @return 执行成功返回true
* @throws PipelineException 管线执行异常
*/
public boolean execute(C context) {
log.info("[{}] 开始执行管线, Stage数量: {}", name, stages.size());
long startTime = System.currentTimeMillis();
int maxStages = 100; // 防止无限循环
int executedCount = 0;
try {
for (int i = 0; i < stages.size(); i++) {
if (executedCount >= maxStages) {
log.error("[{}] Stage执行数量超过最大限制({}),可能存在循环依赖", name, maxStages);
throw new PipelineException("Stage执行数量超过最大限制,可能存在循环依赖");
}
PipelineStage<C> stage = stages.get(i);
String stageName = stage.getName();
log.debug("[{}] [{}/{}] 准备执行Stage: {}", name, i + 1, stages.size(), stageName);
if (!stage.shouldExecute(context)) {
log.debug("[{}] Stage {} 条件不满足,跳过执行", name, stageName);
continue;
}
long stageStartTime = System.currentTimeMillis();
StageResult<C> result = stage.execute(context);
long stageDuration = System.currentTimeMillis() - stageStartTime;
executedCount++;
logStageResult(stageName, result, stageDuration);
// 动态添加后续Stage
if (result.getNextStages() != null && !result.getNextStages().isEmpty()) {
List<PipelineStage<C>> nextStages = result.getNextStages();
log.info("[{}] Stage {} 动态添加了 {} 个后续Stage", name, stageName, nextStages.size());
for (int j = 0; j < nextStages.size(); j++) {
PipelineStage<C> nextStage = nextStages.get(j);
stages.add(i + 1 + j, nextStage);
log.debug("[{}] - 插入Stage: {} 到位置 {}", name, nextStage.getName(), i + 1 + j);
}
}
if (result.isFailed()) {
log.error("[{}] Stage {} 执行失败,管线终止", name, stageName);
return false;
}
}
long totalDuration = System.currentTimeMillis() - startTime;
log.info("[{}] 管线执行完成, 总Stage数: {}, 实际执行: {}, 耗时: {}ms",
name, stages.size(), executedCount, totalDuration);
return true;
} catch (Exception e) {
log.error("[{}] 管线执行异常", name, e);
throw new PipelineException("管线执行失败: " + e.getMessage(), e);
} finally {
safeCleanup(context);
}
}
private void logStageResult(String stageName, StageResult<C> result, long duration) {
String statusIcon = switch (result.getStatus()) {
case SUCCESS -> "";
case SKIPPED -> "";
case DEGRADED -> "";
case FAILED -> "";
};
log.info("[{}] {} Stage {} - {} (耗时: {}ms)",
name, statusIcon, stageName, result.getStatus(), duration);
if (result.getMessage() != null) {
log.debug("[{}] 详情: {}", name, result.getMessage());
}
}
public String getName() {
return name;
}
public int getStageCount() {
return stages.size();
}
public List<String> getStageNames() {
return stages.stream().map(PipelineStage::getName).toList();
}
private void safeCleanup(C context) {
if (context == null) {
return;
}
try {
context.cleanup();
} catch (Exception cleanupError) {
log.warn("[{}] 管线清理失败", name, cleanupError);
}
}
}

View File

@@ -0,0 +1,78 @@
package com.ycwl.basic.image.pipeline.core;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Pipeline构建器
* 使用Builder模式动态组装管线
*/
public class PipelineBuilder<C extends PhotoProcessContext> {
private String name = "DefaultPipeline";
private final List<PipelineStage<C>> stages = new ArrayList<>();
public PipelineBuilder() {
}
public PipelineBuilder(String name) {
this.name = name;
}
/**
* 设置管线名称
*/
public PipelineBuilder<C> name(String name) {
this.name = name;
return this;
}
/**
* 添加Stage
*/
public PipelineBuilder<C> addStage(PipelineStage<C> stage) {
if (stage != null) {
this.stages.add(stage);
}
return this;
}
/**
* 批量添加Stage
*/
public PipelineBuilder<C> addStages(List<PipelineStage<C>> stages) {
if (stages != null) {
this.stages.addAll(stages);
}
return this;
}
/**
* 条件性添加Stage
*/
public PipelineBuilder<C> addStageIf(boolean condition, PipelineStage<C> stage) {
if (condition && stage != null) {
this.stages.add(stage);
}
return this;
}
/**
* 按优先级排序Stage
*/
public PipelineBuilder<C> sortByPriority() {
this.stages.sort(Comparator.comparingInt(PipelineStage::getPriority));
return this;
}
/**
* 构建Pipeline
*/
public Pipeline<C> build() {
if (stages.isEmpty()) {
throw new IllegalStateException("管线至少需要一个Stage");
}
return new Pipeline<>(name, stages);
}
}

View File

@@ -0,0 +1,50 @@
package com.ycwl.basic.image.pipeline.core;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
/**
* 管线处理阶段接口
* 每个Stage负责一个独立的图片处理步骤
*
* @param <C> Context类型
*/
public interface PipelineStage<C extends PhotoProcessContext> {
/**
* 获取Stage名称(用于日志和监控)
*/
String getName();
/**
* 判断是否需要执行此Stage
* 支持条件性执行(如:只有竖图才需要旋转)
*
* @param context 管线上下文
* @return true-执行, false-跳过
*/
boolean shouldExecute(C context);
/**
* 执行Stage处理逻辑
*
* @param context 管线上下文
* @return 执行结果
*/
StageResult<C> execute(C context);
/**
* 获取Stage的执行优先级(用于排序)
* 数值越小优先级越高,默认为100
*/
default int getPriority() {
return 100;
}
/**
* 获取Stage配置注解(用于反射读取可选性控制信息)
* @return Stage配置注解,如果未标注则返回null
*/
default StageConfig getStageConfig() {
return this.getClass().getAnnotation(StageConfig.class);
}
}

View File

@@ -0,0 +1,99 @@
package com.ycwl.basic.image.pipeline.core;
import lombok.Getter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* Stage执行结果
*/
@Getter
public class StageResult<C extends PhotoProcessContext> {
public enum Status {
SUCCESS, // 执行成功
SKIPPED, // 跳过执行
FAILED, // 执行失败
DEGRADED // 降级执行
}
private final Status status;
private final String message;
private final Throwable exception;
private final List<PipelineStage<C>> nextStages;
private StageResult(Status status, String message, Throwable exception, List<PipelineStage<C>> nextStages) {
this.status = status;
this.message = message;
this.exception = exception;
this.nextStages = nextStages != null
? Collections.unmodifiableList(new ArrayList<>(nextStages))
: Collections.emptyList();
}
public static <C extends PhotoProcessContext> StageResult<C> success() {
return new StageResult<>(Status.SUCCESS, null, null, null);
}
public static <C extends PhotoProcessContext> StageResult<C> success(String message) {
return new StageResult<>(Status.SUCCESS, message, null, null);
}
/**
* 成功执行并动态添加后续Stage
*/
@SafeVarargs
public static <C extends PhotoProcessContext> StageResult<C> successWithNext(String message, PipelineStage<C>... stages) {
return new StageResult<>(Status.SUCCESS, message, null, Arrays.asList(stages));
}
/**
* 成功执行并动态添加后续Stage列表
*/
public static <C extends PhotoProcessContext> StageResult<C> successWithNext(String message, List<PipelineStage<C>> stages) {
return new StageResult<>(Status.SUCCESS, message, null, stages);
}
public static <C extends PhotoProcessContext> StageResult<C> skipped() {
return new StageResult<>(Status.SKIPPED, "条件不满足,跳过执行", null, null);
}
public static <C extends PhotoProcessContext> StageResult<C> skipped(String reason) {
return new StageResult<>(Status.SKIPPED, reason, null, null);
}
public static <C extends PhotoProcessContext> StageResult<C> failed(String message) {
return new StageResult<>(Status.FAILED, message, null, null);
}
public static <C extends PhotoProcessContext> StageResult<C> failed(String message, Throwable exception) {
return new StageResult<>(Status.FAILED, message, exception, null);
}
public static <C extends PhotoProcessContext> StageResult<C> degraded(String message) {
return new StageResult<>(Status.DEGRADED, message, null, null);
}
public boolean isSuccess() {
return status == Status.SUCCESS;
}
public boolean isSkipped() {
return status == Status.SKIPPED;
}
public boolean isFailed() {
return status == Status.FAILED;
}
public boolean isDegraded() {
return status == Status.DEGRADED;
}
public boolean canContinue() {
return status == Status.SUCCESS || status == Status.SKIPPED || status == Status.DEGRADED;
}
}

View File

@@ -0,0 +1,63 @@
package com.ycwl.basic.image.pipeline.enums;
/**
* 图片来源枚举
* 用于标识图片的拍摄或上传来源
*/
public enum ImageSource {
/**
* IPC设备(IP Camera)
* 景区固定安装的网络摄像头拍摄的照片
*/
IPC("ipc", "IPC设备"),
/**
* 相机设备
* 工作人员使用专业相机拍摄的照片
*/
CAMERA("camera", "相机设备"),
/**
* 手机上传
* 用户通过手机客户端上传的照片
*/
PHONE("phone", "手机上传"),
/**
* 未知来源
* 无法确定来源或其他特殊情况
*/
UNKNOWN("unknown", "未知来源");
private final String code;
private final String description;
ImageSource(String code, String description) {
this.code = code;
this.description = description;
}
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据code获取枚举
*/
public static ImageSource fromCode(String code) {
if (code == null) {
return UNKNOWN;
}
for (ImageSource source : values()) {
if (source.code.equalsIgnoreCase(code)) {
return source;
}
}
return UNKNOWN;
}
}

View File

@@ -0,0 +1,59 @@
package com.ycwl.basic.image.pipeline.enums;
/**
* 图片类型枚举
* 用于区分管线处理的图片类型,替代通过 sourceId 判断的逻辑
*/
public enum ImageType {
/**
* 普通照片
* 对应原 sourceId > 0 的情况(IPC设备拍摄)
*/
NORMAL_PHOTO("normal", "普通照片"),
/**
* 拼图
* 对应原 sourceId == 0 的情况
*/
PUZZLE("puzzle", "拼图"),
/**
* 手机上传
* 对应原 sourceId == null 的情况
*/
MOBILE_UPLOAD("mobile", "手机上传");
private final String code;
private final String description;
ImageType(String code, String description) {
this.code = code;
this.description = description;
}
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 从 sourceId 推断图片类型
* 用于兼容现有数据结构
*
* @param sourceId 源ID(null=手机上传, 0=拼图, >0=普通照片)
* @return 对应的图片类型
*/
public static ImageType fromSourceId(Long sourceId) {
if (sourceId == null) {
return MOBILE_UPLOAD;
} else if (sourceId == 0) {
return PUZZLE;
} else {
return NORMAL_PHOTO;
}
}
}

View File

@@ -0,0 +1,57 @@
package com.ycwl.basic.image.pipeline.enums;
/**
* 管线处理场景枚举
* 用于区分不同的图片处理业务场景
*/
public enum PipelineScene {
/**
* 图片打印场景
* 包括照片打印、拼图打印等
*/
IMAGE_PRINT("image_print", "图片打印"),
/**
* 图片增强场景
* 包括图片美化、滤镜处理等
*/
IMAGE_ENHANCE("image_enhance", "图片增强"),
/**
* 源图片超分辨率增强场景
* IPC设备拍摄的源图片进行质量提升
*/
SOURCE_PHOTO_SUPER_RESOLUTION("source_photo_sr", "源图片超分辨率增强");
private final String code;
private final String description;
PipelineScene(String code, String description) {
this.code = code;
this.description = description;
}
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据code获取枚举
*/
public static PipelineScene fromCode(String code) {
if (code == null) {
return null;
}
for (PipelineScene scene : values()) {
if (scene.code.equals(code)) {
return scene;
}
}
return null;
}
}

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.image.pipeline.enums;
import lombok.Getter;
/**
* Stage可选性模式枚举
* 定义Stage是否支持外部配置控制
*/
@Getter
public enum StageOptionalMode {
/**
* 不支持外部控制
* Stage的执行完全由代码中的业务逻辑决定
*/
UNSUPPORT("不支持外部控制"),
/**
* 支持外部控制
* Stage可以通过景区配置或请求参数进行开启/关闭
*/
SUPPORT("支持外部控制"),
/**
* 强制开启
* Stage必须执行,不允许外部配置关闭
*/
FORCE_ON("强制开启");
private final String description;
StageOptionalMode(String description) {
this.description = description;
}
}

View File

@@ -0,0 +1,15 @@
package com.ycwl.basic.image.pipeline.exception;
/**
* 管线处理异常基类
*/
public class PipelineException extends RuntimeException {
public PipelineException(String message) {
super(message);
}
public PipelineException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,23 @@
package com.ycwl.basic.image.pipeline.exception;
/**
* Stage执行异常
*/
public class StageExecutionException extends PipelineException {
private final String stageName;
public StageExecutionException(String stageName, String message) {
super(String.format("Stage '%s' 执行失败: %s", stageName, message));
this.stageName = stageName;
}
public StageExecutionException(String stageName, String message, Throwable cause) {
super(String.format("Stage '%s' 执行失败: %s", stageName, message), cause);
this.stageName = stageName;
}
public String getStageName() {
return stageName;
}
}

View File

@@ -0,0 +1,47 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
/**
* 清理临时文件Stage
* 总是在管线最后执行,清理所有临时文件
*/
@Slf4j
@StageConfig(
stageId = "cleanup",
optionalMode = StageOptionalMode.FORCE_ON,
description = "清理临时文件",
defaultEnabled = true
)
public class CleanupStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "CleanupStage";
}
@Override
public int getPriority() {
return 999;
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
try {
int fileCount = context.getTempFileManager().getTempFileCount();
context.cleanup();
log.info("临时文件清理完成: 共{}个文件", fileCount);
return StageResult.success(String.format("已清理 %d 个临时文件", fileCount));
} catch (Exception e) {
log.warn("临时文件清理失败,但不影响主流程", e);
return StageResult.degraded("清理失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,92 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 条件旋转Stage
* 根据图片需要旋转的角度进行旋转(便于后续水印处理)
*/
@Slf4j
@StageConfig(
stageId = "rotate",
optionalMode = StageOptionalMode.UNSUPPORT,
description = "根据需要旋转图片"
)
public class ConditionalRotateStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "ConditionalRotateStage";
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
Integer rotation = context.getImageRotation();
return context.getImageType() == ImageType.NORMAL_PHOTO && (rotation != null && rotation != 0);
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
try {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
// 获取需要旋转的角度
Integer rotation = context.getImageRotation();
if (rotation == null || rotation == 0) {
return StageResult.skipped("无需旋转");
}
File rotatedFile = context.getTempFileManager()
.createTempFile("rotated", ".jpg");
// 根据实际角度进行旋转
log.debug("旋转图片{}度: {} -> {}", rotation, currentFile.getName(), rotatedFile.getName());
rotateByDegree(currentFile, rotatedFile, rotation);
if (!rotatedFile.exists()) {
return StageResult.failed("旋转后的文件未生成");
}
context.updateProcessedFile(rotatedFile);
context.setRotationApplied(true);
log.info("图片已旋转{}度", rotation);
return StageResult.success("已旋转" + rotation + "");
} catch (Exception e) {
log.error("图片旋转失败", e);
return StageResult.failed("旋转失败: " + e.getMessage(), e);
}
}
/**
* 根据角度旋转图片
*/
private void rotateByDegree(File input, File output, int degree) throws Exception {
switch (degree) {
case 90:
ImageUtils.rotateImage90(input, output);
break;
case 180:
ImageUtils.rotateImage180(input, output);
break;
case 270:
ImageUtils.rotateImage270(input, output);
break;
default:
throw new IllegalArgumentException("不支持的旋转角度: " + degree);
}
}
}

View File

@@ -0,0 +1,76 @@
package com.ycwl.basic.image.pipeline.stages;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.io.File;
/**
* 下载图片Stage
* 从URL下载原图到本地临时文件
*/
@Slf4j
@StageConfig(
stageId = "download",
optionalMode = StageOptionalMode.FORCE_ON,
description = "下载图片",
defaultEnabled = true
)
public class DownloadStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "DownloadStage";
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
String url = context.getOriginalUrl();
if (StringUtils.isBlank(url)) {
return StageResult.failed("原图URL为空");
}
try {
String fileExtension = determineFileExtension(context);
String filePrefix = context.getImageType() == ImageType.PUZZLE ? "puzzle" : "print";
File downloadFile = context.getTempFileManager()
.createTempFile(filePrefix, fileExtension);
log.debug("开始下载图片: {} -> {}", url, downloadFile.getName());
HttpUtil.downloadFile(url, downloadFile);
if (!downloadFile.exists() || downloadFile.length() == 0) {
return StageResult.failed("下载的文件不存在或为空");
}
context.setOriginalFile(downloadFile);
log.info("图片下载成功: {} ({}KB)", downloadFile.getName(),
downloadFile.length() / 1024);
return StageResult.success(String.format("已下载 %dKB", downloadFile.length() / 1024));
} catch (Exception e) {
log.error("图片下载失败: {}", url, e);
return StageResult.failed("下载失败: " + e.getMessage(), e);
}
}
private String determineFileExtension(PhotoProcessContext context) {
if (context.getImageType() == ImageType.PUZZLE) {
return ".png";
}
String url = context.getOriginalUrl();
if (url.toLowerCase().endsWith(".png")) {
return ".png";
}
return ".jpg";
}
}

View File

@@ -0,0 +1,187 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.enhancer.adapter.BceImageEnhancer;
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
/**
* 图像增强Stage
* 使用百度云图像增强API提升图片清晰度和质量
*
* 支持两种增强模式:
* 1. 图像清晰度增强 (BceImageEnhancer)
* 2. 图像超分辨率 (BceImageSR)
*/
@Slf4j
@StageConfig(
stageId = "image_enhance",
optionalMode = StageOptionalMode.SUPPORT,
description = "图像增强处理",
defaultEnabled = false // 默认不启用,需要外部配置开启
)
public class ImageEnhanceStage extends AbstractPipelineStage<PhotoProcessContext> {
private BceEnhancerConfig enhancerConfig;
/**
* 构造函数 - 使用默认配置
*/
public ImageEnhanceStage() {
this(buildConfigFromEnvironment());
}
/**
* 构造函数 - 使用自定义配置
*
* @param enhancerConfig 图像增强配置
*/
public ImageEnhanceStage(BceEnhancerConfig enhancerConfig) {
this.enhancerConfig = enhancerConfig;
}
@Override
public String getName() {
return "ImageEnhanceStage";
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
// 仅对照片源为IPC的图片进行增强
return context.getSource() == ImageSource.IPC;
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
// 检查配置是否完整
if (!isConfigValid()) {
log.warn("图像增强配置不完整,跳过增强处理。请在ImageEnhanceStage中配置百度云API凭证");
return StageResult.skipped("配置不完整,跳过增强");
}
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.skipped("当前文件不存在");
}
try {
log.debug("开始图像增强: {}", currentFile.getName());
// 创建百度云图像增强客户端
BceImageEnhancer enhancer = new BceImageEnhancer();
enhancer.setConfig(enhancerConfig);
// 调用图像增强API
// 注意:百度云API需要传入图片URL,这里使用本地文件的绝对路径
String imageUrl = currentFile.getAbsolutePath();
MultipartFile enhancedImage = enhancer.enhance(imageUrl);
if (enhancedImage == null || enhancedImage.isEmpty()) {
log.warn("图像增强返回空结果,可能是API调用失败");
return StageResult.degraded("增强失败,使用原图");
}
// 保存增强后的图片到临时文件
File enhancedFile = context.getTempFileManager()
.createTempFile("enhanced", ".jpg");
saveMultipartFileToFile(enhancedImage, enhancedFile);
if (!enhancedFile.exists() || enhancedFile.length() == 0) {
return StageResult.degraded("增强结果保存失败,使用原图");
}
// 更新处理后的文件
context.updateProcessedFile(enhancedFile);
long originalSize = currentFile.length();
long enhancedSize = enhancedFile.length();
double sizeRatio = (double) enhancedSize / originalSize;
log.info("图像增强完成: 原始{}KB -> 增强后{}KB (比例: {})",
originalSize / 1024,
enhancedSize / 1024,
String.format("%.2f", sizeRatio));
return StageResult.success(String.format("已增强 (%dKB -> %dKB)",
originalSize / 1024,
enhancedSize / 1024));
} catch (Exception e) {
log.error("图像增强失败: {}", e.getMessage(), e);
// 增强失败时返回降级状态,继续使用原图
return StageResult.degraded("增强失败: " + e.getMessage());
}
}
/**
* 检查配置是否有效
*/
private boolean isConfigValid() {
if (enhancerConfig == null) {
return false;
}
String appId = enhancerConfig.getAppId();
String apiKey = enhancerConfig.getApiKey();
String secretKey = enhancerConfig.getSecretKey();
// 检查字段是否为 null 或空
if (appId == null || appId.isBlank() ||
apiKey == null || apiKey.isBlank() ||
secretKey == null || secretKey.isBlank()) {
return false;
}
// 检查是否包含 TODO 占位符
if (appId.contains("TODO") || apiKey.contains("TODO") || secretKey.contains("TODO")) {
return false;
}
return true;
}
private static BceEnhancerConfig buildConfigFromEnvironment() {
BceEnhancerConfig config = new BceEnhancerConfig();
config.setAppId(System.getenv("BCE_IMAGE_APP_ID"));
config.setApiKey(System.getenv("BCE_IMAGE_API_KEY"));
config.setSecretKey(System.getenv("BCE_IMAGE_SECRET_KEY"));
config.setQps(1.0f);
return config;
}
/**
* 保存MultipartFile到本地文件
*/
private void saveMultipartFileToFile(MultipartFile multipartFile, File targetFile) throws IOException {
try (FileOutputStream fos = new FileOutputStream(targetFile)) {
fos.write(multipartFile.getBytes());
fos.flush();
}
}
/**
* 获取当前配置(用于调试)
*/
public BceEnhancerConfig getEnhancerConfig() {
return enhancerConfig;
}
/**
* 设置配置(用于动态配置)
*/
public void setEnhancerConfig(BceEnhancerConfig enhancerConfig) {
this.enhancerConfig = enhancerConfig;
}
}

View File

@@ -0,0 +1,94 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.model.Crop;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 图片方向检测Stage
* 检测图片是横图还是竖图,并记录到Context
*/
@Slf4j
@StageConfig(
stageId = "orientation",
optionalMode = StageOptionalMode.UNSUPPORT,
description = "图片方向检测"
)
public class ImageOrientationStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "ImageOrientationStage";
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
return context.getImageType() == ImageType.NORMAL_PHOTO;
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
try {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
boolean isLandscape = detectOrientation(currentFile, context.getCrop());
context.setLandscape(isLandscape);
// 保存 rotation 信息到 context,方便后续 Stage 使用
if (context.getCrop() != null && context.getCrop().getRotation() != null) {
context.setImageRotation(context.getCrop().getRotation());
}
String orientation = isLandscape ? "横图" : "竖图";
log.info("图片方向检测: {}", orientation);
return StageResult.success(orientation);
} catch (Exception e) {
log.error("图片方向检测失败", e);
return StageResult.failed("方向检测失败: " + e.getMessage(), e);
}
}
/**
* 检测图片方向
* 综合考虑物理分辨率和rotation字段
* rotation表示"需要旋转多少度照片才能正确显示"
*/
private boolean detectOrientation(File imageFile, Crop crop) {
try {
// 先获取物理分辨率方向
boolean physicalLandscape = ImageUtils.isLandscape(imageFile);
// 如果有rotation信息,需要综合判断
if (crop != null && crop.getRotation() != null) {
int rotation = crop.getRotation();
// rotation=90/270 会翻转方向
boolean isLandscape = (rotation == 90 || rotation == 270) ? !physicalLandscape : physicalLandscape;
log.debug("综合判断方向: physicalLandscape={}, rotation={}, finalLandscape={}",
physicalLandscape, rotation, isLandscape);
return isLandscape;
}
// 没有rotation信息,直接使用物理分辨率
log.debug("从图片尺寸判断方向: isLandscape={}", physicalLandscape);
return physicalLandscape;
} catch (Exception e) {
log.warn("从图片尺寸判断方向失败,默认为横图: {}", e.getMessage());
return true;
}
}
}

View File

@@ -0,0 +1,124 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 图像质量检测Stage
* 检测图片质量,如果检测到质量不佳则动态添加ImageEnhanceStage
*
* 此Stage展示了如何在运行时动态添加后续Stage的能力
*/
@Slf4j
@StageConfig(
stageId = "quality_check",
optionalMode = StageOptionalMode.SUPPORT,
description = "图像质量检测",
defaultEnabled = false // 默认不启用
)
public class ImageQualityCheckStage extends AbstractPipelineStage<PhotoProcessContext> {
/**
* 质量阈值:图片尺寸小于此阈值认为质量不佳
* 例如:小于100KB的图片可能需要增强
*/
private static final long QUALITY_THRESHOLD_BYTES = 100 * 1024; // 100KB
/**
* 分辨率阈值:图片分辨率低于此值认为质量不佳
*/
private static final int MIN_WIDTH = 800;
private static final int MIN_HEIGHT = 600;
@Override
public String getName() {
return "ImageQualityCheckStage";
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
// 仅对普通照片执行质量检测
return context.getImageType() == ImageType.NORMAL_PHOTO;
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
try {
// 检查文件大小
long fileSize = currentFile.length();
log.debug("图像质量检测: 文件大小={}KB, 阈值={}KB",
fileSize / 1024,
QUALITY_THRESHOLD_BYTES / 1024);
boolean needsEnhancement = false;
String reason = "";
// 检查:文件大小
if (fileSize < QUALITY_THRESHOLD_BYTES) {
needsEnhancement = true;
reason = String.format("文件过小(%dKB < %dKB)",
fileSize / 1024,
QUALITY_THRESHOLD_BYTES / 1024);
}
// TODO: 可以添加更多质量检测维度
// 例如:使用BufferedImage读取图片获取分辨率
// 例如:使用OpenCV进行图片质量评估
// 例如:检查图片的EXIF信息
// 如果需要增强,动态添加ImageEnhanceStage
if (needsEnhancement) {
log.info("检测到图片质量不佳({}), 动态添加ImageEnhanceStage", reason);
ImageEnhanceStage enhanceStage = new ImageEnhanceStage();
// 使用successWithNext返回,动态添加后续Stage
return StageResult.successWithNext(
"质量不佳,添加增强Stage: " + reason,
enhanceStage
);
}
// 质量良好,无需增强
log.info("图像质量良好,无需增强: {}KB", fileSize / 1024);
return StageResult.success("质量良好,无需增强");
} catch (Exception e) {
log.error("图像质量检测失败", e);
// 检测失败时不影响管线,继续执行
return StageResult.degraded("质量检测失败: " + e.getMessage());
}
}
/**
* 示例:也可以根据其他条件动态添加不同的Stage
*/
@SuppressWarnings("unused")
private StageResult<PhotoProcessContext> checkAndAddMultipleStages(PhotoProcessContext context) {
// 示例:根据不同条件添加多个Stage
// 假设检测到需要多个增强操作
ImageEnhanceStage enhanceStage = new ImageEnhanceStage();
// 其他Stage...
// 可以一次性添加多个Stage
return StageResult.successWithNext(
"检测到需要多重处理",
enhanceStage
// 可以添加更多Stage
);
}
}

View File

@@ -0,0 +1,190 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.enhancer.adapter.BceImageSR;
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
/**
* 图像超分辨率Stage
* 使用百度云图像超分辨率API提升图片分辨率和清晰度
*
* 与ImageEnhanceStage的区别:
* - ImageEnhanceStage: 使用BceImageEnhancer进行清晰度增强
* - ImageSRStage: 使用BceImageSR进行超分辨率处理
*/
@Slf4j
@StageConfig(
stageId = "image_sr",
optionalMode = StageOptionalMode.SUPPORT,
description = "图像超分辨率处理",
defaultEnabled = false // 默认不启用,需要外部配置开启
)
public class ImageSRStage extends AbstractPipelineStage<PhotoProcessContext> {
private BceEnhancerConfig enhancerConfig;
/**
* 构造函数 - 使用默认配置
*/
public ImageSRStage() {
this(buildConfigFromEnvironment());
}
/**
* 构造函数 - 使用自定义配置
*
* @param enhancerConfig 图像增强配置
*/
public ImageSRStage(BceEnhancerConfig enhancerConfig) {
this.enhancerConfig = enhancerConfig;
}
@Override
public String getName() {
return "ImageSRStage";
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
// 仅对照片源为IPC的图片进行超分辨率处理
return context.getSource() == ImageSource.IPC;
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
// 检查配置是否完整
if (!isConfigValid()) {
log.warn("图像超分辨率配置不完整,跳过处理。请配置百度云API凭证");
return StageResult.skipped("配置不完整,跳过超分辨率");
}
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.skipped("当前文件不存在");
}
try {
log.debug("开始图像超分辨率处理: {}", currentFile.getName());
// 创建百度云图像超分辨率客户端
BceImageSR srEnhancer = new BceImageSR();
srEnhancer.setConfig(enhancerConfig);
// 调用图像超分辨率API
// 注意:百度云API需要传入图片URL,这里使用本地文件的绝对路径
String imageUrl = currentFile.getAbsolutePath();
MultipartFile enhancedImage = srEnhancer.enhance(imageUrl);
if (enhancedImage == null || enhancedImage.isEmpty()) {
log.warn("图像超分辨率返回空结果,可能是API调用失败");
return StageResult.degraded("超分辨率失败,使用原图");
}
// 保存超分辨率后的图片到临时文件
File enhancedFile = context.getTempFileManager()
.createTempFile("sr_enhanced", ".jpg");
saveMultipartFileToFile(enhancedImage, enhancedFile);
if (!enhancedFile.exists() || enhancedFile.length() == 0) {
return StageResult.degraded("超分辨率结果保存失败,使用原图");
}
// 更新处理后的文件
context.updateProcessedFile(enhancedFile);
long originalSize = currentFile.length();
long enhancedSize = enhancedFile.length();
double sizeRatio = (double) enhancedSize / originalSize;
log.info("图像超分辨率完成: 原始{}KB -> 超分后{}KB (比例: {})",
originalSize / 1024,
enhancedSize / 1024,
String.format("%.2f", sizeRatio));
return StageResult.success(String.format("超分辨率完成 (%dKB -> %dKB)",
originalSize / 1024,
enhancedSize / 1024));
} catch (Exception e) {
log.error("图像超分辨率失败: {}", e.getMessage(), e);
// 超分辨率失败时返回降级状态,继续使用原图
return StageResult.degraded("超分辨率失败: " + e.getMessage());
}
}
/**
* 检查配置是否有效
*/
private boolean isConfigValid() {
if (enhancerConfig == null) {
return false;
}
String appId = enhancerConfig.getAppId();
String apiKey = enhancerConfig.getApiKey();
String secretKey = enhancerConfig.getSecretKey();
// 检查字段是否为 null 或空
if (appId == null || appId.isBlank() ||
apiKey == null || apiKey.isBlank() ||
secretKey == null || secretKey.isBlank()) {
return false;
}
// 检查是否包含 TODO 占位符
if (appId.contains("TODO") || apiKey.contains("TODO") || secretKey.contains("TODO")) {
return false;
}
return true;
}
/**
* 从环境变量构建配置
*/
private static BceEnhancerConfig buildConfigFromEnvironment() {
BceEnhancerConfig config = new BceEnhancerConfig();
config.setAppId(System.getenv("BCE_IMAGE_APP_ID"));
config.setApiKey(System.getenv("BCE_IMAGE_API_KEY"));
config.setSecretKey(System.getenv("BCE_IMAGE_SECRET_KEY"));
config.setQps(1.0f);
return config;
}
/**
* 保存MultipartFile到本地文件
*/
private void saveMultipartFileToFile(MultipartFile multipartFile, File targetFile) throws IOException {
try (FileOutputStream fos = new FileOutputStream(targetFile)) {
fos.write(multipartFile.getBytes());
fos.flush();
}
}
/**
* 获取当前配置(用于调试)
*/
public BceEnhancerConfig getEnhancerConfig() {
return enhancerConfig;
}
/**
* 设置配置(用于动态配置)
*/
public void setEnhancerConfig(BceEnhancerConfig enhancerConfig) {
this.enhancerConfig = enhancerConfig;
}
}

View File

@@ -0,0 +1,17 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
public class NoOpStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
return StageResult.skipped("无操作");
}
@Override
public String getName() {
return "NoOpStage";
}
}

View File

@@ -0,0 +1,69 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 拼图边框处理Stage
* 为拼图添加白边框并向上偏移
*/
@Slf4j
@StageConfig(
stageId = "puzzle_border",
optionalMode = StageOptionalMode.SUPPORT,
description = "拼图边框处理",
defaultEnabled = true
)
public class PuzzleBorderStage extends AbstractPipelineStage<PhotoProcessContext> {
private static final int BORDER_LR = 20;
private static final int BORDER_TB = 30;
private static final int SHIFT_UP = 15;
@Override
public String getName() {
return "PuzzleBorderStage";
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
return context.getImageType() == ImageType.PUZZLE;
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
try {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
File processedFile = context.getTempFileManager()
.createTempFile("puzzle_processed", ".png");
log.debug("拼图添加边框: lr={}, tb={}, shiftUp={}", BORDER_LR, BORDER_TB, SHIFT_UP);
ImageUtils.addBorderAndShiftUp(currentFile, processedFile, BORDER_LR, BORDER_TB, SHIFT_UP);
if (!processedFile.exists()) {
return StageResult.failed("处理后的文件未生成");
}
context.updateProcessedFile(processedFile);
log.info("拼图边框处理完成: {}KB", processedFile.length() / 1024);
return StageResult.success(String.format("边框处理完成 (%dKB)", processedFile.length() / 1024));
} catch (Exception e) {
log.error("拼图边框处理失败", e);
return StageResult.failed("边框处理失败: " + e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,115 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.utils.ImageUtils;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 恢复图片方向Stage
* 如果之前旋转过图片,现在反向旋转恢复原方向
* - rotation=90 时恢复需要旋转270度
* - rotation=270 时恢复需要旋转90度
*/
@Slf4j
@StageConfig(
stageId = "restore",
optionalMode = StageOptionalMode.UNSUPPORT,
description = "恢复竖图方向"
)
public class RestoreOrientationStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override
public String getName() {
return "RestoreOrientationStage";
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
return context.getImageType() == ImageType.NORMAL_PHOTO && context.isRotationApplied();
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
try {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.failed("当前文件不存在");
}
Integer rotation = context.getImageRotation();
if (rotation == null) {
return StageResult.skipped("无旋转信息");
}
// 计算恢复旋转需要的角度(反向旋转)
int restoreAngle = getRestoreAngle(rotation);
String extension = getFileExtension(currentFile);
File finalFile = context.getTempFileManager()
.createTempFile("final", extension);
log.debug("恢复图片方向(旋转{}度): {} -> {}", restoreAngle, currentFile.getName(), finalFile.getName());
rotateByAngle(currentFile, finalFile, restoreAngle);
if (!finalFile.exists()) {
return StageResult.failed("旋转后的文件未生成");
}
context.updateProcessedFile(finalFile);
log.info("图片方向已恢复(旋转{}度)", restoreAngle);
return StageResult.success("已恢复原方向");
} catch (Exception e) {
log.error("图片旋转失败", e);
return StageResult.failed("旋转失败: " + e.getMessage(), e);
}
}
/**
* 计算恢复方向需要的旋转角度
* @param originalRotation 原始旋转角度
* @return 恢复需要的旋转角度
*/
private int getRestoreAngle(int originalRotation) {
// rotation=90 时,恢复需要旋转270度 (360-90=270)
// rotation=270 时,恢复需要旋转90度 (360-270=90)
// rotation=180 时,恢复需要旋转180度 (360-180=180)
return (360 - originalRotation) % 360;
}
/**
* 根据角度旋转图片
*/
private void rotateByAngle(File input, File output, int angle) throws Exception {
switch (angle) {
case 90:
ImageUtils.rotateImage90(input, output);
break;
case 180:
ImageUtils.rotateImage180(input, output);
break;
case 270:
ImageUtils.rotateImage270(input, output);
break;
default:
break;
}
}
private String getFileExtension(File file) {
String name = file.getName();
int lastDot = name.lastIndexOf('.');
if (lastDot > 0) {
return name.substring(lastDot);
}
return ".jpg";
}
}

View File

@@ -0,0 +1,82 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.service.pc.SourceService;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 源图片上传和更新Stage
* 专门用于将增强后的图片上传并更新数据库中的URL
*
* 与UploadStage的区别:
* - UploadStage: 通用上传,仅上传到存储并设置Context.resultUrl
* - SourcePhotoUpdateStage: 专门用于源图片,上传+更新数据库source表的URL字段
*/
@Slf4j
@StageConfig(
stageId = "source_photo_update",
optionalMode = StageOptionalMode.UNSUPPORT,
description = "源图片上传和数据库更新",
defaultEnabled = true
)
public class SourcePhotoUpdateStage extends AbstractPipelineStage<PhotoProcessContext> {
private final SourceService sourceService;
private final Long sourceId;
/**
* 构造函数
*
* @param sourceService 源图片服务
* @param sourceId 源图片ID
*/
public SourcePhotoUpdateStage(SourceService sourceService, Long sourceId) {
this.sourceService = sourceService;
this.sourceId = sourceId;
}
@Override
public String getName() {
return "SourcePhotoUpdateStage";
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
File fileToUpload = context.getCurrentFile();
if (fileToUpload == null || !fileToUpload.exists()) {
return StageResult.failed("没有可上传的文件");
}
if (sourceService == null) {
return StageResult.failed("SourceService未注入");
}
if (sourceId == null) {
return StageResult.failed("SourceId为空");
}
try {
// 调用SourceService上传并更新URL
String uploadedUrl = sourceService.uploadAndUpdateUrl(sourceId, fileToUpload);
// 设置结果URL到Context
context.setResultUrl(uploadedUrl);
log.info("源图片上传并更新成功: sourceId={}, url={}", sourceId, uploadedUrl);
return StageResult.success("已上传并更新: " + uploadedUrl);
} catch (Exception e) {
log.error("源图片上传并更新失败: sourceId={}", sourceId, e);
return StageResult.failed("上传并更新失败: " + e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,103 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.storage.enums.StorageAcl;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
/**
* 上传图片Stage
* 支持降级: 配置的存储 -> 默认assets-ext存储
*/
@Slf4j
@StageConfig(
stageId = "upload",
optionalMode = StageOptionalMode.FORCE_ON,
description = "上传图片到存储",
defaultEnabled = true
)
public class UploadStage extends AbstractPipelineStage<PhotoProcessContext> {
private static final String DEFAULT_STORAGE = "assets-ext";
@Override
public String getName() {
return "UploadStage";
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
File fileToUpload = context.getCurrentFile();
if (fileToUpload == null || !fileToUpload.exists()) {
return StageResult.failed("没有可上传的文件");
}
IStorageAdapter adapter = context.getStorageAdapter();
boolean usingDefaultStorage = false;
if (adapter == null) {
log.debug("未配置存储适配器,使用默认存储: {}", DEFAULT_STORAGE);
try {
adapter = StorageFactory.use(DEFAULT_STORAGE);
usingDefaultStorage = true;
} catch (Exception e) {
return StageResult.failed("无法获取默认存储: " + e.getMessage(), e);
}
}
try {
String uploadedUrl = uploadFile(adapter, fileToUpload);
context.setResultUrl(uploadedUrl);
log.info("文件上传成功: {}", uploadedUrl);
if (usingDefaultStorage) {
return StageResult.degraded("降级: 使用默认存储 " + DEFAULT_STORAGE);
}
return StageResult.success("已上传");
} catch (Exception e) {
log.error("文件上传失败", e);
if (!usingDefaultStorage) {
log.warn("尝试降级到默认存储");
try {
IStorageAdapter defaultAdapter = StorageFactory.use(DEFAULT_STORAGE);
String uploadedUrl = uploadFile(defaultAdapter, fileToUpload);
context.setResultUrl(uploadedUrl);
log.info("降级上传成功: {}", uploadedUrl);
return StageResult.degraded("降级: 使用默认存储 " + DEFAULT_STORAGE);
} catch (Exception fallbackEx) {
log.error("降级上传也失败", fallbackEx);
return StageResult.failed("上传失败(包括降级): " + fallbackEx.getMessage(), fallbackEx);
}
}
return StageResult.failed("上传失败: " + e.getMessage(), e);
}
}
private String uploadFile(IStorageAdapter adapter, File file) throws Exception {
String filename = file.getName();
String extension = filename.substring(filename.lastIndexOf('.') + 1);
if (extension.equals("jpg")) {
extension = "jpeg";
}
String uploadPath = "print/" + filename;
String url = adapter.uploadFile("image/" + extension, file, uploadPath);
adapter.setAcl(StorageAcl.PUBLIC_READ, uploadPath);
return url;
}
}

View File

@@ -0,0 +1,37 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import lombok.Builder;
import lombok.Getter;
import java.io.File;
/**
* 水印Stage配置
* 封装水印处理所需的所有配置参数
*/
@Getter
@Builder
public class WatermarkConfig {
/**
* 水印类型
*/
private final ImageWatermarkOperatorEnum watermarkType;
/**
* 景区文字
*/
private final String scenicText;
/**
* 日期格式
*/
@Builder.Default
private final String dateFormat = "yyyy.MM.dd";
/**
* 二维码文件
*/
private final File qrcodeFile;
}

View File

@@ -0,0 +1,184 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.image.watermark.operator.IOperator;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.io.File;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
/**
* 水印处理Stage
* 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印
*/
@Slf4j
@StageConfig(
stageId = "watermark",
optionalMode = StageOptionalMode.SUPPORT,
description = "水印处理",
defaultEnabled = true
)
public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
private static final int OFFSET_FOR_PRINTER = 40;
private final WatermarkConfig config;
/**
* 构造函数
*
* @param config 水印配置
*/
public WatermarkStage(WatermarkConfig config) {
this.config = config;
}
@Override
public String getName() {
return "WatermarkStage";
}
@Override
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
return context.getImageType() == ImageType.NORMAL_PHOTO;
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
if (config == null || config.getWatermarkType() == null) {
log.info("未配置水印类型,跳过水印处理");
return StageResult.skipped("未配置水印");
}
ImageWatermarkOperatorEnum watermarkType = config.getWatermarkType();
List<ImageWatermarkOperatorEnum> fallbackChain = buildFallbackChain(watermarkType);
log.debug("水印降级链: {}", fallbackChain);
for (int i = 0; i < fallbackChain.size(); i++) {
ImageWatermarkOperatorEnum type = fallbackChain.get(i);
if (type == null) {
log.warn("所有水印处理均失败,跳过水印");
return StageResult.degraded("降级: 跳过水印处理");
}
try {
StageResult<PhotoProcessContext> result = applyWatermark(context, type);
if (i > 0) {
String degradeMsg = String.format("降级: %s -> %s",
watermarkType.getType(), type.getType());
log.warn(degradeMsg);
return StageResult.degraded(degradeMsg);
}
return result;
} catch (Exception e) {
log.warn("水印类型 {} 处理失败: {}", type.getType(), e.getMessage());
if (i == fallbackChain.size() - 2) {
log.warn("所有水印类型均失败,准备跳过水印", e);
}
}
}
return StageResult.degraded("降级: 跳过水印处理");
}
/**
* 构建降级链
*/
private List<ImageWatermarkOperatorEnum> buildFallbackChain(ImageWatermarkOperatorEnum primary) {
if (primary == ImageWatermarkOperatorEnum.PRINTER_DEFAULT) {
return Arrays.asList(primary, null);
}
return Arrays.asList(
primary,
ImageWatermarkOperatorEnum.PRINTER_DEFAULT,
null
);
}
/**
* 应用水印
*/
private StageResult<PhotoProcessContext> applyWatermark(PhotoProcessContext context, ImageWatermarkOperatorEnum type)
throws Exception {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
throw new IllegalStateException("当前文件不存在");
}
String fileExt = type.getPreferFileType();
File watermarkedFile = context.getTempFileManager()
.createTempFile("watermark_" + type.getType(), "." + fileExt);
WatermarkInfo watermarkInfo = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
IOperator operator = ImageWatermarkFactory.get(type);
File result = operator.process(watermarkInfo);
if (result == null || !result.exists()) {
throw new RuntimeException("水印处理后文件不存在");
}
context.updateProcessedFile(result);
log.info("水印应用成功: type={}, size={}KB", type.getType(), result.length() / 1024);
return StageResult.success(String.format("水印: %s (%dKB)",
type.getType(), result.length() / 1024));
}
/**
* 构建水印参数
*/
private WatermarkInfo buildWatermarkInfo(PhotoProcessContext context, File originalFile,
File watermarkedFile, ImageWatermarkOperatorEnum type) {
WatermarkInfo info = new WatermarkInfo();
info.setOriginalFile(originalFile);
info.setWatermarkedFile(watermarkedFile);
// 从 config 读取景区文字
String scenicText = config.getScenicText();
if (StringUtils.isNotBlank(scenicText)) {
info.setScenicLine(scenicText);
}
// 从 config 读取日期格式
Date now = new Date();
String dateFormat = config.getDateFormat();
info.setDatetime(now);
info.setDtFormat(dateFormat);
// 从 config 读取二维码文件
File qrcodeFile = config.getQrcodeFile();
if (qrcodeFile != null && qrcodeFile.exists()) {
info.setQrcodeFile(qrcodeFile);
}
// 根据旋转状态自己处理 offsetLeft
if (context.isRotationApplied()) {
if (context.getImageRotation() == 90) {
info.setOffsetLeft(OFFSET_FOR_PRINTER);
} else if (context.getImageRotation() == 270) {
info.setOffsetRight(OFFSET_FOR_PRINTER);
}
}
return info;
}
}

View File

@@ -0,0 +1,127 @@
package com.ycwl.basic.image.pipeline.util;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 临时文件管理器
* 统一管理所有临时文件的创建和清理
*/
@Slf4j
public class TempFileManager {
private final String processId;
private final Path tempDir;
private final List<File> tempFiles;
public TempFileManager() {
this.processId = UUID.randomUUID().toString();
this.tempDir = initTempDirectory();
this.tempFiles = new ArrayList<>();
}
public TempFileManager(String processId) {
this.processId = processId;
this.tempDir = initTempDirectory();
this.tempFiles = new ArrayList<>();
}
private Path initTempDirectory() {
Path baseDir = Path.of(System.getProperty("java.io.tmpdir"), "photo_process", processId);
try {
Files.createDirectories(baseDir);
log.debug("创建隔离临时目录: {}", baseDir);
return baseDir;
} catch (IOException e) {
log.warn("无法创建隔离临时目录,尝试使用系统临时目录", e);
try {
Path fallback = Files.createTempDirectory("photo_process_" + processId + "_");
log.debug("创建备用临时目录: {}", fallback);
return fallback;
} catch (IOException ex) {
log.warn("无法创建系统临时目录,使用当前目录", ex);
return Path.of(".");
}
}
}
/**
* 创建临时文件
*
* @param prefix 文件名前缀
* @param suffix 文件名后缀(如 .jpg, .png)
* @return 临时文件
*/
public File createTempFile(String prefix, String suffix) {
String filename = String.format("%s_%s%s", prefix, processId, suffix);
File tempFile = tempDir.resolve(filename).toFile();
tempFiles.add(tempFile);
log.debug("创建临时文件: {}", tempFile.getAbsolutePath());
return tempFile;
}
/**
* 注册外部创建的临时文件
*/
public void registerTempFile(File file) {
if (file != null && !tempFiles.contains(file)) {
tempFiles.add(file);
log.debug("注册临时文件: {}", file.getAbsolutePath());
}
}
/**
* 清理所有临时文件
*/
public void cleanup() {
int deletedCount = 0;
int failedCount = 0;
for (File file : tempFiles) {
if (file != null && file.exists()) {
boolean deleted = file.delete();
if (deleted) {
deletedCount++;
log.debug("删除临时文件: {}", file.getAbsolutePath());
} else {
failedCount++;
log.warn("无法删除临时文件: {}", file.getAbsolutePath());
}
}
}
tempFiles.clear();
if (deletedCount > 0) {
log.info("清理临时文件: 成功{}个, 失败{}个", deletedCount, failedCount);
}
cleanupTempDirectory();
}
private void cleanupTempDirectory() {
if (tempDir != null && !tempDir.equals(Path.of("."))) {
try {
Files.deleteIfExists(tempDir);
log.debug("删除临时目录: {}", tempDir);
} catch (IOException e) {
log.warn("无法删除临时目录: {}", tempDir, e);
}
}
}
public String getProcessId() {
return processId;
}
public int getTempFileCount() {
return tempFiles.size();
}
}

View File

@@ -1,54 +0,0 @@
package com.ycwl.basic.integration.device.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.device.dto.defaults.*;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 默认配置管理 Feign 客户端
*/
@FeignClient(name = "zt-device", contextId = "device-default-config", path = "/api/device/config/v2/defaults")
public interface DefaultConfigClient {
/**
* 获取默认配置列表
*/
@GetMapping
CommonResponse<PageResponse<DefaultConfigResponse>> listDefaultConfigs(@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize);
/**
* 根据配置键获取默认配置
*/
@GetMapping("/{configKey}")
CommonResponse<DefaultConfigResponse> getDefaultConfig(@PathVariable("configKey") String configKey);
/**
* 创建默认配置
*/
@PostMapping
CommonResponse<String> createDefaultConfig(@RequestBody DefaultConfigRequest request);
/**
* 更新默认配置
*/
@PutMapping("/{configKey}")
CommonResponse<Map<String, Object>> updateDefaultConfig(@PathVariable("configKey") String configKey,
@RequestBody Map<String, Object> updates);
/**
* 删除默认配置
*/
@DeleteMapping("/{configKey}")
CommonResponse<String> deleteDefaultConfig(@PathVariable("configKey") String configKey);
/**
* 批量更新默认配置
*/
@PostMapping("/batch")
CommonResponse<BatchDefaultConfigResponse> batchUpdateDefaultConfigs(@RequestBody BatchDefaultConfigRequest request);
}

View File

@@ -1,62 +0,0 @@
package com.ycwl.basic.integration.device.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.device.dto.config.*;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@FeignClient(name = "zt-device", contextId = "device-config-v2", path = "/api/device/config/v2")
public interface DeviceConfigV2Client {
/**
* 获取设备所有配置
*/
@GetMapping("/{deviceId}")
CommonResponse<List<DeviceConfigV2DTO>> getDeviceConfigs(@PathVariable("deviceId") Long deviceId);
/**
* 根据设备编号获取设备所有配置
*/
@GetMapping("/no/{no}")
CommonResponse<List<DeviceConfigV2DTO>> getDeviceConfigsByNo(@PathVariable("no") String no);
/**
* 获取设备特定配置
*/
@GetMapping("/{deviceId}/key/{configKey}")
CommonResponse<DeviceConfigV2DTO> getDeviceConfigByKey(@PathVariable("deviceId") Long deviceId,
@PathVariable("configKey") String configKey);
/**
* 创建设备配置
*/
@PostMapping("/{deviceId}")
CommonResponse<DeviceConfigV2DTO> createDeviceConfig(@PathVariable("deviceId") Long deviceId,
@RequestBody CreateDeviceConfigRequest request);
/**
* 更新设备配置
*/
@PutMapping("/{deviceId}/{id}")
CommonResponse<String> updateDeviceConfig(@PathVariable("deviceId") Long deviceId,
@PathVariable("id") Long id,
@RequestBody UpdateDeviceConfigRequest request);
/**
* 删除设备配置
*/
@DeleteMapping("/{deviceId}/{id}")
CommonResponse<String> deleteDeviceConfig(@PathVariable("deviceId") Long deviceId,
@PathVariable("id") Long id);
/**
* 批量更新设备配置
*/
@PostMapping("/{deviceId}/batch")
CommonResponse<BatchUpdateResponse> batchUpdateDeviceConfig(@PathVariable("deviceId") Long deviceId,
@RequestBody BatchDeviceConfigRequest request);
}

View File

@@ -1,51 +0,0 @@
package com.ycwl.basic.integration.device.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.device.dto.status.DeviceStatusDTO;
import com.ycwl.basic.integration.device.dto.status.OnlineStatusResponseDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.List;
@FeignClient(name = "zt-device", contextId = "deviceStatusClient", path = "/api/device/status")
public interface DeviceStatusClient {
/**
* 获取设备状态
*/
@GetMapping("/{deviceNo}")
CommonResponse<DeviceStatusDTO> getDeviceStatus(@PathVariable("deviceNo") String deviceNo);
/**
* 检查设备是否在线
*/
@GetMapping("/{deviceNo}/online")
CommonResponse<OnlineStatusResponseDTO> isDeviceOnline(@PathVariable("deviceNo") String deviceNo);
/**
* 获取所有在线设备
*/
@GetMapping("/online")
CommonResponse<List<DeviceStatusDTO>> getAllOnlineDevices();
/**
* 设置设备离线
*/
@PostMapping("/{deviceNo}/offline")
CommonResponse<String> setDeviceOffline(@PathVariable("deviceNo") String deviceNo);
/**
* 设置设备在线
*/
@PostMapping("/{deviceNo}/online")
CommonResponse<String> setDeviceOnline(@PathVariable("deviceNo") String deviceNo);
/**
* 清理过期设备状态
*/
@PostMapping("/clean")
CommonResponse<String> cleanExpiredDevices();
}

View File

@@ -2,48 +2,57 @@ package com.ycwl.basic.integration.device.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.device.dto.config.*;
import com.ycwl.basic.integration.device.dto.defaults.*;
import com.ycwl.basic.integration.device.dto.device.*;
import com.ycwl.basic.integration.device.dto.status.DeviceStatusDTO;
import com.ycwl.basic.integration.device.dto.status.OnlineStatusResponseDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name = "zt-device", contextId = "device-v2", path = "/api/device/v2")
import java.util.List;
import java.util.Map;
@FeignClient(name = "zt-device", contextId = "device-v2", path = "/api/device")
public interface DeviceV2Client {
// ==================== Device V2 Operations ====================
/**
* 获取设备核心信息
*/
@GetMapping("/{id}")
@GetMapping("v2/{id}")
CommonResponse<DeviceV2DTO> getDevice(@PathVariable("id") Long id);
/**
* 根据设备编号获取设备核心信息
*/
@GetMapping("/no/{no}")
@GetMapping("v2/no/{no}")
CommonResponse<DeviceV2DTO> getDeviceByNo(@PathVariable("no") String no);
/**
* 创建设备
*/
@PostMapping("/")
@PostMapping("v2/")
CommonResponse<DeviceV2DTO> createDevice(@RequestBody CreateDeviceRequest request);
/**
* 更新设备
*/
@PutMapping("/{id}")
@PutMapping("v2/{id}")
CommonResponse<String> updateDevice(@PathVariable("id") Long id,
@RequestBody UpdateDeviceRequest request);
/**
* 删除设备
*/
@DeleteMapping("/{id}")
@DeleteMapping("v2/{id}")
CommonResponse<String> deleteDevice(@PathVariable("id") Long id);
/**
* 分页获取设备列表(核心信息)
*/
@GetMapping("/")
@GetMapping("v2/")
CommonResponse<PageResponse<DeviceV2DTO>> listDevices(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize,
@@ -56,7 +65,135 @@ public interface DeviceV2Client {
/**
* 根据配置条件筛选设备
*/
@PostMapping("/filter-by-configs")
@PostMapping("v2/filter-by-configs")
CommonResponse<FilterDevicesByConfigsResponse> filterDevicesByConfigs(
@RequestBody FilterDevicesByConfigsRequest request);
// ==================== Device Config V2 Operations ====================
/**
* 获取设备所有配置
*/
@GetMapping("config/v2/{deviceId}")
CommonResponse<List<DeviceConfigV2DTO>> getDeviceConfigs(@PathVariable("deviceId") Long deviceId);
/**
* 根据设备编号获取设备所有配置
*/
@GetMapping("config/v2/no/{no}")
CommonResponse<List<DeviceConfigV2DTO>> getDeviceConfigsByNo(@PathVariable("no") String no);
/**
* 获取设备特定配置
*/
@GetMapping("config/v2/{deviceId}/key/{configKey}")
CommonResponse<DeviceConfigV2DTO> getDeviceConfigByKey(@PathVariable("deviceId") Long deviceId,
@PathVariable("configKey") String configKey);
/**
* 创建设备配置
*/
@PostMapping("config/v2/{deviceId}")
CommonResponse<DeviceConfigV2DTO> createDeviceConfig(@PathVariable("deviceId") Long deviceId,
@RequestBody CreateDeviceConfigRequest request);
/**
* 更新设备配置
*/
@PutMapping("config/v2/{deviceId}/{id}")
CommonResponse<String> updateDeviceConfig(@PathVariable("deviceId") Long deviceId,
@PathVariable("id") Long id,
@RequestBody UpdateDeviceConfigRequest request);
/**
* 删除设备配置
*/
@DeleteMapping("config/v2/{deviceId}/{id}")
CommonResponse<String> deleteDeviceConfig(@PathVariable("deviceId") Long deviceId,
@PathVariable("id") Long id);
/**
* 批量更新设备配置
*/
@PostMapping("config/v2/{deviceId}/batch")
CommonResponse<BatchUpdateResponse> batchUpdateDeviceConfig(@PathVariable("deviceId") Long deviceId,
@RequestBody BatchDeviceConfigRequest request);
// ==================== Default Config Operations ====================
/**
* 获取默认配置列表
*/
@GetMapping("config/v2/defaults")
CommonResponse<PageResponse<DefaultConfigResponse>> listDefaultConfigs(@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize);
/**
* 根据配置键获取默认配置
*/
@GetMapping("config/v2/defaults/{configKey}")
CommonResponse<DefaultConfigResponse> getDefaultConfig(@PathVariable("configKey") String configKey);
/**
* 创建默认配置
*/
@PostMapping("config/v2/defaults")
CommonResponse<String> createDefaultConfig(@RequestBody DefaultConfigRequest request);
/**
* 更新默认配置
*/
@PutMapping("config/v2/defaults/{configKey}")
CommonResponse<Map<String, Object>> updateDefaultConfig(@PathVariable("configKey") String configKey,
@RequestBody Map<String, Object> updates);
/**
* 删除默认配置
*/
@DeleteMapping("config/v2/defaults/{configKey}")
CommonResponse<String> deleteDefaultConfig(@PathVariable("configKey") String configKey);
/**
* 批量更新默认配置
*/
@PostMapping("config/v2/defaults/batch")
CommonResponse<BatchDefaultConfigResponse> batchUpdateDefaultConfigs(@RequestBody BatchDefaultConfigRequest request);
// ==================== Device Status Operations ====================
/**
* 获取设备状态
*/
@GetMapping("status/{deviceNo}")
CommonResponse<DeviceStatusDTO> getDeviceStatus(@PathVariable("deviceNo") String deviceNo);
/**
* 检查设备是否在线
*/
@GetMapping("status/{deviceNo}/online")
CommonResponse<OnlineStatusResponseDTO> isDeviceOnline(@PathVariable("deviceNo") String deviceNo);
/**
* 获取所有在线设备
*/
@GetMapping("status/online")
CommonResponse<List<DeviceStatusDTO>> getAllOnlineDevices();
/**
* 设置设备离线
*/
@PostMapping("status/{deviceNo}/offline")
CommonResponse<String> setDeviceOffline(@PathVariable("deviceNo") String deviceNo);
/**
* 设置设备在线
*/
@PostMapping("status/{deviceNo}/online")
CommonResponse<String> setDeviceOnline(@PathVariable("deviceNo") String deviceNo);
/**
* 清理过期设备状态
*/
@PostMapping("status/clean")
CommonResponse<String> cleanExpiredDevices();
}

View File

@@ -3,7 +3,7 @@ package com.ycwl.basic.integration.device.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.device.client.DeviceConfigV2Client;
import com.ycwl.basic.integration.device.client.DeviceV2Client;
import com.ycwl.basic.integration.device.dto.config.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -18,7 +18,7 @@ import java.util.Map;
@RequiredArgsConstructor
public class DeviceConfigIntegrationService {
private final DeviceConfigV2Client deviceConfigV2Client;
private final DeviceV2Client deviceConfigV2Client;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-device";

View File

@@ -4,7 +4,7 @@ import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.device.client.DefaultConfigClient;
import com.ycwl.basic.integration.device.client.DeviceV2Client;
import com.ycwl.basic.integration.device.dto.defaults.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -21,7 +21,7 @@ import java.util.Map;
@RequiredArgsConstructor
public class DeviceDefaultConfigIntegrationService {
private final DefaultConfigClient defaultConfigClient;
private final DeviceV2Client defaultConfigClient;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-device";

View File

@@ -3,7 +3,7 @@ package com.ycwl.basic.integration.device.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.device.client.DeviceStatusClient;
import com.ycwl.basic.integration.device.client.DeviceV2Client;
import com.ycwl.basic.integration.device.dto.status.DeviceStatusDTO;
import com.ycwl.basic.integration.device.dto.status.OnlineStatusResponseDTO;
import lombok.RequiredArgsConstructor;
@@ -18,7 +18,7 @@ import java.util.Optional;
@RequiredArgsConstructor
public class DeviceStatusIntegrationService {
private final DeviceStatusClient deviceStatusClient;
private final DeviceV2Client deviceStatusClient;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-device";

View File

@@ -1,49 +0,0 @@
package com.ycwl.basic.integration.kafka.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 人脸识别异步处理线程池配置
*/
@Slf4j
@Configuration
@ConditionalOnProperty(name = "kafka.enabled", havingValue = "true")
public class FaceRecognitionThreadPoolConfig {
/**
* 创建人脸识别专用线程池
* - 核心线程数:32
* - 最大线程数:128
* - 队列容量:1000(避免无限制增长)
* - 拒绝策略:CallerRunsPolicy(调用者线程执行)
*/
@Bean(name = "faceRecognitionExecutor", destroyMethod = "shutdown")
public ThreadPoolExecutor faceRecognitionExecutor() {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
32, // 核心线程数
128, // 最大线程数
60L, // 空闲线程存活时间
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingQueue<>(1000), // 任务队列
r -> {
Thread thread = new Thread(r);
thread.setName("face-recognition-" + thread.getId());
thread.setDaemon(false);
return thread;
},
new ThreadPoolExecutor.CallerRunsPolicy() // 超过容量时由调用者线程执行
);
log.info("人脸识别线程池初始化完成 - 核心线程数: {}, 最大线程数: {}, 队列容量: 1000",
executor.getCorePoolSize(), executor.getMaximumPoolSize());
return executor;
}
}

View File

@@ -0,0 +1,180 @@
package com.ycwl.basic.integration.kafka.scheduler;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 账号级别的人脸识别调度器管理
* 每个账号(accessKeyId/appId)拥有独立的:
* 1. 线程池 - 资源隔离
* 2. QPS调度器 - 精确控制每个账号的QPS
* 3. 任务队列 - 独立排队
* <p>
* 核心优势:
* - 多个阿里云账号互不影响,充分利用多账号QPS优势
* - 百度云和阿里云任务完全隔离
* - 每个账号严格按自己的QPS限制调度
*/
@Slf4j
@Component
public class AccountFaceSchedulerManager {
// 账号 -> 调度器上下文的映射
private final ConcurrentHashMap<String, AccountSchedulerContext> schedulers = new ConcurrentHashMap<>();
/**
* 获取或创建账号的调度器上下文
*
* @param accountKey 账号唯一标识 (accessKeyId 或 appId)
* @param cloudType 云类型 ("ALI" 或 "BAIDU")
* @param qps 该账号的QPS限制
* @return 调度器上下文
*/
public AccountSchedulerContext getOrCreateScheduler(
String accountKey,
String cloudType,
float qps
) {
return schedulers.computeIfAbsent(accountKey, key -> {
log.info("创建账号调度器: accountKey={}, cloudType={}, qps={}",
accountKey, cloudType, qps);
return createSchedulerContext(accountKey, cloudType, qps);
});
}
/**
* 创建调度器上下文
*/
private AccountSchedulerContext createSchedulerContext(
String accountKey,
String cloudType,
float qps
) {
// 根据云类型和QPS计算线程池参数
ThreadPoolConfig poolConfig = calculateThreadPoolConfig(cloudType, qps);
// 创建独立线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
poolConfig.coreSize,
poolConfig.maxSize,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(poolConfig.queueCapacity),
new ThreadFactoryBuilder()
.setNameFormat(cloudType.toLowerCase() + "-" + accountKey.substring(0, Math.min(8, accountKey.length())) + "-%d")
.build(),
new ThreadPoolExecutor.AbortPolicy() // 快速失败,避免阻塞
);
// 创建QPS调度器
QpsScheduler scheduler = new QpsScheduler(
Math.round(qps), // 每秒调度的任务数
poolConfig.maxConcurrent, // 最大并发数
executor
);
log.info("账号调度器创建成功: accountKey={}, threadPool=[core={}, max={}, queue={}], qps={}, maxConcurrent={}",
accountKey,
poolConfig.coreSize,
poolConfig.maxSize,
poolConfig.queueCapacity,
Math.round(qps),
poolConfig.maxConcurrent);
return new AccountSchedulerContext(accountKey, cloudType, executor, scheduler);
}
/**
* 根据云类型和QPS计算线程池参数
*/
private ThreadPoolConfig calculateThreadPoolConfig(String cloudType, float qps) {
// 假设每个任务平均执行时间 500ms
int avgExecutionTimeMs = 500;
// 所需线程数 = QPS × 平均执行时间(秒)
int requiredThreads = Math.max(1, (int) Math.ceil(qps * avgExecutionTimeMs / 1000.0));
// 核心线程数 = 所需线程数 × 2 (留有余量)
int coreSize = requiredThreads * 2;
// 最大线程数 = 核心线程数 × 2
int maxSize = coreSize * 2;
// 队列容量 = QPS × 60 (可容纳1分钟的任务)
int queueCapacity = Math.max(100, (int) (qps * 60));
// 最大并发数 = 所需线程数 × 1.5 (防止瞬时抖动)
int maxConcurrent = Math.max(2, (int) (requiredThreads * 1.5));
log.debug("计算线程池参数 - cloudType={}, qps={}, requiredThreads={}, coreSize={}, maxSize={}, queue={}, maxConcurrent={}",
cloudType, qps, requiredThreads, coreSize, maxSize, queueCapacity, maxConcurrent);
return new ThreadPoolConfig(coreSize, maxSize, queueCapacity, maxConcurrent);
}
/**
* 获取所有调度器的监控信息
*/
public Map<String, AccountSchedulerStats> getAllStats() {
Map<String, AccountSchedulerStats> stats = new HashMap<>();
schedulers.forEach((key, ctx) -> {
stats.put(key, new AccountSchedulerStats(
ctx.getAccountKey(),
ctx.getCloudType(),
ctx.getExecutor().getActiveCount(),
ctx.getExecutor().getQueue().size(),
ctx.getScheduler().getQueueSize()
));
});
return stats;
}
/**
* 关闭所有调度器 (应用关闭时调用)
*/
public void shutdownAll() {
log.info("关闭所有账号调度器, total={}", schedulers.size());
schedulers.forEach((key, ctx) -> {
try {
ctx.getScheduler().shutdown();
ctx.getExecutor().shutdown();
} catch (Exception e) {
log.error("关闭调度器失败, accountKey={}", key, e);
}
});
}
/**
* 线程池配置
*/
@Data
@AllArgsConstructor
static class ThreadPoolConfig {
int coreSize;
int maxSize;
int queueCapacity;
int maxConcurrent;
}
/**
* 账号调度器统计信息
*/
@Data
@AllArgsConstructor
public static class AccountSchedulerStats {
String accountKey;
String cloudType;
int activeThreads;
int executorQueueSize;
int schedulerQueueSize;
}
}

View File

@@ -0,0 +1,34 @@
package com.ycwl.basic.integration.kafka.scheduler;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 账号调度器上下文
* 封装每个账号的线程池和QPS调度器
*/
@Data
@AllArgsConstructor
public class AccountSchedulerContext {
/**
* 账号唯一标识 (accessKeyId 或 appId)
*/
private String accountKey;
/**
* 云类型 ("ALI" 或 "BAIDU")
*/
private String cloudType;
/**
* 该账号专属的线程池
*/
private ThreadPoolExecutor executor;
/**
* 该账号专属的QPS调度器
*/
private QpsScheduler scheduler;
}

View File

@@ -0,0 +1,114 @@
package com.ycwl.basic.integration.kafka.scheduler;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.*;
/**
* QPS 调度器
* 定期从队列取任务,严格控制 QPS
* 每秒调度固定数量的任务,确保不超过云端 API 的 QPS 限制
*/
@Slf4j
public class QpsScheduler {
private final BlockingQueue<Runnable> taskQueue;
private final ThreadPoolExecutor workerPool;
private final ScheduledExecutorService scheduler;
private final int qps;
private final Semaphore concurrentLimiter; // 并发数限制,防止瞬时抖动
/**
* 创建 QPS 调度器
*
* @param qps 每秒允许的最大请求数
* @param maxConcurrent 最大并发数
* @param workerPool 工作线程池
*/
public QpsScheduler(int qps, int maxConcurrent, ThreadPoolExecutor workerPool) {
this.qps = qps;
this.taskQueue = new LinkedBlockingQueue<>();
this.workerPool = workerPool;
this.scheduler = new ScheduledThreadPoolExecutor(1, r -> {
Thread thread = new Thread(r);
thread.setName("qps-scheduler-" + workerPool.getThreadFactory().newThread(() -> {}).getName());
thread.setDaemon(true);
return thread;
});
this.concurrentLimiter = new Semaphore(maxConcurrent);
// 每秒调度一次,取 qps 个任务
scheduler.scheduleAtFixedRate(this::dispatch, 0, 1, TimeUnit.SECONDS);
log.info("QPS调度器已启动: qps={}, maxConcurrent={}", qps, maxConcurrent);
}
/**
* 调度任务
* 每秒执行一次,从队列中取出 qps 个任务提交到工作线程池
*/
private void dispatch() {
int dispatched = 0;
for (int i = 0; i < qps; i++) {
Runnable task = taskQueue.poll();
if (task == null) {
break; // 队列为空,结束本次调度
}
// 检查并发数限制
if (concurrentLimiter.tryAcquire()) {
try {
workerPool.execute(() -> {
try {
task.run();
} catch (Exception e) {
log.error("任务执行失败", e);
} finally {
concurrentLimiter.release();
}
});
dispatched++;
} catch (RejectedExecutionException e) {
// 线程池拒绝,释放并发许可,任务丢弃
concurrentLimiter.release();
log.warn("任务被线程池拒绝", e);
}
} else {
// 并发数已满,任务放回队列,等待下次调度
taskQueue.offer(task);
break;
}
}
if (dispatched > 0 || taskQueue.size() > 0) {
log.debug("QPS调度完成: dispatched={}, remainQueue={}, availableConcurrent={}",
dispatched, taskQueue.size(), concurrentLimiter.availablePermits());
}
}
/**
* 提交任务到调度队列
*
* @param task 待执行的任务
* @return 是否成功提交
*/
public boolean submit(Runnable task) {
return taskQueue.offer(task);
}
/**
* 获取队列中等待调度的任务数量
*
* @return 队列大小
*/
public int getQueueSize() {
return taskQueue.size();
}
/**
* 关闭调度器
*/
public void shutdown() {
scheduler.shutdown();
log.info("QPS调度器已关闭, qps={}", qps);
}
}

View File

@@ -1,17 +1,21 @@
package com.ycwl.basic.integration.kafka.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ycwl.basic.facebody.adapter.AliFaceBodyAdapter;
import com.ycwl.basic.facebody.adapter.BceFaceBodyAdapter;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.facebody.entity.AddFaceResp;
import com.ycwl.basic.facebody.entity.AliFaceBodyConfig;
import com.ycwl.basic.facebody.entity.BceFaceBodyConfig;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
import com.ycwl.basic.integration.kafka.dto.FaceProcessingMessage;
import com.ycwl.basic.integration.kafka.scheduler.AccountFaceSchedulerManager;
import com.ycwl.basic.integration.kafka.scheduler.AccountSchedulerContext;
import com.ycwl.basic.mapper.FaceSampleMapper;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.service.pc.ScenicService;
import com.ycwl.basic.service.task.TaskFaceService;
import com.ycwl.basic.task.DynamicTaskGenerator;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
// 不再需要SnowFlakeUtil,使用外部传入的ID
import com.ycwl.basic.utils.JacksonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -20,13 +24,16 @@ import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Service;
import java.time.ZoneId;
import java.util.Date;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 人脸处理Kafka消费服务
* 消费外部系统发送到zt-face topic的消息
* <p>
* 核心改进:
* 1. 按账号(accessKeyId/appId)隔离线程池和QPS调度器
* 2. 确保数据库优先写入,状态流转清晰
* 3. 严格QPS控制,线程资源高效利用
*/
@Slf4j
@Service
@@ -40,51 +47,144 @@ public class FaceProcessingKafkaService {
private final TaskFaceService taskFaceService;
private final ScenicService scenicService;
private final DeviceRepository deviceRepository;
private final ThreadPoolExecutor faceRecognitionExecutor;
private final AccountFaceSchedulerManager schedulerManager;
/**
* 消费外部系统发送的人脸处理消息
* 先保存人脸样本数据,再进行异步人脸识别处理
* 核心流程:
* 1. 同步写入数据库 (最高优先级)
* 2. 获取账号调度器上下文
* 3. 提交到账号专属调度器队列
*/
@KafkaListener(topics = ZT_FACE_TOPIC, containerFactory = "manualCommitKafkaListenerContainerFactory")
public void processFaceMessage(String message, Acknowledgment ack) {
Long faceSampleId = null;
try {
FaceProcessingMessage faceMessage = JacksonUtil.parseObject(message, FaceProcessingMessage.class);
log.debug("接收到外部人脸处理消息, scenicId: {}, deviceId: {}, faceUrl: {}",
faceMessage.getScenicId(), faceMessage.getDeviceId(), faceMessage.getFaceUrl());
faceSampleId = faceMessage.getFaceSampleId();
// 使用外部传入的faceSampleId
Long externalFaceId = faceMessage.getFaceSampleId();
if (externalFaceId == null) {
log.error("外部消息中未包含faceSampleId");
// 即使消息格式错误,也消费消息避免重复处理
log.debug("接收人脸消息: scenicId={}, deviceId={}, faceSampleId={}",
faceMessage.getScenicId(), faceMessage.getDeviceId(), faceSampleId);
// ========== 第一步: 同步写入数据库 (最高优先级) ==========
boolean saved = saveFaceSample(faceMessage, faceSampleId);
if (!saved) {
log.error("❌ 数据库写入失败, 不提交识别任务, faceSampleId={}", faceSampleId);
// 数据库写入失败,消费消息避免重复
ack.acknowledge();
return;
}
// 先保存人脸样本数据
boolean saved = saveFaceSample(faceMessage, externalFaceId);
log.debug("✅ 数据库写入成功, faceSampleId={}, status=0", faceSampleId);
// 然后异步进行人脸识别处理(使用专用线程池)
if (saved) {
faceRecognitionExecutor.execute(() -> processFaceRecognitionAsync(faceMessage));
log.debug("人脸识别任务已提交至线程池, faceSampleId: {}, 活跃线程: {}, 队列大小: {}",
externalFaceId, faceRecognitionExecutor.getActiveCount(),
faceRecognitionExecutor.getQueue().size());
} else {
log.warn("人脸样本保存失败,但消息仍将被消费, faceSampleId: {}", externalFaceId);
// ========== 第二步: 获取账号调度器上下文 ==========
AccountSchedulerContext schedulerCtx = getSchedulerContextForScenic(faceMessage.getScenicId());
if (schedulerCtx == null) {
log.error("❌ 无法获取调度器上下文, faceSampleId={}", faceSampleId);
updateFaceSampleStatusSafely(faceSampleId, -1);
ack.acknowledge();
return;
}
// 无论处理是否成功,都消费消息
// ========== 第三步: 提交到账号专属调度器 ==========
boolean submitted = schedulerCtx.getScheduler().submit(() -> {
processFaceRecognitionAsync(faceMessage);
});
if (submitted) {
log.debug("✅ 任务已提交到调度器, account={}, cloudType={}, faceSampleId={}, schedulerQueue={}",
schedulerCtx.getAccountKey(),
schedulerCtx.getCloudType(),
faceSampleId,
schedulerCtx.getScheduler().getQueueSize());
} else {
log.error("❌ 调度器队列已满, account={}, faceSampleId={}",
schedulerCtx.getAccountKey(), faceSampleId);
updateFaceSampleStatusSafely(faceSampleId, -1);
}
// 无论成功失败,都消费消息
ack.acknowledge();
} catch (Exception e) {
log.error("处理外部人脸消息失败: {}", e.getMessage(), e);
// 即使发生异常也消费消息,避免消息堆积
log.error("❌ 处理人脸消息异常, faceSampleId={}", faceSampleId, e);
if (faceSampleId != null) {
updateFaceSampleStatusSafely(faceSampleId, -1);
}
ack.acknowledge();
}
}
/**
* 根据景区获取对应的账号调度器上下文
* 关键: 按 accessKeyId/appId 隔离,而非按云类型
*/
private AccountSchedulerContext getSchedulerContextForScenic(Long scenicId) {
try {
// 获取景区的 adapter
IFaceBodyAdapter adapter = scenicService.getScenicFaceBodyAdapter(scenicId);
if (adapter == null) {
log.error("景区 adapter 不存在, scenicId={}", scenicId);
return null;
}
// 提取账号信息和QPS配置
if (adapter instanceof AliFaceBodyAdapter aliAdapter) {
AliFaceBodyConfig config = aliAdapter.getConfig();
if (config == null || config.getAccessKeyId() == null) {
log.error("阿里云配置为空, scenicId={}", scenicId);
return null;
}
// 使用 accessKeyId 作为唯一标识
String accountKey = config.getAccessKeyId();
float qps = 2.0f; // 阿里云固定 2 QPS (AddFace操作)
return schedulerManager.getOrCreateScheduler(accountKey, "ALI", qps);
} else if (adapter instanceof BceFaceBodyAdapter baiduAdapter) {
BceFaceBodyConfig config = baiduAdapter.getConfig();
if (config == null || config.getAppId() == null) {
log.error("百度云配置为空, scenicId={}", scenicId);
return null;
}
// 使用 appId 作为唯一标识
String accountKey = config.getAppId();
float qps = config.getAddQps(); // 百度云可配置 QPS
return schedulerManager.getOrCreateScheduler(accountKey, "BAIDU", qps);
} else {
log.error("未知的 adapter 类型: {}", adapter.getClass().getName());
return null;
}
} catch (Exception e) {
log.error("获取调度器上下文失败, scenicId={}", scenicId, e);
return null;
}
}
/**
* 安全地更新人脸样本状态
* 捕获所有异常,确保状态更新失败不影响主流程
*/
private void updateFaceSampleStatusSafely(Long faceSampleId, Integer status) {
if (faceSampleId == null) {
return;
}
try {
faceSampleMapper.updateStatus(faceSampleId, status);
log.debug("状态更新成功: faceSampleId={}, status={}", faceSampleId, status);
} catch (Exception e) {
log.error("⚠️ 状态更新失败(非致命): faceSampleId={}, status={}", faceSampleId, status, e);
// 不抛出异常,避免影响消息消费
}
}
/**
* 保存人脸样本数据到数据库
* @param faceMessage 人脸处理消息
@@ -126,69 +226,59 @@ public class FaceProcessingKafkaService {
private void processFaceRecognitionAsync(FaceProcessingMessage message) {
Long faceSampleId = message.getFaceSampleId();
Long scenicId = message.getScenicId();
String faceUrl = message.getFaceUrl();
// 直接使用faceSampleId作为唯一标识
String faceUniqueId = faceSampleId.toString();
try {
// ========== 第一步: 更新状态为"处理中" ==========
updateFaceSampleStatusSafely(faceSampleId, 1);
log.debug("开始人脸识别, faceSampleId={}, status=1", faceSampleId);
// 获取人脸识别适配器
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(scenicId);
if (faceBodyAdapter == null) {
log.error("人脸识别适配器不存在, scenicId: {}", scenicId);
updateFaceSampleStatus(faceSampleId, -1);
// ========== 第二步: 获取 adapter ==========
IFaceBodyAdapter adapter = scenicService.getScenicFaceBodyAdapter(scenicId);
if (adapter == null) {
log.error("adapter 不存在, scenicId={}, faceSampleId={}", scenicId, faceSampleId);
updateFaceSampleStatusSafely(faceSampleId, -1);
return;
}
try {
// 更新状态为处理中
updateFaceSampleStatus(faceSampleId, 1);
// ========== 第三步: 确保人脸数据库存在 ==========
taskFaceService.assureFaceDb(adapter, scenicId.toString());
// 确保人脸数据库存在
taskFaceService.assureFaceDb(faceBodyAdapter, scenicId.toString());
// 添加人脸到识别服务(使用faceSampleId作为唯一标识)
AddFaceResp addFaceResp = faceBodyAdapter.addFace(
// ========== 第四步: 调用 addFace (QPS已由调度器控制) ==========
String faceUniqueId = faceSampleId.toString();
AddFaceResp addFaceResp = adapter.addFace(
scenicId.toString(),
faceSampleId.toString(),
faceUrl,
faceUniqueId // 即faceSampleId.toString()
message.getFaceUrl(),
faceUniqueId
);
// ========== 第五步: 更新识别结果 ==========
if (addFaceResp != null) {
// 更新人脸样本得分和状态
// 成功: 更新 score 和状态
faceSampleMapper.updateScore(faceSampleId, addFaceResp.getScore());
updateFaceSampleStatus(faceSampleId, 2);
log.debug("人脸识别处理成功, faceSampleId: {}", faceSampleId);
updateFaceSampleStatusSafely(faceSampleId, 2);
// 查询设备配置,判断是否启用预订功能
log.info("✅ 人脸识别成功, faceSampleId={}, score={}, status=2",
faceSampleId, addFaceResp.getScore());
// 可选: 触发预订任务
Long deviceId = message.getDeviceId();
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId);
if (deviceConfig != null &&
Integer.valueOf(1).equals(deviceConfig.getInteger("enable_pre_book"))) {
DynamicTaskGenerator.addTask(faceSampleId);
}
} else {
log.warn("人脸添加返回空结果, faceSampleId: {}", faceSampleId);
updateFaceSampleStatus(faceSampleId, -1);
// addFace 返回 null,识别失败
log.warn("⚠️ addFace 返回 null, faceSampleId={}", faceSampleId);
updateFaceSampleStatusSafely(faceSampleId, -1);
}
} catch (Exception e) {
log.error("人脸识别处理失败, faceSampleId: {}, error: {}",
faceSampleId, e.getMessage(), e);
// 标记人脸样本为处理失败状态
updateFaceSampleStatus(faceSampleId, -1);
}
}
/**
* 更新人脸样本状态
*/
private void updateFaceSampleStatus(Long faceSampleId, Integer status) {
try {
faceSampleMapper.updateStatus(faceSampleId, status);
} catch (Exception e) {
log.error("更新人脸样本状态失败, faceSampleId: {}", faceSampleId, e);
// ========== 异常处理: 更新状态为失败 ==========
log.error("❌ 人脸识别异常, faceSampleId={}", faceSampleId, e);
updateFaceSampleStatusSafely(faceSampleId, -1);
}
}
@@ -226,14 +316,27 @@ public class FaceProcessingKafkaService {
.source("retry-manual")
.build();
// 提交到线程池进行异步处理
faceRecognitionExecutor.execute(() -> processFaceRecognitionAsync(message));
// 获取账号调度器上下文
AccountSchedulerContext schedulerCtx = getSchedulerContextForScenic(faceSample.getScenicId());
if (schedulerCtx == null) {
log.error("无法获取调度器上下文, faceSampleId={}", faceSampleId);
return false;
}
log.info("人脸识别重试任务已提交, faceSampleId: {}, 活跃线程: {}, 队列大小: {}",
faceSampleId, faceRecognitionExecutor.getActiveCount(),
faceRecognitionExecutor.getQueue().size());
// 提交到调度器进行异步处理
boolean submitted = schedulerCtx.getScheduler().submit(() -> processFaceRecognitionAsync(message));
if (submitted) {
log.info("人脸识别重试任务已提交, faceSampleId={}, account={}, cloudType={}, schedulerQueue={}",
faceSampleId,
schedulerCtx.getAccountKey(),
schedulerCtx.getCloudType(),
schedulerCtx.getScheduler().getQueueSize());
return true;
} else {
log.error("调度器队列已满,重试任务被拒绝, faceSampleId={}", faceSampleId);
return false;
}
} catch (Exception e) {
log.error("提交人脸识别重试任务失败, faceSampleId: {}", faceSampleId, e);

View File

@@ -1,59 +0,0 @@
package com.ycwl.basic.integration.render.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.render.dto.config.BatchRenderWorkerConfigRequest;
import com.ycwl.basic.integration.render.dto.config.RenderWorkerConfigV2DTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 渲染工作器配置V2客户端
*/
@FeignClient(name = "zt-render-worker", contextId = "render-worker-config-v2", path = "/api/render/worker/config/v2")
public interface RenderWorkerConfigV2Client {
/**
* 获取工作器所有配置
*/
@GetMapping("/{workerId}")
CommonResponse<List<RenderWorkerConfigV2DTO>> getWorkerConfigs(@PathVariable("workerId") Long workerId);
/**
* 根据配置键获取特定配置
*/
@GetMapping("/{workerId}/key/{configKey}")
CommonResponse<RenderWorkerConfigV2DTO> getWorkerConfigByKey(@PathVariable("workerId") Long workerId,
@PathVariable("configKey") String configKey);
/**
* 创建配置
*/
@PostMapping("/{workerId}")
CommonResponse<RenderWorkerConfigV2DTO> createWorkerConfig(@PathVariable("workerId") Long workerId,
@RequestBody RenderWorkerConfigV2DTO config);
/**
* 更新配置
*/
@PutMapping("/{workerId}/{id}")
CommonResponse<Void> updateWorkerConfig(@PathVariable("workerId") Long workerId,
@PathVariable("id") Long id,
@RequestBody Map<String, Object> updates);
/**
* 删除配置
*/
@DeleteMapping("/{workerId}/{id}")
CommonResponse<Void> deleteWorkerConfig(@PathVariable("workerId") Long workerId,
@PathVariable("id") Long id);
/**
* 批量更新配置
*/
@PostMapping("/{workerId}/batch")
CommonResponse<Void> batchUpdateWorkerConfigs(@PathVariable("workerId") Long workerId,
@RequestBody BatchRenderWorkerConfigRequest request);
}

View File

@@ -2,45 +2,52 @@ package com.ycwl.basic.integration.render.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.render.dto.config.BatchRenderWorkerConfigRequest;
import com.ycwl.basic.integration.render.dto.config.RenderWorkerConfigV2DTO;
import com.ycwl.basic.integration.render.dto.worker.*;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 渲染工作器V2客户端
*/
@FeignClient(name = "zt-render-worker", contextId = "render-worker-v2", path = "/api/render/worker/v2")
@FeignClient(name = "zt-render-worker", contextId = "render-worker-v2", path = "/api/render/worker")
public interface RenderWorkerV2Client {
// ==================== Worker V2 Operations ====================
/**
* 获取工作器核心信息
*/
@GetMapping("/{id}")
@GetMapping("v2/{id}")
CommonResponse<RenderWorkerV2DTO> getWorker(@PathVariable("id") Long id);
/**
* 创建工作器
*/
@PostMapping
@PostMapping("v2")
CommonResponse<RenderWorkerV2DTO> createWorker(@RequestBody CreateRenderWorkerRequest request);
/**
* 更新工作器
*/
@PutMapping("/{id}")
@PutMapping("v2/{id}")
CommonResponse<Void> updateWorker(@PathVariable("id") Long id,
@RequestBody UpdateRenderWorkerRequest request);
/**
* 删除工作器
*/
@DeleteMapping("/{id}")
@DeleteMapping("v2/{id}")
CommonResponse<Void> deleteWorker(@PathVariable("id") Long id);
/**
* 分页查询工作器列表(核心信息)
*/
@GetMapping
@GetMapping("v2")
CommonResponse<PageResponse<RenderWorkerV2DTO>> listWorkers(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Integer isEnabled,
@@ -49,6 +56,50 @@ public interface RenderWorkerV2Client {
/**
* 根据key获取工作器核心信息
*/
@GetMapping("/key/{key}")
@GetMapping("v2/key/{key}")
CommonResponse<RenderWorkerV2DTO> getWorkerByKey(@PathVariable("key") String key);
// ==================== Worker Config V2 Operations ====================
/**
* 获取工作器所有配置
*/
@GetMapping("config/v2/{workerId}")
CommonResponse<List<RenderWorkerConfigV2DTO>> getWorkerConfigs(@PathVariable("workerId") Long workerId);
/**
* 根据配置键获取特定配置
*/
@GetMapping("config/v2/{workerId}/key/{configKey}")
CommonResponse<RenderWorkerConfigV2DTO> getWorkerConfigByKey(@PathVariable("workerId") Long workerId,
@PathVariable("configKey") String configKey);
/**
* 创建配置
*/
@PostMapping("config/v2/{workerId}")
CommonResponse<RenderWorkerConfigV2DTO> createWorkerConfig(@PathVariable("workerId") Long workerId,
@RequestBody RenderWorkerConfigV2DTO config);
/**
* 更新配置
*/
@PutMapping("config/v2/{workerId}/{id}")
CommonResponse<Void> updateWorkerConfig(@PathVariable("workerId") Long workerId,
@PathVariable("id") Long id,
@RequestBody Map<String, Object> updates);
/**
* 删除配置
*/
@DeleteMapping("config/v2/{workerId}/{id}")
CommonResponse<Void> deleteWorkerConfig(@PathVariable("workerId") Long workerId,
@PathVariable("id") Long id);
/**
* 批量更新配置
*/
@PostMapping("config/v2/{workerId}/batch")
CommonResponse<Void> batchUpdateWorkerConfigs(@PathVariable("workerId") Long workerId,
@RequestBody BatchRenderWorkerConfigRequest request);
}

View File

@@ -3,7 +3,7 @@ package com.ycwl.basic.integration.render.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.render.client.RenderWorkerConfigV2Client;
import com.ycwl.basic.integration.render.client.RenderWorkerV2Client;
import com.ycwl.basic.integration.render.dto.config.BatchRenderWorkerConfigRequest;
import com.ycwl.basic.integration.render.dto.config.RenderWorkerConfigV2DTO;
import lombok.RequiredArgsConstructor;
@@ -23,7 +23,7 @@ import java.util.Map;
@RequiredArgsConstructor
public class RenderWorkerConfigIntegrationService {
private final RenderWorkerConfigV2Client renderWorkerConfigV2Client;
private final RenderWorkerV2Client renderWorkerConfigV2Client;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-render-worker";

View File

@@ -1,28 +0,0 @@
package com.ycwl.basic.integration.scenic.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.scenic.dto.config.DefaultConfigDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@FeignClient(name = "zt-scenic", contextId = "scenic-default-config", path = "/api/scenic/default-config")
public interface DefaultConfigClient {
@GetMapping("/")
CommonResponse<List<DefaultConfigDTO>> listDefaultConfigs();
@GetMapping("/{configKey}")
CommonResponse<DefaultConfigDTO> getDefaultConfig(@PathVariable("configKey") String configKey);
@PostMapping("/")
CommonResponse<DefaultConfigDTO> createDefaultConfig(@RequestBody DefaultConfigDTO request);
@PutMapping("/{configKey}")
CommonResponse<DefaultConfigDTO> updateDefaultConfig(@PathVariable("configKey") String configKey,
@RequestBody DefaultConfigDTO request);
@DeleteMapping("/{configKey}")
CommonResponse<Void> deleteDefaultConfig(@PathVariable("configKey") String configKey);
}

View File

@@ -1,37 +0,0 @@
package com.ycwl.basic.integration.scenic.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.scenic.dto.config.*;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@FeignClient(name = "zt-scenic", contextId = "scenic-config-v2", path = "/api/scenic/config/v2")
public interface ScenicConfigV2Client {
@GetMapping("/{scenicId}")
CommonResponse<List<ScenicConfigV2DTO>> listConfigs(@PathVariable("scenicId") Long scenicId);
@GetMapping("/{scenicId}/key/{configKey}")
CommonResponse<ScenicConfigV2DTO> getConfigByKey(@PathVariable("scenicId") Long scenicId,
@PathVariable("configKey") String configKey);
@PostMapping("/{scenicId}")
CommonResponse<ScenicConfigV2DTO> createConfig(@PathVariable("scenicId") Long scenicId,
@RequestBody CreateConfigRequest request);
@PutMapping("/{scenicId}/{id}")
CommonResponse<ScenicConfigV2DTO> updateConfig(@PathVariable("scenicId") Long scenicId,
@PathVariable("id") String id,
@RequestBody UpdateConfigRequest request);
@DeleteMapping("/{scenicId}/{id}")
CommonResponse<Void> deleteConfig(@PathVariable("scenicId") Long scenicId,
@PathVariable("id") String id);
@PostMapping("/{scenicId}/batch")
CommonResponse<BatchUpdateResponse> batchUpdateConfigs(@PathVariable("scenicId") Long scenicId,
@RequestBody BatchConfigRequest request);
}

View File

@@ -1,39 +1,87 @@
package com.ycwl.basic.integration.scenic.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.scenic.dto.config.*;
import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterPageResponse;
import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterRequest;
import com.ycwl.basic.integration.scenic.dto.scenic.CreateScenicRequest;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.scenic.dto.scenic.UpdateScenicRequest;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@FeignClient(name = "zt-scenic", contextId = "scenic-v2", path = "/api/scenic/v2")
@FeignClient(name = "zt-scenic", contextId = "scenic-v2", path = "/api/scenic")
public interface ScenicV2Client {
@GetMapping("/{scenicId}")
// ==================== Scenic V2 Operations ====================
@GetMapping("v2/{scenicId}")
CommonResponse<ScenicV2DTO> getScenic(@PathVariable("scenicId") Long scenicId);
@PostMapping("/")
@PostMapping("v2/")
CommonResponse<ScenicV2DTO> createScenic(@RequestBody CreateScenicRequest request);
@PutMapping("/{scenicId}")
@PutMapping("v2/{scenicId}")
CommonResponse<ScenicV2DTO> updateScenic(@PathVariable("scenicId") Long scenicId,
@RequestBody UpdateScenicRequest request);
@DeleteMapping("/{scenicId}")
@DeleteMapping("v2/{scenicId}")
CommonResponse<Void> deleteScenic(@PathVariable("scenicId") Long scenicId);
@PostMapping("/filter")
@PostMapping("v2/filter")
CommonResponse<ScenicFilterPageResponse> filterScenics(@RequestBody ScenicFilterRequest request);
@GetMapping("/")
@GetMapping("v2/")
CommonResponse<PageResponse<ScenicV2DTO>> listScenics(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String name);
// ==================== Scenic Config V2 Operations ====================
@GetMapping("config/v2/{scenicId}")
CommonResponse<List<ScenicConfigV2DTO>> listConfigs(@PathVariable("scenicId") Long scenicId);
@GetMapping("config/v2/{scenicId}/key/{configKey}")
CommonResponse<ScenicConfigV2DTO> getConfigByKey(@PathVariable("scenicId") Long scenicId,
@PathVariable("configKey") String configKey);
@PostMapping("config/v2/{scenicId}")
CommonResponse<ScenicConfigV2DTO> createConfig(@PathVariable("scenicId") Long scenicId,
@RequestBody CreateConfigRequest request);
@PutMapping("config/v2/{scenicId}/{id}")
CommonResponse<ScenicConfigV2DTO> updateConfig(@PathVariable("scenicId") Long scenicId,
@PathVariable("id") String id,
@RequestBody UpdateConfigRequest request);
@DeleteMapping("config/v2/{scenicId}/{id}")
CommonResponse<Void> deleteConfig(@PathVariable("scenicId") Long scenicId,
@PathVariable("id") String id);
@PostMapping("config/v2/{scenicId}/batch")
CommonResponse<BatchUpdateResponse> batchUpdateConfigs(@PathVariable("scenicId") Long scenicId,
@RequestBody BatchConfigRequest request);
// ==================== Default Config Operations ====================
@GetMapping("default-config/")
CommonResponse<List<DefaultConfigDTO>> listDefaultConfigs();
@GetMapping("default-config/{configKey}")
CommonResponse<DefaultConfigDTO> getDefaultConfig(@PathVariable("configKey") String configKey);
@PostMapping("default-config/")
CommonResponse<DefaultConfigDTO> createDefaultConfig(@RequestBody DefaultConfigDTO request);
@PutMapping("default-config/{configKey}")
CommonResponse<DefaultConfigDTO> updateDefaultConfig(@PathVariable("configKey") String configKey,
@RequestBody DefaultConfigDTO request);
@DeleteMapping("default-config/{configKey}")
CommonResponse<Void> deleteDefaultConfig(@PathVariable("configKey") String configKey);
}

View File

@@ -3,7 +3,7 @@ package com.ycwl.basic.integration.scenic.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.scenic.client.ScenicConfigV2Client;
import com.ycwl.basic.integration.scenic.client.ScenicV2Client;
import com.ycwl.basic.integration.scenic.dto.config.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -17,7 +17,7 @@ import java.util.Map;
@RequiredArgsConstructor
public class ScenicConfigIntegrationService {
private final ScenicConfigV2Client scenicConfigV2Client;
private final ScenicV2Client scenicConfigV2Client;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-scenic";

View File

@@ -2,7 +2,7 @@ package com.ycwl.basic.integration.scenic.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.scenic.client.DefaultConfigClient;
import com.ycwl.basic.integration.scenic.client.ScenicV2Client;
import com.ycwl.basic.integration.scenic.dto.config.DefaultConfigDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -15,7 +15,7 @@ import java.util.List;
@RequiredArgsConstructor
public class ScenicDefaultConfigIntegrationService {
private final DefaultConfigClient defaultConfigClient;
private final ScenicV2Client defaultConfigClient;
public List<DefaultConfigDTO> listDefaultConfigs() {
log.debug("获取默认配置列表");

View File

@@ -3,7 +3,6 @@ package com.ycwl.basic.integration.scenic.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.scenic.client.ScenicConfigV2Client;
import com.ycwl.basic.integration.scenic.client.ScenicV2Client;
import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterPageResponse;
import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterRequest;
@@ -23,7 +22,6 @@ import java.util.Map;
public class ScenicIntegrationService {
private final ScenicV2Client scenicV2Client;
private final ScenicConfigV2Client scenicConfigV2Client;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-scenic";

View File

@@ -26,6 +26,8 @@ public interface PrinterMapper {
PrintTaskResp findTaskByPrinterId(Integer printerId);
List<PrintTaskResp> listPendingTasksByPrinterId(Integer printerId);
int updateTaskStatus(@Param("id") Integer id, @Param("status") Integer status);
int compareAndSetTaskStatus(@Param("id") Integer id,

View File

@@ -0,0 +1,11 @@
package com.ycwl.basic.model;
import lombok.Data;
/**
* 裁剪信息
*/
@Data
public class Crop {
private Integer rotation;
}

View File

@@ -0,0 +1,42 @@
package com.ycwl.basic.model;
import com.ycwl.basic.model.pc.printer.resp.MemberPrintResp;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.Data;
/**
* 打印订单项(用于管线处理)
*/
@Data
public class PrinterOrderItem {
private Long id;
private Long sourceId;
private String cropUrl;
private Crop crop;
/**
* 从MemberPrintResp转换
*/
public static PrinterOrderItem fromMemberPrintResp(MemberPrintResp resp) {
PrinterOrderItem item = new PrinterOrderItem();
item.setId(resp.getId() != null ? resp.getId().longValue() : null);
item.setSourceId(resp.getSourceId());
item.setCropUrl(resp.getCropUrl());
if (resp.getCrop() != null) {
try {
Crop crop = new Crop();
Integer rotation = JacksonUtil.getInt(resp.getCrop(), "rotation");
if (rotation != null) {
crop.setRotation(rotation);
}
item.setCrop(crop);
} catch (Exception e) {
// 解析失败,crop为null
}
}
return item;
}
}

View File

@@ -0,0 +1,28 @@
package com.ycwl.basic.model.pc.price.resp;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 简化的商品响应VO - 仅包含商品ID和名称
*/
@NoArgsConstructor
@AllArgsConstructor
@Data
public class SimpleGoodsRespVO {
/**
* 商品ID(模板ID或固定值)
*/
private Long goodsId;
/**
* 商品名称
*/
private String goodsName;
/**
* 商品类型
*/
private String productType;
}

View File

@@ -11,4 +11,8 @@ public class ReprintRequest {
* 打印机名称
*/
private String printerName;
/**
* 是否增强打印
*/
private Boolean needEnhance;
}

View File

@@ -0,0 +1,68 @@
package com.ycwl.basic.order.factory;
import com.ycwl.basic.order.strategy.IDuplicatePurchaseChecker;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 重复购买检查策略工厂
*
* 设计原则:
* 1. 自动注册:Spring 自动注入所有策略实现并注册
* 2. 类型安全:根据枚举类型查找策略
* 3. 失败快速:找不到策略时抛出异常
*/
@Slf4j
@Service
public class DuplicatePurchaseCheckerFactory {
private final Map<DuplicateCheckStrategy, IDuplicatePurchaseChecker> checkerMap = new HashMap<>();
/**
* 构造函数:自动注册所有策略实现
*/
@Autowired
public DuplicatePurchaseCheckerFactory(List<IDuplicatePurchaseChecker> checkers) {
for (IDuplicatePurchaseChecker checker : checkers) {
DuplicateCheckStrategy strategy = checker.getStrategyType();
checkerMap.put(strategy, checker);
log.info("注册重复购买检查策略: {} -> {}", strategy, checker.getClass().getSimpleName());
}
log.info("重复购买检查策略工厂初始化完成,共注册 {} 个策略", checkerMap.size());
}
/**
* 根据策略类型获取检查器
*
* @param strategy 策略类型
* @return 对应的检查器实现
* @throws IllegalArgumentException 如果找不到对应的策略实现
*/
public IDuplicatePurchaseChecker getChecker(DuplicateCheckStrategy strategy) {
IDuplicatePurchaseChecker checker = checkerMap.get(strategy);
if (checker == null) {
throw new IllegalArgumentException("未找到重复购买检查策略: " + strategy);
}
return checker;
}
/**
* 检查是否支持指定策略
*/
public boolean supportsStrategy(DuplicateCheckStrategy strategy) {
return checkerMap.containsKey(strategy);
}
/**
* 获取所有已注册的策略类型
*/
public Map<DuplicateCheckStrategy, IDuplicatePurchaseChecker> getAllCheckers() {
return new HashMap<>(checkerMap);
}
}

View File

@@ -21,6 +21,13 @@ import com.ycwl.basic.order.mapper.OrderV2Mapper;
import com.ycwl.basic.order.mapper.OrderRefundMapper;
import com.ycwl.basic.order.service.IOrderService;
import com.ycwl.basic.order.event.*;
import com.ycwl.basic.order.strategy.DuplicateCheckContext;
import com.ycwl.basic.order.strategy.IDuplicatePurchaseChecker;
import com.ycwl.basic.order.factory.DuplicatePurchaseCheckerFactory;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import com.ycwl.basic.product.capability.PricingMode;
import com.ycwl.basic.product.capability.ProductTypeCapability;
import com.ycwl.basic.product.service.IProductTypeCapabilityService;
import com.ycwl.basic.pricing.dto.DiscountDetail;
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
import com.ycwl.basic.pricing.dto.ProductItem;
@@ -69,6 +76,8 @@ public class OrderServiceImpl implements IOrderService {
private final ICouponService couponService;
private final IVoucherService voucherService;
private final IProductConfigService productConfigService;
private final IProductTypeCapabilityService productTypeCapabilityService;
private final DuplicatePurchaseCheckerFactory duplicatePurchaseCheckerFactory;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -767,16 +776,10 @@ public class OrderServiceImpl implements IOrderService {
/**
* 获取商品类型中文名称
* 重构: 从配置驱动替代硬编码
*/
private String getProductTypeName(String productType) {
return switch (productType) {
case "VLOG_VIDEO" -> "Vlog视频";
case "RECORDING_SET" -> "录像集";
case "PHOTO_SET" -> "照相集";
case "PHOTO_PRINT" -> "照片打印";
case "MACHINE_PRINT" -> "一体机打印";
default -> "景区商品";
};
return productTypeCapabilityService.getDisplayName(productType);
}
/**
@@ -850,14 +853,17 @@ public class OrderServiceImpl implements IOrderService {
: getProductTypeName(product.getProductType().name());
// 3. 处理按数量计价的商品类型
if (product.getProductType() == com.ycwl.basic.pricing.enums.ProductType.PHOTO_PRINT ||
product.getProductType() == com.ycwl.basic.pricing.enums.ProductType.MACHINE_PRINT) {
if (product.getQuantity() != null && product.getQuantity() > 0) {
unitPrice = unitPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
// 重构: 使用商品类型能力配置替代硬编码判断
ProductTypeCapability capability = productTypeCapabilityService.getCapability(productTypeCode);
if (capability.getPricingModeEnum() == PricingMode.QUANTITY_BASED) {
Integer quantity = product.getQuantity() != null && product.getQuantity() > 0
? product.getQuantity() : 1;
unitPrice = unitPrice.multiply(BigDecimal.valueOf(quantity));
if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
}
originalPrice = originalPrice.multiply(BigDecimal.valueOf(quantity));
}
log.debug("按数量计价: productType={}, quantity={}, unitPrice={}",
productTypeCode, quantity, unitPrice);
}
}
@@ -894,6 +900,7 @@ public class OrderServiceImpl implements IOrderService {
/**
* 检查重复购买
* 防止用户重复购买相同内容
* 重构: 使用策略模式替代硬编码的 switch-case
*
* @param userId 用户ID
* @param faceId 人脸ID
@@ -903,117 +910,44 @@ public class OrderServiceImpl implements IOrderService {
*/
private void checkDuplicatePurchase(Long userId, Long faceId, Long scenicId, List<ProductItem> products) {
for (ProductItem product : products) {
switch (product.getProductType()) {
case VLOG_VIDEO:
checkVideoAlreadyPurchased(userId, faceId, scenicId, product.getProductId());
break;
case RECORDING_SET:
case PHOTO_SET:
checkSetAlreadyPurchased(userId, faceId, scenicId, product.getProductType());
break;
case PHOTO_PRINT:
case PHOTO_PRINT_MU:
case PHOTO_PRINT_FX:
case MACHINE_PRINT:
// 打印类商品允许重复购买,跳过检查
log.debug("跳过打印类商品重复购买检查: productType={}, productId={}",
product.getProductType(), product.getProductId());
break;
default:
log.warn("未知的商品类型,跳过重复购买检查: productType={}", product.getProductType());
break;
String productType = product.getProductType().getCode();
// 获取商品类型能力配置
ProductTypeCapability capability = productTypeCapabilityService.getCapability(productType);
// 如果允许重复购买,直接跳过
if (Boolean.TRUE.equals(capability.getAllowDuplicatePurchase())) {
log.debug("商品类型允许重复购买,跳过检查: productType={}, productId={}",
productType, product.getProductId());
continue;
}
// 获取检查策略并执行
DuplicateCheckStrategy strategy = capability.getDuplicateCheckStrategyEnum();
if (strategy != null && strategy != DuplicateCheckStrategy.NO_CHECK) {
try {
IDuplicatePurchaseChecker checker = duplicatePurchaseCheckerFactory.getChecker(strategy);
// 构建检查上下文
DuplicateCheckContext context = new DuplicateCheckContext();
context.setUserId(String.valueOf(userId));
context.setScenicId(String.valueOf(scenicId));
context.setProductType(productType);
context.setProductId(product.getProductId());
context.setProducts(products);
context.addParam("faceId", faceId);
// 执行检查
checker.check(context);
} catch (DuplicatePurchaseException e) {
// 重新抛出重复购买异常
throw e;
} catch (Exception e) {
log.error("重复购买检查失败,策略: {}, productType: {}", strategy, productType, e);
// 检查失败时为了安全起见,默认拒绝
throw new BaseException("重复购买检查失败,请稍后重试");
}
}
}
/**
* 检查视频是否已经购买
*
* @param userId 用户ID
* @param faceId 人脸ID
* @param scenicId 景区ID
* @param videoId 视频ID
* @throws DuplicatePurchaseException 如果已购买
*/
private void checkVideoAlreadyPurchased(Long userId, Long faceId, Long scenicId, String videoId) {
// 构建查询条件:查找已支付的有效订单中包含该视频的订单
QueryWrapper<OrderV2> orderQuery = new QueryWrapper<>();
orderQuery.eq("member_id", userId)
.eq("face_id", faceId)
.eq("scenic_id", scenicId)
.eq("payment_status", PaymentStatus.PAID.getCode())
.in("order_status", OrderStatus.PAID.getCode(), OrderStatus.PROCESSING.getCode(), OrderStatus.COMPLETED.getCode())
.eq("deleted", 0);
List<OrderV2> existingOrders = orderV2Mapper.selectList(orderQuery);
for (OrderV2 order : existingOrders) {
// 检查订单明细中是否包含该视频
QueryWrapper<OrderItemV2> itemQuery = new QueryWrapper<>();
itemQuery.eq("order_id", order.getId())
.eq("product_type", com.ycwl.basic.pricing.enums.ProductType.VLOG_VIDEO.name())
.eq("product_id", videoId);
long count = orderItemMapper.selectCount(itemQuery);
if (count > 0) {
log.warn("检测到重复购买视频: userId={}, faceId={}, scenicId={}, videoId={}, existingOrderId={}",
userId, faceId, scenicId, videoId, order.getId());
throw new DuplicatePurchaseException(
"您已购买过此视频",
order.getId(),
order.getOrderNo(),
com.ycwl.basic.pricing.enums.ProductType.VLOG_VIDEO,
videoId
);
}
}
log.debug("视频重复购买检查通过: userId={}, faceId={}, scenicId={}, videoId={}",
userId, faceId, scenicId, videoId);
}
/**
* 检查套餐(录像集/照相集)是否已经购买
*
* @param userId 用户ID
* @param faceId 人脸ID
* @param scenicId 景区ID
* @param productType 商品类型
* @throws DuplicatePurchaseException 如果已购买
*/
private void checkSetAlreadyPurchased(Long userId, Long faceId, Long scenicId,
com.ycwl.basic.pricing.enums.ProductType productType) {
// 构建查询条件:查找已支付的有效订单中包含该类型套餐的订单
QueryWrapper<OrderV2> orderQuery = new QueryWrapper<>();
orderQuery.eq("member_id", userId)
.eq("face_id", faceId)
.eq("scenic_id", scenicId)
.eq("payment_status", PaymentStatus.PAID.getCode())
.in("order_status", OrderStatus.PAID.getCode(), OrderStatus.PROCESSING.getCode(), OrderStatus.COMPLETED.getCode())
.eq("deleted", 0);
List<OrderV2> existingOrders = orderV2Mapper.selectList(orderQuery);
for (OrderV2 order : existingOrders) {
// 检查订单明细中是否包含该类型的套餐
QueryWrapper<OrderItemV2> itemQuery = new QueryWrapper<>();
itemQuery.eq("order_id", order.getId())
.eq("product_type", productType.name());
long count = orderItemMapper.selectCount(itemQuery);
if (count > 0) {
log.warn("检测到重复购买套餐: userId={}, faceId={}, scenicId={}, productType={}, existingOrderId={}",
userId, faceId, scenicId, productType, order.getId());
throw new DuplicatePurchaseException(
"您已购买过此类型的套餐",
order.getId(),
order.getOrderNo(),
productType
);
}
}
log.debug("套餐重复购买检查通过: userId={}, faceId={}, scenicId={}, productType={}",
userId, faceId, scenicId, productType);
}
}

View File

@@ -0,0 +1,65 @@
package com.ycwl.basic.order.strategy;
import com.ycwl.basic.pricing.dto.ProductItem;
import lombok.Data;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 重复购买检查上下文
* 封装检查所需的所有参数
*/
@Data
public class DuplicateCheckContext {
/**
* 用户ID
*/
private String userId;
/**
* 景区ID
*/
private String scenicId;
/**
* 商品类型代码
*/
private String productType;
/**
* 商品ID(视频ID、套餐ID等)
*/
private String productId;
/**
* 当前购物车中的所有商品
*/
private List<ProductItem> products;
/**
* 额外参数(扩展用)
*/
private Map<String, Object> additionalParams;
public DuplicateCheckContext() {
this.additionalParams = new HashMap<>();
}
/**
* 添加额外参数
*/
public void addParam(String key, Object value) {
this.additionalParams.put(key, value);
}
/**
* 获取额外参数
*/
@SuppressWarnings("unchecked")
public <T> T getParam(String key) {
return (T) this.additionalParams.get(key);
}
}

View File

@@ -0,0 +1,43 @@
package com.ycwl.basic.order.strategy;
import com.ycwl.basic.order.exception.DuplicatePurchaseException;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
/**
* 重复购买检查策略接口
*
* 设计原则:
* 1. 单一职责: 每个策略只负责一种检查逻辑
* 2. 开放扩展: 通过实现接口添加新策略
* 3. 自动注册: Spring 自动扫描并注册所有实现类
*/
public interface IDuplicatePurchaseChecker {
/**
* 获取策略类型
* 用于策略工厂的注册和查找
*
* @return 对应的 DuplicateCheckStrategy 枚举值
*/
DuplicateCheckStrategy getStrategyType();
/**
* 执行重复购买检查
*
* @param context 检查上下文,包含用户ID、商品信息等
* @throws DuplicatePurchaseException 如果检测到重复购买
*/
void check(DuplicateCheckContext context) throws DuplicatePurchaseException;
/**
* 是否支持该商品类型
* 默认实现:所有策略都支持所有商品类型
* 可在具体实现中覆盖以限制适用范围
*
* @param productType 商品类型代码
* @return true-支持, false-不支持
*/
default boolean supports(String productType) {
return true;
}
}

View File

@@ -0,0 +1,31 @@
package com.ycwl.basic.order.strategy.impl;
import com.ycwl.basic.order.strategy.DuplicateCheckContext;
import com.ycwl.basic.order.strategy.IDuplicatePurchaseChecker;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 不检查重复购买策略
* 适用于:打印类商品等允许重复购买的商品类型
*
* 检查逻辑:
* 不执行任何检查,直接通过
*/
@Slf4j
@Component
public class NoCheckDuplicateChecker implements IDuplicatePurchaseChecker {
@Override
public DuplicateCheckStrategy getStrategyType() {
return DuplicateCheckStrategy.NO_CHECK;
}
@Override
public void check(DuplicateCheckContext context) {
// 不检查,直接通过
log.debug("跳过重复购买检查: productType={}, productId={}",
context.getProductType(), context.getProductId());
}
}

View File

@@ -0,0 +1,102 @@
package com.ycwl.basic.order.strategy.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.ycwl.basic.order.entity.OrderItemV2;
import com.ycwl.basic.order.entity.OrderV2;
import com.ycwl.basic.order.enums.OrderStatus;
import com.ycwl.basic.order.enums.PaymentStatus;
import com.ycwl.basic.order.exception.DuplicatePurchaseException;
import com.ycwl.basic.order.mapper.OrderItemMapper;
import com.ycwl.basic.order.mapper.OrderV2Mapper;
import com.ycwl.basic.order.strategy.DuplicateCheckContext;
import com.ycwl.basic.order.strategy.IDuplicatePurchaseChecker;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 父资源/套餐重复购买检查策略
* 适用于:套餐类商品(RECORDING_SET录像集、PHOTO_SET照片集等)
*
* 检查逻辑:
* 1. 查找用户在该景区已支付的有效订单
* 2. 检查订单明细中是否包含相同product_type的商品(不关心具体productId)
* 3. 如果存在,抛出重复购买异常
*
* SQL查询: WHERE order_id = ? AND product_type = ?
*/
@Slf4j
@Component
public class ParentResourceDuplicateChecker implements IDuplicatePurchaseChecker {
@Autowired
private OrderV2Mapper orderV2Mapper;
@Autowired
private OrderItemMapper orderItemMapper;
@Override
public DuplicateCheckStrategy getStrategyType() {
return DuplicateCheckStrategy.PARENT_RESOURCE;
}
@Override
public void check(DuplicateCheckContext context) throws DuplicatePurchaseException {
String userId = context.getUserId();
String scenicId = context.getScenicId();
String productType = context.getProductType(); // 如"RECORDING_SET"或"PHOTO_SET"
// 获取人脸ID(从扩展参数中)
Long faceId = context.getParam("faceId");
log.debug("执行父资源重复购买检查: userId={}, faceId={}, scenicId={}, productType={}",
userId, faceId, scenicId, productType);
// 构建查询条件:查找已支付的有效订单
QueryWrapper<OrderV2> orderQuery = new QueryWrapper<>();
orderQuery.eq("member_id", userId)
.eq("scenic_id", scenicId)
.eq("payment_status", PaymentStatus.PAID.getCode())
.in("order_status",
OrderStatus.PAID.getCode(),
OrderStatus.PROCESSING.getCode(),
OrderStatus.COMPLETED.getCode())
.eq("deleted", 0);
// 如果提供了人脸ID,也作为查询条件
if (faceId != null) {
orderQuery.eq("face_id", faceId);
}
List<OrderV2> existingOrders = orderV2Mapper.selectList(orderQuery);
for (OrderV2 order : existingOrders) {
// 检查订单明细中是否包含该类型的商品(仅按productType匹配)
QueryWrapper<OrderItemV2> itemQuery = new QueryWrapper<>();
itemQuery.eq("order_id", order.getId())
.eq("product_type", productType); // 仅按productType匹配
long count = orderItemMapper.selectCount(itemQuery);
if (count > 0) {
log.warn("检测到重复购买父资源: userId={}, faceId={}, scenicId={}, productType={}, existingOrderId={}",
userId, faceId, scenicId, productType, order.getId());
ProductType productTypeEnum = ProductType.fromCode(productType);
throw new DuplicatePurchaseException(
String.format("您已购买过%s", productTypeEnum.getDescription()),
order.getId(),
order.getOrderNo(),
productTypeEnum
);
}
}
log.debug("父资源重复购买检查通过: userId={}, faceId={}, scenicId={}, productType={}",
userId, faceId, scenicId, productType);
}
}

View File

@@ -0,0 +1,105 @@
package com.ycwl.basic.order.strategy.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.ycwl.basic.order.entity.OrderItemV2;
import com.ycwl.basic.order.entity.OrderV2;
import com.ycwl.basic.order.enums.OrderStatus;
import com.ycwl.basic.order.enums.PaymentStatus;
import com.ycwl.basic.order.exception.DuplicatePurchaseException;
import com.ycwl.basic.order.mapper.OrderItemMapper;
import com.ycwl.basic.order.mapper.OrderV2Mapper;
import com.ycwl.basic.order.strategy.DuplicateCheckContext;
import com.ycwl.basic.order.strategy.IDuplicatePurchaseChecker;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 唯一资源重复购买检查策略
* 适用于:独立资源类商品(PHOTO照片、VLOG_VIDEO视频、VIDEO视频片段等)
*
* 检查逻辑:
* 1. 查找用户在该景区已支付的有效订单
* 2. 检查订单明细中是否包含相同product_type和product_id的商品
* 3. 如果存在,抛出重复购买异常
*
* SQL查询: WHERE order_id = ? AND product_type = ? AND product_id = ?
*/
@Slf4j
@Component
public class UniqueResourceDuplicateChecker implements IDuplicatePurchaseChecker {
@Autowired
private OrderV2Mapper orderV2Mapper;
@Autowired
private OrderItemMapper orderItemMapper;
@Override
public DuplicateCheckStrategy getStrategyType() {
return DuplicateCheckStrategy.UNIQUE_RESOURCE;
}
@Override
public void check(DuplicateCheckContext context) throws DuplicatePurchaseException {
String userId = context.getUserId();
String scenicId = context.getScenicId();
String productType = context.getProductType();
String productId = context.getProductId(); // 唯一资源ID
// 获取人脸ID(从扩展参数中)
Long faceId = context.getParam("faceId");
log.debug("执行唯一资源重复购买检查: userId={}, faceId={}, scenicId={}, productType={}, productId={}",
userId, faceId, scenicId, productType, productId);
// 构建查询条件:查找已支付的有效订单
QueryWrapper<OrderV2> orderQuery = new QueryWrapper<>();
orderQuery.eq("member_id", userId)
.eq("scenic_id", scenicId)
.eq("payment_status", PaymentStatus.PAID.getCode())
.in("order_status",
OrderStatus.PAID.getCode(),
OrderStatus.PROCESSING.getCode(),
OrderStatus.COMPLETED.getCode())
.eq("deleted", 0);
// 如果提供了人脸ID,也作为查询条件
if (faceId != null) {
orderQuery.eq("face_id", faceId);
}
List<OrderV2> existingOrders = orderV2Mapper.selectList(orderQuery);
for (OrderV2 order : existingOrders) {
// 检查订单明细中是否包含该资源(按product_type和product_id双重匹配)
QueryWrapper<OrderItemV2> itemQuery = new QueryWrapper<>();
itemQuery.eq("order_id", order.getId())
.eq("product_type", productType)
.eq("product_id", productId); // 按productId精确匹配
long count = orderItemMapper.selectCount(itemQuery);
if (count > 0) {
log.warn("检测到重复购买唯一资源: userId={}, faceId={}, scenicId={}, productType={}, productId={}, existingOrderId={}",
userId, faceId, scenicId, productType, productId, order.getId());
ProductType productTypeEnum = ProductType.fromCode(productType);
throw new DuplicatePurchaseException(
String.format("您已购买过此%s", productTypeEnum.getDescription()),
order.getId(),
order.getOrderNo(),
productTypeEnum,
productId
);
}
}
log.debug("唯一资源重复购买检查通过: userId={}, faceId={}, scenicId={}, productType={}, productId={}",
userId, faceId, scenicId, productType, productId);
}
}

View File

@@ -0,0 +1,33 @@
package com.ycwl.basic.pricing.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 商品分类枚举
*/
@Getter
@AllArgsConstructor
public enum ProductCategory {
VLOG("VLOG", "Vlog类"),
PHOTO("PHOTO", "照片类"),
VIDEO("VIDEO", "视频类"),
PRINT("PRINT", "打印类"),
OTHER("OTHER", "其他");
private final String code;
private final String description;
/**
* 根据代码获取枚举
*/
public static ProductCategory fromCode(String code) {
for (ProductCategory category : values()) {
if (category.code.equals(code)) {
return category;
}
}
throw new IllegalArgumentException("Unknown product category code: " + code);
}
}

View File

@@ -10,18 +10,27 @@ import lombok.Getter;
@AllArgsConstructor
public enum ProductType {
VLOG_VIDEO("VLOG_VIDEO", "Vlog视频"),
RECORDING_SET("RECORDING_SET", "录像集"),
PHOTO_SET("PHOTO_SET", "照相集"),
PHOTO_LOG("PHOTO_LOG", "pLog图"),
PHOTO_VLOG("PHOTO_VLOG", "pLog视频"),
PHOTO_PRINT("PHOTO_PRINT", "照片打印"),
PHOTO_PRINT_MU("PHOTO_PRINT_MU", "手机照片打印"),
PHOTO_PRINT_FX("PHOTO_PRINT_FX", "特效照片打印"),
MACHINE_PRINT("MACHINE_PRINT", "一体机打印");
// VLOG类
VLOG_VIDEO("VLOG_VIDEO", "Vlog视频", ProductCategory.VLOG),
PHOTO_VLOG("PHOTO_VLOG", "pLog视频", ProductCategory.VLOG),
// 照片类
PHOTO("PHOTO", "照片", ProductCategory.PHOTO),
PHOTO_SET("PHOTO_SET", "照片集", ProductCategory.PHOTO),
PHOTO_LOG("PHOTO_LOG", "pLog图", ProductCategory.PHOTO),
// 视频类(素材视频)
RECORDING_SET("RECORDING_SET", "录像集", ProductCategory.VIDEO),
// 其他类(打印类等)
PHOTO_PRINT("PHOTO_PRINT", "照片打印", ProductCategory.PRINT),
PHOTO_PRINT_MU("PHOTO_PRINT_MU", "手机照片打印", ProductCategory.PRINT),
PHOTO_PRINT_FX("PHOTO_PRINT_FX", "特效照片打印", ProductCategory.PRINT),
MACHINE_PRINT("MACHINE_PRINT", "一体机打印", ProductCategory.PRINT);
private final String code;
private final String description;
private final ProductCategory category;
/**
* 根据代码获取枚举
@@ -34,4 +43,18 @@ public enum ProductType {
}
throw new IllegalArgumentException("Unknown product type code: " + code);
}
/**
* 获取分类代码
*/
public String getCategoryCode() {
return category.getCode();
}
/**
* 获取分类描述
*/
public String getCategoryDescription() {
return category.getDescription();
}
}

View File

@@ -34,6 +34,14 @@ public interface PriceProductConfigMapper extends BaseMapper<PriceProductConfig>
@Select("SELECT * FROM price_product_config WHERE product_type = #{productType} AND product_id = #{productId} AND is_active = 1")
PriceProductConfig selectByProductTypeAndId(String productType, String productId);
/**
* 根据商品类型、商品ID和景区ID查询配置(支持景区维度)
*/
@Select("SELECT * FROM price_product_config WHERE product_type = #{productType} AND product_id = #{productId} AND scenic_id = #{scenicId} AND is_active = 1")
PriceProductConfig selectByProductTypeIdAndScenic(@Param("productType") String productType,
@Param("productId") String productId,
@Param("scenicId") String scenicId);
/**
* 检查是否存在default配置(包含禁用的)
*/

View File

@@ -27,6 +27,18 @@ public interface PriceTierConfigMapper extends BaseMapper<PriceTierConfig> {
@Param("productId") String productId,
@Param("quantity") Integer quantity);
/**
* 根据商品类型、商品ID、数量和景区ID查询匹配的阶梯价格(支持景区维度)
*/
@Select("SELECT * FROM price_tier_config WHERE product_type = #{productType} " +
"AND product_id = #{productId} AND scenic_id = #{scenicId} " +
"AND #{quantity} >= min_quantity AND #{quantity} <= max_quantity " +
"AND is_active = 1 ORDER BY sort_order ASC LIMIT 1")
PriceTierConfig selectByProductTypeQuantityAndScenic(@Param("productType") String productType,
@Param("productId") String productId,
@Param("quantity") Integer quantity,
@Param("scenicId") String scenicId);
/**
* 根据商品类型查询所有阶梯配置
*/

View File

@@ -30,6 +30,22 @@ public interface IProductConfigService {
*/
PriceProductConfig getProductConfig(String productType, String productId);
/**
* 根据商品类型、商品ID和景区ID获取精确配置(支持景区维度的优惠策略控制)
*
* 查询优先级:
* 1. 景区+商品ID: (productType, productId, scenicId)
* 2. 景区+默认: (productType, "default", scenicId)
* 3. 全局+商品ID: (productType, productId, null)
* 4. 全局+默认: (productType, "default", null)
*
* @param productType 商品类型
* @param productId 具体商品ID
* @param scenicId 景区ID
* @return 商品配置(包含优惠策略控制字段)
*/
PriceProductConfig getProductConfig(String productType, String productId, Long scenicId);
/**
* 根据商品类型、商品ID和数量获取阶梯价格配置
*
@@ -40,6 +56,23 @@ public interface IProductConfigService {
*/
PriceTierConfig getTierConfig(String productType, String productId, Integer quantity);
/**
* 根据商品类型、商品ID、数量和景区ID获取阶梯价格配置(支持景区维度)
*
* 查询优先级:
* 1. 景区+商品ID: (productType, productId, quantity, scenicId)
* 2. 景区+默认: (productType, "default", quantity, scenicId)
* 3. 全局+商品ID: (productType, productId, quantity, null)
* 4. 全局+默认: (productType, "default", quantity, null)
*
* @param productType 商品类型
* @param productId 具体商品ID
* @param quantity 数量
* @param scenicId 景区ID
* @return 阶梯价格配置
*/
PriceTierConfig getTierConfig(String productType, String productId, Integer quantity, Long scenicId);
/**
* 获取所有启用的商品配置
*

View File

@@ -138,18 +138,18 @@ public class CouponDiscountProvider implements IDiscountProvider {
}
}
// 检查单个商品的优惠券使用开关
// 检查单个商品的优惠券使用开关(使用景区维度配置)
for (ProductItem product : context.getProducts()) {
String productId = product.getProductId() != null ? product.getProductId() : "default";
try {
PriceProductConfig productConfig = productConfigService.getProductConfig(
product.getProductType().getCode(), productId);
product.getProductType().getCode(), productId, context.getScenicId());
if (productConfig != null) {
if (!Boolean.TRUE.equals(productConfig.getCanUseCoupon())) {
log.debug("商品配置不允许使用优惠券: productType={}, productId={}",
product.getProductType().getCode(), productId);
log.debug("商品配置不允许使用优惠券: productType={}, productId={}, scenicId={}",
product.getProductType().getCode(), productId, context.getScenicId());
return false;
}
}
@@ -157,18 +157,18 @@ public class CouponDiscountProvider implements IDiscountProvider {
// 如果获取具体商品配置失败,尝试获取default配置
try {
PriceProductConfig defaultConfig = productConfigService.getProductConfig(
product.getProductType().getCode(), "default");
product.getProductType().getCode(), "default", context.getScenicId());
if (defaultConfig != null) {
if (!Boolean.TRUE.equals(defaultConfig.getCanUseCoupon())) {
log.debug("商品默认配置不允许使用优惠券: productType={}",
product.getProductType().getCode());
log.debug("商品默认配置不允许使用优惠券: productType={}, scenicId={}",
product.getProductType().getCode(), context.getScenicId());
return false;
}
}
} catch (Exception ex) {
log.warn("获取商品配置失败,默认允许使用优惠券: productType={}, productId={}",
product.getProductType().getCode(), productId);
log.warn("获取商品配置失败,默认允许使用优惠券: productType={}, productId={}, scenicId={}",
product.getProductType().getCode(), productId, context.getScenicId());
}
}
}

View File

@@ -54,8 +54,8 @@ public class OnePricePurchaseDiscountProvider implements IDiscountProvider {
return discounts;
}
// 检查商品是否支持一口价优惠
if (!areAllProductsSupportOnePrice(context.getProducts())) {
// 检查商品是否支持一口价优惠(使用景区维度配置)
if (!areAllProductsSupportOnePrice(context.getProducts(), context.getScenicId())) {
log.debug("存在不支持一口价优惠的商品,跳过一口价检测");
return discounts;
}
@@ -182,49 +182,48 @@ public class OnePricePurchaseDiscountProvider implements IDiscountProvider {
}
/**
* 检查购物车中的所有商品是否都支持一口价优惠
* 检查购物车中的所有商品是否都支持一口价优惠(使用景区维度配置)
*/
private boolean areAllProductsSupportOnePrice(List<ProductItem> products) {
private boolean areAllProductsSupportOnePrice(List<ProductItem> products, Long scenicId) {
if (products == null || products.isEmpty()) {
return true; // 空购物车时默认支持
}
for (ProductItem product : products) {
try {
// 查询商品配置
// 查询商品配置(使用景区维度)
PriceProductConfig productConfig = productConfigService.getProductConfig(
product.getProductType().getCode(), product.getProductId());
product.getProductType().getCode(), product.getProductId(), scenicId);
if (productConfig != null) {
// 检查商品是否支持一口价优惠
if (Boolean.FALSE.equals(productConfig.getCanUseOnePrice())) {
log.debug("商品 {}({}) 不支持一口价优惠",
product.getProductType().getCode(), product.getProductId());
log.debug("商品 {}({}) 在景区 {} 不支持一口价优惠",
product.getProductType().getCode(), product.getProductId(), scenicId);
return false;
}
} else {
// 如果找不到具体商品配置,尝试查询 default 配置
PriceProductConfig defaultConfig = productConfigService.getProductConfig(
product.getProductType().getCode(), "default");
product.getProductType().getCode(), "default", scenicId);
if (defaultConfig != null) {
if (Boolean.FALSE.equals(defaultConfig.getCanUseOnePrice())) {
log.debug("商品类型 {} 的默认配置不支持一口价优惠",
product.getProductType().getCode());
log.debug("商品类型 {} 在景区 {} 的默认配置不支持一口价优惠",
product.getProductType().getCode(), scenicId);
return false;
}
} else {
// 如果既没有具体配置也没有默认配置,默认支持一口价优惠
log.debug("商品 {}({}) 未找到价格配置,默认支持一口价优惠",
product.getProductType().getCode(), product.getProductId());
return false;
// 如果既没有具体配置也没有默认配置,默认支持(保持向后兼容)
log.debug("商品 {}({}) 在景区 {} 未找到价格配置,默认支持一口价优惠",
product.getProductType().getCode(), product.getProductId(), scenicId);
// 改为默认支持,避免配置缺失导致一口价功能不可用
}
}
} catch (Exception e) {
log.warn("检查商品 {}({}) 一口价优惠支持情况时发生异常,默认支持",
product.getProductType().getCode(), product.getProductId(), e);
// 异常情况下默认支持,避免出现意外情况
return false;
log.warn("检查商品 {}({}) 在景区 {} 一口价优惠支持情况时发生异常,默认支持",
product.getProductType().getCode(), product.getProductId(), scenicId, e);
// 异常情况下默认支持,确保业务流程不受影响
}
}

View File

@@ -6,6 +6,9 @@ import com.ycwl.basic.pricing.entity.PriceTierConfig;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.pricing.exception.PriceCalculationException;
import com.ycwl.basic.pricing.service.*;
import com.ycwl.basic.product.capability.PricingMode;
import com.ycwl.basic.product.capability.ProductTypeCapability;
import com.ycwl.basic.product.service.IProductTypeCapabilityService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -29,16 +32,18 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
private final IPriceBundleService bundleService;
private final IDiscountDetectionService discountDetectionService;
private final IVoucherService voucherService;
private final IProductTypeCapabilityService productTypeCapabilityService;
/**
* 判断是否为打印类商品
* 打印类商品的价格计算方式为:单价 × 数量
* 判断是否为按数量计价的商品
* 重构: 使用商品类型能力配置替代硬编码
*
* @param productType 商品类型代码
* @return true-按数量计价, false-固定价格
*/
private boolean isPrintProduct(ProductType productType) {
return productType == ProductType.PHOTO_PRINT
|| productType == ProductType.PHOTO_PRINT_MU
|| productType == ProductType.PHOTO_PRINT_FX
|| productType == ProductType.MACHINE_PRINT;
private boolean isQuantityBasedPricing(String productType) {
ProductTypeCapability capability = productTypeCapabilityService.getCapability(productType);
return capability.getPricingModeEnum() == PricingMode.QUANTITY_BASED;
}
@Override
@@ -61,8 +66,8 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
product.setQuantity(1);
}
});
// 计算商品价格和原价
PriceDetails priceDetails = calculateProductsPriceWithOriginal(request.getProducts());
// 计算商品价格和原价(传入景区ID以支持景区级优惠策略控制)
PriceDetails priceDetails = calculateProductsPriceWithOriginal(request.getProducts(), request.getScenicId());
BigDecimal totalAmount = priceDetails.getTotalAmount();
BigDecimal originalTotalAmount = priceDetails.getOriginalTotalAmount();
@@ -157,13 +162,13 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
return totalAmount.setScale(2, RoundingMode.HALF_UP);
}
private PriceDetails calculateProductsPriceWithOriginal(List<ProductItem> products) {
private PriceDetails calculateProductsPriceWithOriginal(List<ProductItem> products, Long scenicId) {
BigDecimal totalAmount = BigDecimal.ZERO;
BigDecimal originalTotalAmount = BigDecimal.ZERO;
for (ProductItem product : products) {
// 计算实际价格和原价
ProductPriceInfo priceInfo = calculateSingleProductPriceWithOriginal(product);
// 计算实际价格和原价(传入景区ID)
ProductPriceInfo priceInfo = calculateSingleProductPriceWithOriginal(product, scenicId);
product.setUnitPrice(priceInfo.getActualPrice());
product.setOriginalPrice(priceInfo.getOriginalPrice());
@@ -201,7 +206,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
try {
PriceProductConfig baseConfig = productConfigService.getProductConfig(productType.getCode(), productId);
if (baseConfig != null) {
if (isPrintProduct(productType)) {
if (isQuantityBasedPricing(productType.getCode())) {
return baseConfig.getBasePrice().multiply(BigDecimal.valueOf(product.getQuantity()));
} else {
return baseConfig.getBasePrice();
@@ -216,7 +221,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
try {
PriceProductConfig defaultConfig = productConfigService.getProductConfig(productType.getCode(), "default");
if (defaultConfig != null) {
if (isPrintProduct(productType)) {
if (isQuantityBasedPricing(productType.getCode())) {
return defaultConfig.getBasePrice().multiply(BigDecimal.valueOf(product.getQuantity()));
} else {
return defaultConfig.getBasePrice();
@@ -230,7 +235,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
List<PriceProductConfig> configs = productConfigService.getProductConfig(productType.getCode());
if (!configs.isEmpty()) {
PriceProductConfig baseConfig = configs.get(0); // 使用第一个配置作为默认
if (isPrintProduct(productType)) {
if (isQuantityBasedPricing(productType.getCode())) {
return baseConfig.getBasePrice().multiply(BigDecimal.valueOf(product.getQuantity()));
} else {
return baseConfig.getBasePrice();
@@ -240,31 +245,31 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
throw new PriceCalculationException("无法计算商品价格: " + productType.getDescription() + ", productId: " + productId);
}
private ProductPriceInfo calculateSingleProductPriceWithOriginal(ProductItem product) {
private ProductPriceInfo calculateSingleProductPriceWithOriginal(ProductItem product, Long scenicId) {
ProductType productType = product.getProductType();
String productId = product.getProductId() != null ? product.getProductId() : "default";
BigDecimal actualPrice;
BigDecimal originalPrice = null;
// 优先使用基于product_id的阶梯定价
// 优先使用基于product_id的阶梯定价(带景区ID)
PriceTierConfig tierConfig = productConfigService.getTierConfig(
productType.getCode(), productId, product.getQuantity());
productType.getCode(), productId, product.getQuantity(), scenicId);
if (tierConfig != null) {
actualPrice = tierConfig.getPrice();
originalPrice = tierConfig.getOriginalPrice();
log.debug("使用阶梯定价: productType={}, productId={}, quantity={}, price={}, originalPrice={}",
productType.getCode(), productId, product.getQuantity(), actualPrice, originalPrice);
log.debug("使用阶梯定价: productType={}, productId={}, quantity={}, scenicId={}, price={}, originalPrice={}",
productType.getCode(), productId, product.getQuantity(), scenicId, actualPrice, originalPrice);
} else {
// 使用基于product_id的基础配置
// 使用基于product_id的基础配置(带景区ID)
try {
PriceProductConfig baseConfig = productConfigService.getProductConfig(productType.getCode(), productId);
PriceProductConfig baseConfig = productConfigService.getProductConfig(productType.getCode(), productId, scenicId);
if (baseConfig != null) {
actualPrice = baseConfig.getBasePrice();
originalPrice = baseConfig.getOriginalPrice();
if (isPrintProduct(productType)) {
if (isQuantityBasedPricing(productType.getCode())) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
@@ -274,17 +279,17 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
throw new PriceCalculationException("无法找到具体商品配置");
}
} catch (Exception e) {
log.warn("未找到具体商品配置: productType={}, productId={}, 尝试使用通用配置",
productType, productId);
log.warn("未找到具体商品配置: productType={}, productId={}, scenicId={}, 尝试使用通用配置",
productType, productId, scenicId);
// 兜底:使用default配置
// 兜底:使用default配置(带景区ID)
try {
PriceProductConfig defaultConfig = productConfigService.getProductConfig(productType.getCode(), "default");
PriceProductConfig defaultConfig = productConfigService.getProductConfig(productType.getCode(), "default", scenicId);
if (defaultConfig != null) {
actualPrice = defaultConfig.getBasePrice();
originalPrice = defaultConfig.getOriginalPrice();
if (isPrintProduct(productType)) {
if (isQuantityBasedPricing(productType.getCode())) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
@@ -294,7 +299,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
throw new PriceCalculationException("无法找到default配置");
}
} catch (Exception defaultEx) {
log.warn("未找到default配置: productType={}", productType.getCode());
log.warn("未找到default配置: productType={}, scenicId={}", productType.getCode(), scenicId);
// 最后兜底:使用通用配置(向后兼容)
List<PriceProductConfig> configs = productConfigService.getProductConfig(productType.getCode());
@@ -303,7 +308,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
actualPrice = baseConfig.getBasePrice();
originalPrice = baseConfig.getOriginalPrice();
if (isPrintProduct(productType)) {
if (isQuantityBasedPricing(productType.getCode())) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));

View File

@@ -72,6 +72,107 @@ public class ProductConfigServiceImpl implements IProductConfigService {
return config;
}
@Override
// @Cacheable(value = "product-config", key = "#productType + '_' + #productId + '_' + #scenicId")
public PriceProductConfig getProductConfig(String productType, String productId, Long scenicId) {
if (scenicId == null) {
// 如果没有景区ID,使用原有逻辑
return getProductConfig(productType, productId);
}
// 查询优先级:
// 1. 景区+商品ID
PriceProductConfig config = productConfigMapper.selectByProductTypeIdAndScenic(
productType, productId, scenicId.toString());
if (config != null) {
log.debug("使用景区特定商品配置: productType={}, productId={}, scenicId={}",
productType, productId, scenicId);
return config;
}
// 2. 景区+默认
if (!"default".equals(productId)) {
config = productConfigMapper.selectByProductTypeIdAndScenic(
productType, "default", scenicId.toString());
if (config != null) {
log.debug("使用景区默认配置: productType={}, scenicId={}", productType, scenicId);
return config;
}
}
// 3. 全局+商品ID (兜底)
try {
config = productConfigMapper.selectByProductTypeAndId(productType, productId);
if (config != null) {
log.debug("使用全局商品配置: productType={}, productId={}", productType, productId);
return config;
}
} catch (Exception e) {
log.debug("全局商品配置未找到: productType={}, productId={}", productType, productId);
}
// 4. 全局+默认 (最后兜底)
config = productConfigMapper.selectByProductTypeAndId(productType, "default");
if (config != null) {
log.debug("使用全局默认配置: productType={}", productType);
return config;
}
throw new ProductConfigNotFoundException(
String.format("商品配置未找到: productType=%s, productId=%s, scenicId=%s",
productType, productId, scenicId));
}
@Override
// @Cacheable(value = "tier-config", key = "#productType + '_' + #productId + '_' + #quantity + '_' + #scenicId")
public PriceTierConfig getTierConfig(String productType, String productId, Integer quantity, Long scenicId) {
if (quantity == null || quantity <= 0) {
return null;
}
if (scenicId == null) {
// 如果没有景区ID,使用原有逻辑
return getTierConfig(productType, productId, quantity);
}
// 查询优先级:
// 1. 景区+商品ID
PriceTierConfig config = tierConfigMapper.selectByProductTypeQuantityAndScenic(
productType, productId, quantity, scenicId.toString());
if (config != null) {
log.debug("使用景区特定阶梯定价: productType={}, productId={}, quantity={}, scenicId={}",
productType, productId, quantity, scenicId);
return config;
}
// 2. 景区+默认
if (!"default".equals(productId)) {
config = tierConfigMapper.selectByProductTypeQuantityAndScenic(
productType, "default", quantity, scenicId.toString());
if (config != null) {
log.debug("使用景区默认阶梯定价: productType={}, quantity={}, scenicId={}",
productType, quantity, scenicId);
return config;
}
}
// 3. 全局+商品ID (兜底)
config = tierConfigMapper.selectByProductTypeAndQuantity(productType, productId, quantity);
if (config != null) {
log.debug("使用全局阶梯定价: productType={}, productId={}, quantity={}",
productType, productId, quantity);
return config;
}
// 4. 全局+默认 (最后兜底)
config = tierConfigMapper.selectByProductTypeAndQuantity(productType, "default", quantity);
if (config != null) {
log.debug("使用全局默认阶梯定价: productType={}, quantity={}", productType, quantity);
}
return config;
}
@Override
// @Cacheable(value = "active-product-configs")
public List<PriceProductConfig> getActiveProductConfigs() {

View File

@@ -224,18 +224,18 @@ public class VoucherDiscountProvider implements IDiscountProvider {
}
}
// 检查单个商品的券码使用开关
// 检查单个商品的券码使用开关(使用景区维度配置)
for (ProductItem product : context.getProducts()) {
String productId = product.getProductId() != null ? product.getProductId() : "default";
try {
PriceProductConfig productConfig = productConfigService.getProductConfig(
product.getProductType().getCode(), productId);
product.getProductType().getCode(), productId, context.getScenicId());
if (productConfig != null) {
if (!Boolean.TRUE.equals(productConfig.getCanUseVoucher())) {
log.info("商品配置不允许使用券码: productType={}, productId={}",
product.getProductType().getCode(), productId);
log.info("商品配置不允许使用券码: productType={}, productId={}, scenicId={}",
product.getProductType().getCode(), productId, context.getScenicId());
return false;
}
}
@@ -243,18 +243,18 @@ public class VoucherDiscountProvider implements IDiscountProvider {
// 如果获取具体商品配置失败,尝试获取default配置
try {
PriceProductConfig defaultConfig = productConfigService.getProductConfig(
product.getProductType().getCode(), "default");
product.getProductType().getCode(), "default", context.getScenicId());
if (defaultConfig != null) {
if (!Boolean.TRUE.equals(defaultConfig.getCanUseVoucher())) {
log.debug("商品默认配置不允许使用券码: productType={}",
product.getProductType().getCode());
log.debug("商品默认配置不允许使用券码: productType={}, scenicId={}",
product.getProductType().getCode(), context.getScenicId());
return false;
}
}
} catch (Exception ex) {
log.warn("获取商品配置失败,默认允许使用券码: productType={}, productId={}",
product.getProductType().getCode(), productId);
log.warn("获取商品配置失败,默认允许使用券码: productType={}, productId={}, scenicId={}",
product.getProductType().getCode(), productId, context.getScenicId());
}
}
}

View File

@@ -0,0 +1,74 @@
package com.ycwl.basic.product.capability;
/**
* 重复购买检查策略枚举
* 定义不同商品类型的重复购买检查规则
*/
public enum DuplicateCheckStrategy {
/**
* 不检查(如打印类商品)
* 允许重复购买
*/
NO_CHECK("NO_CHECK", "不检查"),
/**
* 检查唯一资源
* 检查用户是否已购买过相同的独立资源
* 查询逻辑:WHERE product_type = ? AND product_id = ?
*
* 适用商品:
* - VLOG_VIDEO:检查是否购买过同一个视频
* - PHOTO:检查是否购买过同一张照片
* - 未来的VIDEO片段:检查是否购买过同一段视频
*/
UNIQUE_RESOURCE("UNIQUE_RESOURCE", "唯一资源检查"),
/**
* 检查父资源/套餐
* 检查用户是否已购买过该类型的任何商品
* 查询逻辑:WHERE product_type = ?
*
* 适用商品:
* - RECORDING_SET:购买过任意录像集后不能再购买
* - PHOTO_SET:购买过任意照片集后不能再购买
*/
PARENT_RESOURCE("PARENT_RESOURCE", "父资源检查"),
/**
* 自定义策略(通过扩展实现)
* 预留扩展点,用于未来更复杂的检查逻辑
*/
CUSTOM("CUSTOM", "自定义策略");
private final String code;
private final String description;
DuplicateCheckStrategy(String code, String description) {
this.code = code;
this.description = description;
}
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据代码获取枚举值
*/
public static DuplicateCheckStrategy fromCode(String code) {
if (code == null) {
return null;
}
for (DuplicateCheckStrategy strategy : DuplicateCheckStrategy.values()) {
if (strategy.code.equals(code)) {
return strategy;
}
}
throw new IllegalArgumentException("未知的重复检查策略: " + code);
}
}

View File

@@ -0,0 +1,57 @@
package com.ycwl.basic.product.capability;
/**
* 定价模式枚举
* 定义商品的价格计算方式
*/
public enum PricingMode {
/**
* 固定价格(不受数量影响)
* 适用于:视频类、照片集类商品
*/
FIXED("FIXED", "固定价格"),
/**
* 基于数量(价格 = 单价 × 数量)
* 适用于:打印类商品
*/
QUANTITY_BASED("QUANTITY_BASED", "基于数量"),
/**
* 分层定价(根据数量区间)
* 适用于:支持阶梯定价的商品
*/
TIERED("TIERED", "分层定价");
private final String code;
private final String description;
PricingMode(String code, String description) {
this.code = code;
this.description = description;
}
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据代码获取枚举值
*/
public static PricingMode fromCode(String code) {
if (code == null) {
return null;
}
for (PricingMode mode : PricingMode.values()) {
if (mode.code.equals(code)) {
return mode;
}
}
throw new IllegalArgumentException("未知的定价模式: " + code);
}
}

View File

@@ -0,0 +1,156 @@
package com.ycwl.basic.product.capability;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 商品类型能力配置实体
* 统一管理商品类型的各项能力和行为特征
*
* 设计目标:
* 1. 消除代码中的硬编码,将商品类型相关的业务规则配置化
* 2. 提供统一的商品类型能力查询接口
* 3. 支持通过配置快速扩展新商品类型
*/
@Data
@TableName(value = "product_type_capability", autoResultMap = true)
public class ProductTypeCapability {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 商品类型代码(唯一)
* 如:VLOG_VIDEO, PHOTO_PRINT 等
*/
private String productType;
/**
* 显示名称
* 用于前端展示和支付页面
*/
private String displayName;
/**
* 商品分类
* 如:VIDEO(视频类), PRINT(打印类), PHOTO_SET(照片集类)
*/
private String category;
// ========== 定价相关 ==========
/**
* 定价模式
* FIXED: 固定价格
* QUANTITY_BASED: 基于数量(价格 = 单价 × 数量)
* TIERED: 分层定价
*/
private String pricingMode;
/**
* 是否支持阶梯定价
*/
private Boolean supportsTierPricing;
// ========== 购买限制 ==========
/**
* 是否允许重复购买
*/
private Boolean allowDuplicatePurchase;
/**
* 重复购买检查策略
* NO_CHECK: 不检查
* CHECK_BY_VIDEO_ID: 按视频ID检查
* CHECK_BY_SET_ID: 按套餐ID检查
* CUSTOM: 自定义策略
*/
private String duplicateCheckStrategy;
// ========== 优惠能力 ==========
/**
* 是否可使用优惠券
*/
private Boolean canUseCoupon;
/**
* 是否可使用券码
*/
private Boolean canUseVoucher;
/**
* 是否可使用一口价优惠
*/
private Boolean canUseOnePrice;
/**
* 是否可参与打包优惠
*/
private Boolean canUseBundle;
// ========== 扩展属性 ==========
/**
* 扩展属性(JSON 格式)
* 用于存储特定商品类型的额外配置
*/
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> metadata;
/**
* 是否启用
*/
private Boolean isActive;
/**
* 创建时间
*/
private LocalDateTime createdAt;
/**
* 更新时间
*/
private LocalDateTime updatedAt;
// ========== 便捷方法 ==========
/**
* 获取定价模式枚举
*/
public PricingMode getPricingModeEnum() {
return PricingMode.fromCode(this.pricingMode);
}
/**
* 获取重复检查策略枚举
*/
public DuplicateCheckStrategy getDuplicateCheckStrategyEnum() {
return DuplicateCheckStrategy.fromCode(this.duplicateCheckStrategy);
}
/**
* 设置定价模式枚举
*/
public void setPricingModeEnum(PricingMode mode) {
this.pricingMode = mode != null ? mode.getCode() : null;
}
/**
* 设置重复检查策略枚举
*/
public void setDuplicateCheckStrategyEnum(DuplicateCheckStrategy strategy) {
this.duplicateCheckStrategy = strategy != null ? strategy.getCode() : null;
}
}

View File

@@ -0,0 +1,229 @@
package com.ycwl.basic.product.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.pricing.enums.ProductCategory;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import com.ycwl.basic.product.capability.PricingMode;
import com.ycwl.basic.product.capability.ProductTypeCapability;
import com.ycwl.basic.product.dto.EnumOptionResponse;
import com.ycwl.basic.product.dto.ProductTypeCapabilityRequest;
import com.ycwl.basic.product.dto.ProductTypeCapabilityResponse;
import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService;
import com.ycwl.basic.product.service.IProductTypeCapabilityService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 商品类型能力配置管理控制器
* 提供管理端的配置管理功能
*/
@Slf4j
@RestController
@RequestMapping("/api/product/admin/capability")
@RequiredArgsConstructor
public class ProductTypeCapabilityController {
private final IProductTypeCapabilityManagementService managementService;
private final IProductTypeCapabilityService capabilityService;
/**
* 分页查询商品类型能力配置
*/
@GetMapping("/page")
public ApiResponse<Page<ProductTypeCapabilityResponse>> queryByPage(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String productType,
@RequestParam(required = false) String category,
@RequestParam(required = false) Boolean isActive) {
Page<ProductTypeCapability> page = managementService.queryByPage(
pageNum, pageSize, productType, category, isActive);
// 转换为响应DTO
Page<ProductTypeCapabilityResponse> responsePage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
List<ProductTypeCapabilityResponse> responseList = page.getRecords().stream()
.map(ProductTypeCapabilityResponse::fromEntity)
.collect(Collectors.toList());
responsePage.setRecords(responseList);
return ApiResponse.success(responsePage);
}
/**
* 查询所有商品类型能力配置
*/
@GetMapping("/list")
public ApiResponse<List<ProductTypeCapabilityResponse>> queryAll(
@RequestParam(defaultValue = "false") boolean includeInactive) {
List<ProductTypeCapability> capabilities = managementService.queryAll(includeInactive);
List<ProductTypeCapabilityResponse> responseList = capabilities.stream()
.map(ProductTypeCapabilityResponse::fromEntity)
.collect(Collectors.toList());
return ApiResponse.success(responseList);
}
/**
* 根据分类查询商品类型能力配置
*/
@GetMapping("/category/{category}")
public ApiResponse<List<ProductTypeCapabilityResponse>> queryByCategory(
@PathVariable String category) {
List<ProductTypeCapability> capabilities = managementService.queryByCategory(category);
List<ProductTypeCapabilityResponse> responseList = capabilities.stream()
.map(ProductTypeCapabilityResponse::fromEntity)
.collect(Collectors.toList());
return ApiResponse.success(responseList);
}
/**
* 根据ID查询配置详情
*/
@GetMapping("/{id}")
public ApiResponse<ProductTypeCapabilityResponse> getById(
@PathVariable Long id) {
ProductTypeCapability capability = managementService.getById(id);
return ApiResponse.success(ProductTypeCapabilityResponse.fromEntity(capability));
}
/**
* 根据商品类型代码查询配置详情
*/
@GetMapping("/product-type/{productType}")
public ApiResponse<ProductTypeCapabilityResponse> getByProductType(
@PathVariable String productType) {
ProductTypeCapability capability = managementService.getByProductType(productType);
return ApiResponse.success(ProductTypeCapabilityResponse.fromEntity(capability));
}
/**
* 创建商品类型能力配置
*/
@PostMapping
public ApiResponse<ProductTypeCapabilityResponse> create(
@RequestBody ProductTypeCapabilityRequest request) {
ProductTypeCapability capability = managementService.create(request);
return ApiResponse.success(ProductTypeCapabilityResponse.fromEntity(capability));
}
/**
* 更新商品类型能力配置
*/
@PutMapping("/{id}")
public ApiResponse<ProductTypeCapabilityResponse> update(
@PathVariable Long id,
@RequestBody ProductTypeCapabilityRequest request) {
ProductTypeCapability capability = managementService.update(id, request);
return ApiResponse.success(ProductTypeCapabilityResponse.fromEntity(capability));
}
/**
* 删除商品类型能力配置
*/
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(
@PathVariable Long id) {
managementService.delete(id);
return ApiResponse.success(null);
}
/**
* 启用/禁用商品类型能力配置
*/
@PutMapping("/{id}/status")
public ApiResponse<Void> updateStatus(
@PathVariable Long id,
@RequestParam Boolean isActive) {
managementService.updateStatus(id, isActive);
return ApiResponse.success(null);
}
/**
* 批量初始化商品类型能力配置
* 为所有已定义的商品类型创建默认配置
*/
@PostMapping("/init-defaults")
public ApiResponse<Integer> initializeDefaultCapabilities() {
int count = managementService.initializeDefaultCapabilities();
return ApiResponse.success(count);
}
/**
* 刷新所有缓存
*/
@PostMapping("/refresh-cache")
public ApiResponse<Void> refreshCache() {
capabilityService.refreshCache();
return ApiResponse.success(null);
}
/**
* 刷新指定商品类型的缓存
*/
@PostMapping("/refresh-cache/{productType}")
public ApiResponse<Void> refreshCacheByProductType(
@PathVariable String productType) {
capabilityService.refreshCache(productType);
return ApiResponse.success(null);
}
// ========== 枚举选项接口 ==========
/**
* 获取定价模式枚举选项
*/
@GetMapping("/enums/pricing-modes")
public ApiResponse<List<EnumOptionResponse>> getPricingModes() {
List<EnumOptionResponse> options = Arrays.stream(PricingMode.values())
.map(mode -> new EnumOptionResponse(mode.getCode(), mode.getDescription()))
.collect(Collectors.toList());
return ApiResponse.success(options);
}
/**
* 获取重复检查策略枚举选项
*/
@GetMapping("/enums/duplicate-check-strategies")
public ApiResponse<List<EnumOptionResponse>> getDuplicateCheckStrategies() {
List<EnumOptionResponse> options = Arrays.stream(DuplicateCheckStrategy.values())
.map(strategy -> new EnumOptionResponse(strategy.getCode(), strategy.getDescription()))
.collect(Collectors.toList());
return ApiResponse.success(options);
}
/**
* 获取商品分类枚举选项
*/
@GetMapping("/enums/categories")
public ApiResponse<List<EnumOptionResponse>> getCategories() {
List<EnumOptionResponse> options = Arrays.stream(ProductCategory.values())
.map(category -> new EnumOptionResponse(category.getCode(), category.getDescription()))
.collect(Collectors.toList());
return ApiResponse.success(options);
}
}

View File

@@ -0,0 +1,25 @@
package com.ycwl.basic.product.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 枚举选项响应DTO
* 用于前端下拉框等场景
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EnumOptionResponse {
/**
* 枚举代码
*/
private String code;
/**
* 枚举描述
*/
private String description;
}

View File

@@ -0,0 +1,91 @@
package com.ycwl.basic.product.dto;
import lombok.Data;
import java.util.Map;
/**
* 商品类型能力配置请求DTO
*/
@Data
public class ProductTypeCapabilityRequest {
/**
* 商品类型代码(唯一)
* 如:VLOG_VIDEO, PHOTO_PRINT 等
*/
private String productType;
/**
* 显示名称
*/
private String displayName;
/**
* 商品分类
* VLOG, PHOTO, VIDEO, PRINT, OTHER
*/
private String category;
// ========== 定价相关 ==========
/**
* 定价模式
* FIXED: 固定价格
* QUANTITY_BASED: 基于数量
* TIERED: 分层定价
*/
private String pricingMode;
/**
* 是否支持阶梯定价
*/
private Boolean supportsTierPricing;
// ========== 购买限制 ==========
/**
* 是否允许重复购买
*/
private Boolean allowDuplicatePurchase;
/**
* 重复购买检查策略
* NO_CHECK, CHECK_BY_VIDEO_ID, CHECK_BY_SET_ID, CUSTOM
*/
private String duplicateCheckStrategy;
// ========== 优惠能力 ==========
/**
* 是否可使用优惠券
*/
private Boolean canUseCoupon;
/**
* 是否可使用券码
*/
private Boolean canUseVoucher;
/**
* 是否可使用一口价优惠
*/
private Boolean canUseOnePrice;
/**
* 是否可参与打包优惠
*/
private Boolean canUseBundle;
// ========== 扩展属性 ==========
/**
* 扩展属性(JSON 格式)
*/
private Map<String, Object> metadata;
/**
* 是否启用
*/
private Boolean isActive;
}

View File

@@ -0,0 +1,85 @@
package com.ycwl.basic.product.dto;
import com.ycwl.basic.product.capability.ProductTypeCapability;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 商品类型能力配置响应DTO
*/
@Data
public class ProductTypeCapabilityResponse {
private Long id;
private String productType;
private String displayName;
private String category;
private String categoryDescription;
// 定价相关
private String pricingMode;
private String pricingModeDescription;
private Boolean supportsTierPricing;
// 购买限制
private Boolean allowDuplicatePurchase;
private String duplicateCheckStrategy;
private String duplicateCheckStrategyDescription;
// 优惠能力
private Boolean canUseCoupon;
private Boolean canUseVoucher;
private Boolean canUseOnePrice;
private Boolean canUseBundle;
// 扩展属性
private Map<String, Object> metadata;
private Boolean isActive;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 从实体转换为响应DTO
*/
public static ProductTypeCapabilityResponse fromEntity(ProductTypeCapability entity) {
if (entity == null) {
return null;
}
ProductTypeCapabilityResponse response = new ProductTypeCapabilityResponse();
response.setId(entity.getId());
response.setProductType(entity.getProductType());
response.setDisplayName(entity.getDisplayName());
response.setCategory(entity.getCategory());
// 定价相关
response.setPricingMode(entity.getPricingMode());
if (entity.getPricingModeEnum() != null) {
response.setPricingModeDescription(entity.getPricingModeEnum().getDescription());
}
response.setSupportsTierPricing(entity.getSupportsTierPricing());
// 购买限制
response.setAllowDuplicatePurchase(entity.getAllowDuplicatePurchase());
response.setDuplicateCheckStrategy(entity.getDuplicateCheckStrategy());
if (entity.getDuplicateCheckStrategyEnum() != null) {
response.setDuplicateCheckStrategyDescription(entity.getDuplicateCheckStrategyEnum().getDescription());
}
// 优惠能力
response.setCanUseCoupon(entity.getCanUseCoupon());
response.setCanUseVoucher(entity.getCanUseVoucher());
response.setCanUseOnePrice(entity.getCanUseOnePrice());
response.setCanUseBundle(entity.getCanUseBundle());
// 扩展属性
response.setMetadata(entity.getMetadata());
response.setIsActive(entity.getIsActive());
response.setCreatedAt(entity.getCreatedAt());
response.setUpdatedAt(entity.getUpdatedAt());
return response;
}
}

View File

@@ -0,0 +1,21 @@
package com.ycwl.basic.product.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.product.capability.ProductTypeCapability;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 商品类型能力配置 Mapper
*/
@Mapper
public interface ProductTypeCapabilityMapper extends BaseMapper<ProductTypeCapability> {
/**
* 根据商品类型代码查询能力配置
*
* @param productType 商品类型代码
* @return 能力配置,不存在时返回 null
*/
ProductTypeCapability selectByProductType(@Param("productType") String productType);
}

View File

@@ -0,0 +1,99 @@
package com.ycwl.basic.product.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ycwl.basic.product.capability.ProductTypeCapability;
import com.ycwl.basic.product.dto.ProductTypeCapabilityRequest;
import java.util.List;
/**
* 商品类型能力配置管理服务接口
* 用于管理端的配置管理功能
*/
public interface IProductTypeCapabilityManagementService {
/**
* 分页查询商品类型能力配置
*
* @param pageNum 页码
* @param pageSize 每页大小
* @param productType 商品类型代码(可选,支持模糊查询)
* @param category 商品分类(可选)
* @param isActive 是否启用(可选)
* @return 分页结果
*/
Page<ProductTypeCapability> queryByPage(int pageNum, int pageSize,
String productType, String category, Boolean isActive);
/**
* 查询所有商品类型能力配置
*
* @param includeInactive 是否包含禁用的配置
* @return 配置列表
*/
List<ProductTypeCapability> queryAll(boolean includeInactive);
/**
* 根据分类查询商品类型能力配置
*
* @param category 商品分类
* @return 配置列表
*/
List<ProductTypeCapability> queryByCategory(String category);
/**
* 根据ID查询配置详情
*
* @param id 配置ID
* @return 配置详情
*/
ProductTypeCapability getById(Long id);
/**
* 根据商品类型代码查询配置详情
*
* @param productType 商品类型代码
* @return 配置详情
*/
ProductTypeCapability getByProductType(String productType);
/**
* 创建商品类型能力配置
*
* @param request 请求参数
* @return 创建的配置
*/
ProductTypeCapability create(ProductTypeCapabilityRequest request);
/**
* 更新商品类型能力配置
*
* @param id 配置ID
* @param request 请求参数
* @return 更新后的配置
*/
ProductTypeCapability update(Long id, ProductTypeCapabilityRequest request);
/**
* 删除商品类型能力配置
*
* @param id 配置ID
*/
void delete(Long id);
/**
* 启用/禁用商品类型能力配置
*
* @param id 配置ID
* @param isActive 是否启用
*/
void updateStatus(Long id, Boolean isActive);
/**
* 批量初始化商品类型能力配置
* 为所有已定义的商品类型创建默认配置
*
* @return 初始化的配置数量
*/
int initializeDefaultCapabilities();
}

View File

@@ -0,0 +1,70 @@
package com.ycwl.basic.product.service;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import com.ycwl.basic.product.capability.PricingMode;
import com.ycwl.basic.product.capability.ProductTypeCapability;
/**
* 商品类型能力服务接口
* 提供商品类型能力配置的查询和管理功能
*
* 设计原则:
* 1. 缓存优先:所有查询都支持缓存,提高性能
* 2. 降级兜底:查询失败时返回安全的默认配置
* 3. 接口简洁:提供便捷方法,简化调用方代码
*/
public interface IProductTypeCapabilityService {
/**
* 获取商品类型能力配置(支持缓存)
*
* @param productType 商品类型代码(如 VLOG_VIDEO)
* @return 商品类型能力配置,查询失败时返回默认配置
*/
ProductTypeCapability getCapability(String productType);
/**
* 获取商品显示名称
*
* @param productType 商品类型代码
* @return 显示名称,查询失败时返回"景区商品"
*/
String getDisplayName(String productType);
/**
* 判断是否允许重复购买
*
* @param productType 商品类型代码
* @return true-允许重复购买, false-不允许
*/
boolean allowDuplicatePurchase(String productType);
/**
* 获取定价模式
*
* @param productType 商品类型代码
* @return 定价模式枚举
*/
PricingMode getPricingMode(String productType);
/**
* 获取重复检查策略
*
* @param productType 商品类型代码
* @return 重复检查策略枚举
*/
DuplicateCheckStrategy getDuplicateCheckStrategy(String productType);
/**
* 刷新缓存
* 用于配置更新后手动触发缓存刷新
*/
void refreshCache();
/**
* 刷新指定商品类型的缓存
*
* @param productType 商品类型代码
*/
void refreshCache(String productType);
}

View File

@@ -0,0 +1,371 @@
package com.ycwl.basic.product.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ycwl.basic.pricing.enums.ProductCategory;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import com.ycwl.basic.product.capability.PricingMode;
import com.ycwl.basic.product.capability.ProductTypeCapability;
import com.ycwl.basic.product.dto.ProductTypeCapabilityRequest;
import com.ycwl.basic.product.mapper.ProductTypeCapabilityMapper;
import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService;
import com.ycwl.basic.product.service.IProductTypeCapabilityService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 商品类型能力配置管理服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductTypeCapabilityManagementServiceImpl implements IProductTypeCapabilityManagementService {
private final ProductTypeCapabilityMapper mapper;
private final IProductTypeCapabilityService capabilityService;
@Override
public Page<ProductTypeCapability> queryByPage(int pageNum, int pageSize,
String productType, String category, Boolean isActive) {
Page<ProductTypeCapability> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<ProductTypeCapability> queryWrapper = new LambdaQueryWrapper<>();
if (productType != null && !productType.trim().isEmpty()) {
queryWrapper.like(ProductTypeCapability::getProductType, productType);
}
if (category != null && !category.trim().isEmpty()) {
queryWrapper.eq(ProductTypeCapability::getCategory, category);
}
if (isActive != null) {
queryWrapper.eq(ProductTypeCapability::getIsActive, isActive);
}
queryWrapper.orderByDesc(ProductTypeCapability::getCreatedAt);
return mapper.selectPage(page, queryWrapper);
}
@Override
public List<ProductTypeCapability> queryAll(boolean includeInactive) {
LambdaQueryWrapper<ProductTypeCapability> queryWrapper = new LambdaQueryWrapper<>();
if (!includeInactive) {
queryWrapper.eq(ProductTypeCapability::getIsActive, true);
}
queryWrapper.orderBy(true, true, ProductTypeCapability::getCategory, ProductTypeCapability::getProductType);
return mapper.selectList(queryWrapper);
}
@Override
public List<ProductTypeCapability> queryByCategory(String category) {
if (category == null || category.trim().isEmpty()) {
return new ArrayList<>();
}
LambdaQueryWrapper<ProductTypeCapability> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ProductTypeCapability::getCategory, category);
queryWrapper.eq(ProductTypeCapability::getIsActive, true);
queryWrapper.orderByAsc(ProductTypeCapability::getProductType);
return mapper.selectList(queryWrapper);
}
@Override
public ProductTypeCapability getById(Long id) {
if (id == null) {
throw new IllegalArgumentException("配置ID不能为空");
}
ProductTypeCapability capability = mapper.selectById(id);
if (capability == null) {
throw new IllegalArgumentException("配置不存在: " + id);
}
return capability;
}
@Override
public ProductTypeCapability getByProductType(String productType) {
if (productType == null || productType.trim().isEmpty()) {
throw new IllegalArgumentException("商品类型代码不能为空");
}
return mapper.selectByProductType(productType);
}
@Transactional(rollbackFor = Exception.class)
@Override
public ProductTypeCapability create(ProductTypeCapabilityRequest request) {
validateRequest(request, true);
// 检查商品类型代码是否已存在
ProductTypeCapability existing = mapper.selectByProductType(request.getProductType());
if (existing != null) {
throw new IllegalArgumentException("商品类型代码已存在: " + request.getProductType());
}
ProductTypeCapability capability = convertToEntity(request);
capability.setCreatedAt(LocalDateTime.now());
capability.setUpdatedAt(LocalDateTime.now());
mapper.insert(capability);
// 刷新缓存
capabilityService.refreshCache(capability.getProductType());
log.info("创建商品类型能力配置成功: {}", capability.getProductType());
return capability;
}
@Transactional(rollbackFor = Exception.class)
@Override
public ProductTypeCapability update(Long id, ProductTypeCapabilityRequest request) {
validateRequest(request, false);
ProductTypeCapability capability = getById(id);
// 如果修改了商品类型代码,检查新代码是否已被使用
if (request.getProductType() != null &&
!request.getProductType().equals(capability.getProductType())) {
ProductTypeCapability existing = mapper.selectByProductType(request.getProductType());
if (existing != null && !existing.getId().equals(id)) {
throw new IllegalArgumentException("商品类型代码已被使用: " + request.getProductType());
}
}
String oldProductType = capability.getProductType();
updateEntityFromRequest(capability, request);
capability.setUpdatedAt(LocalDateTime.now());
mapper.updateById(capability);
// 刷新缓存(如果商品类型代码改变了,需要刷新两个缓存)
capabilityService.refreshCache(capability.getProductType());
if (!oldProductType.equals(capability.getProductType())) {
capabilityService.refreshCache(oldProductType);
}
log.info("更新商品类型能力配置成功: {}", capability.getProductType());
return capability;
}
@Transactional(rollbackFor = Exception.class)
@Override
public void delete(Long id) {
ProductTypeCapability capability = getById(id);
mapper.deleteById(id);
// 刷新缓存
capabilityService.refreshCache(capability.getProductType());
log.info("删除商品类型能力配置成功: {}", capability.getProductType());
}
@Transactional(rollbackFor = Exception.class)
@Override
public void updateStatus(Long id, Boolean isActive) {
if (isActive == null) {
throw new IllegalArgumentException("状态不能为空");
}
ProductTypeCapability capability = getById(id);
capability.setIsActive(isActive);
capability.setUpdatedAt(LocalDateTime.now());
mapper.updateById(capability);
// 刷新缓存
capabilityService.refreshCache(capability.getProductType());
log.info("更新商品类型能力配置状态成功: {}, isActive={}", capability.getProductType(), isActive);
}
@Transactional(rollbackFor = Exception.class)
@Override
public int initializeDefaultCapabilities() {
int count = 0;
for (ProductType productType : ProductType.values()) {
// 检查是否已存在配置
ProductTypeCapability existing = mapper.selectByProductType(productType.getCode());
if (existing != null) {
log.debug("商品类型 {} 已存在配置,跳过初始化", productType.getCode());
continue;
}
// 创建默认配置
ProductTypeCapability capability = createDefaultCapability(productType);
mapper.insert(capability);
count++;
log.info("初始化商品类型能力配置: {}", productType.getCode());
}
// 刷新所有缓存
capabilityService.refreshCache();
log.info("商品类型能力配置批量初始化完成,共初始化 {} 个配置", count);
return count;
}
/**
* 创建默认配置
*/
private ProductTypeCapability createDefaultCapability(ProductType productType) {
ProductTypeCapability capability = new ProductTypeCapability();
capability.setProductType(productType.getCode());
capability.setDisplayName(productType.getDescription());
capability.setCategory(productType.getCategoryCode());
// 根据分类设置默认的定价模式和去重策略
if (ProductCategory.PRINT == productType.getCategory()) {
// 打印类:基于数量定价,允许重复购买
capability.setPricingMode(PricingMode.QUANTITY_BASED.getCode());
capability.setSupportsTierPricing(true);
capability.setAllowDuplicatePurchase(true);
capability.setDuplicateCheckStrategy(DuplicateCheckStrategy.NO_CHECK.getCode());
} else if (ProductCategory.PHOTO == productType.getCategory()
|| ProductCategory.VIDEO == productType.getCategory()
|| ProductCategory.VLOG == productType.getCategory()) {
// 照片类、视频类、Vlog类:固定价格,检查唯一资源
capability.setPricingMode(PricingMode.FIXED.getCode());
capability.setSupportsTierPricing(false);
capability.setAllowDuplicatePurchase(false);
// 判断是套餐类还是独立资源类
if (productType.getCode().endsWith("_SET")) {
capability.setDuplicateCheckStrategy(DuplicateCheckStrategy.PARENT_RESOURCE.getCode());
} else {
capability.setDuplicateCheckStrategy(DuplicateCheckStrategy.UNIQUE_RESOURCE.getCode());
}
} else {
// 其他类:默认不检查
capability.setPricingMode(PricingMode.FIXED.getCode());
capability.setSupportsTierPricing(false);
capability.setAllowDuplicatePurchase(true);
capability.setDuplicateCheckStrategy(DuplicateCheckStrategy.NO_CHECK.getCode());
}
// 优惠能力默认全部开启
capability.setCanUseCoupon(true);
capability.setCanUseVoucher(true);
capability.setCanUseOnePrice(true);
capability.setCanUseBundle(true);
capability.setIsActive(true);
capability.setCreatedAt(LocalDateTime.now());
capability.setUpdatedAt(LocalDateTime.now());
return capability;
}
/**
* 验证请求参数
*/
private void validateRequest(ProductTypeCapabilityRequest request, boolean isCreate) {
if (request == null) {
throw new IllegalArgumentException("请求参数不能为空");
}
if (isCreate || request.getProductType() != null) {
if (request.getProductType() == null || request.getProductType().trim().isEmpty()) {
throw new IllegalArgumentException("商品类型代码不能为空");
}
}
if (request.getDisplayName() != null && request.getDisplayName().trim().isEmpty()) {
throw new IllegalArgumentException("显示名称不能为空字符串");
}
// 验证枚举值有效性
if (request.getPricingMode() != null) {
try {
PricingMode.fromCode(request.getPricingMode());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("无效的定价模式: " + request.getPricingMode());
}
}
if (request.getDuplicateCheckStrategy() != null) {
try {
DuplicateCheckStrategy.fromCode(request.getDuplicateCheckStrategy());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("无效的重复检查策略: " + request.getDuplicateCheckStrategy());
}
}
if (request.getCategory() != null) {
try {
ProductCategory.fromCode(request.getCategory());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("无效的商品分类: " + request.getCategory());
}
}
}
/**
* 将请求转换为实体
*/
private ProductTypeCapability convertToEntity(ProductTypeCapabilityRequest request) {
ProductTypeCapability capability = new ProductTypeCapability();
updateEntityFromRequest(capability, request);
return capability;
}
/**
* 从请求更新实体
*/
private void updateEntityFromRequest(ProductTypeCapability capability, ProductTypeCapabilityRequest request) {
if (request.getProductType() != null) {
capability.setProductType(request.getProductType());
}
if (request.getDisplayName() != null) {
capability.setDisplayName(request.getDisplayName());
}
if (request.getCategory() != null) {
capability.setCategory(request.getCategory());
}
if (request.getPricingMode() != null) {
capability.setPricingMode(request.getPricingMode());
}
if (request.getSupportsTierPricing() != null) {
capability.setSupportsTierPricing(request.getSupportsTierPricing());
}
if (request.getAllowDuplicatePurchase() != null) {
capability.setAllowDuplicatePurchase(request.getAllowDuplicatePurchase());
}
if (request.getDuplicateCheckStrategy() != null) {
capability.setDuplicateCheckStrategy(request.getDuplicateCheckStrategy());
}
if (request.getCanUseCoupon() != null) {
capability.setCanUseCoupon(request.getCanUseCoupon());
}
if (request.getCanUseVoucher() != null) {
capability.setCanUseVoucher(request.getCanUseVoucher());
}
if (request.getCanUseOnePrice() != null) {
capability.setCanUseOnePrice(request.getCanUseOnePrice());
}
if (request.getCanUseBundle() != null) {
capability.setCanUseBundle(request.getCanUseBundle());
}
if (request.getMetadata() != null) {
capability.setMetadata(request.getMetadata());
}
if (request.getIsActive() != null) {
capability.setIsActive(request.getIsActive());
}
}
}

View File

@@ -0,0 +1,152 @@
package com.ycwl.basic.product.service.impl;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import com.ycwl.basic.product.capability.PricingMode;
import com.ycwl.basic.product.capability.ProductTypeCapability;
import com.ycwl.basic.product.mapper.ProductTypeCapabilityMapper;
import com.ycwl.basic.product.service.IProductTypeCapabilityService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
/**
* 商品类型能力服务实现
*/
@Slf4j
@Service
public class ProductTypeCapabilityServiceImpl implements IProductTypeCapabilityService {
@Autowired
private ProductTypeCapabilityMapper mapper;
@Override
public ProductTypeCapability getCapability(String productType) {
if (productType == null || productType.trim().isEmpty()) {
log.warn("商品类型代码为空,返回默认配置");
return getDefaultCapability(null);
}
try {
ProductTypeCapability capability = mapper.selectByProductType(productType);
if (capability == null) {
log.warn("未找到商品类型能力配置: {}, 使用默认配置", productType);
return getDefaultCapability(productType);
}
// 验证配置完整性
if (!isValidCapability(capability)) {
log.warn("商品类型能力配置不完整: {}, 使用默认配置", productType);
return getDefaultCapability(productType);
}
return capability;
} catch (Exception e) {
log.error("查询商品类型能力配置失败: {}, 降级到默认配置", productType, e);
return getDefaultCapability(productType);
}
}
@Override
public String getDisplayName(String productType) {
ProductTypeCapability capability = getCapability(productType);
return capability.getDisplayName();
}
@Override
public boolean allowDuplicatePurchase(String productType) {
ProductTypeCapability capability = getCapability(productType);
return Boolean.TRUE.equals(capability.getAllowDuplicatePurchase());
}
@Override
public PricingMode getPricingMode(String productType) {
ProductTypeCapability capability = getCapability(productType);
return capability.getPricingModeEnum();
}
@Override
public DuplicateCheckStrategy getDuplicateCheckStrategy(String productType) {
ProductTypeCapability capability = getCapability(productType);
return capability.getDuplicateCheckStrategyEnum();
}
@Override
public void refreshCache() {
log.info("刷新所有商品类型能力缓存");
}
@Override
public void refreshCache(String productType) {
log.info("刷新商品类型能力缓存: {}", productType);
}
/**
* 获取默认能力配置(兜底方案)
* 设计原则:安全第一,宁可限制也不放开
*
* @param productType 商品类型代码(可为null)
* @return 安全的默认配置
*/
private ProductTypeCapability getDefaultCapability(String productType) {
ProductTypeCapability defaultCap = new ProductTypeCapability();
defaultCap.setProductType(productType);
defaultCap.setDisplayName("景区商品");
defaultCap.setCategory("GENERAL");
// 定价相关:默认固定价格
defaultCap.setPricingMode(PricingMode.FIXED.getCode());
defaultCap.setSupportsTierPricing(false);
// 购买限制:默认不允许重复购买(安全策略)
defaultCap.setAllowDuplicatePurchase(false);
defaultCap.setDuplicateCheckStrategy(DuplicateCheckStrategy.NO_CHECK.getCode());
// 优惠能力:默认全部允许
defaultCap.setCanUseCoupon(true);
defaultCap.setCanUseVoucher(true);
defaultCap.setCanUseOnePrice(true);
defaultCap.setCanUseBundle(true);
defaultCap.setIsActive(true);
return defaultCap;
}
/**
* 验证配置完整性
*/
private boolean isValidCapability(ProductTypeCapability capability) {
if (capability == null) {
return false;
}
// 必填字段检查
if (capability.getProductType() == null || capability.getProductType().trim().isEmpty()) {
return false;
}
if (capability.getDisplayName() == null || capability.getDisplayName().trim().isEmpty()) {
return false;
}
if (capability.getPricingMode() == null) {
return false;
}
// 验证枚举值有效性
try {
PricingMode.fromCode(capability.getPricingMode());
if (capability.getDuplicateCheckStrategy() != null) {
DuplicateCheckStrategy.fromCode(capability.getDuplicateCheckStrategy());
}
} catch (IllegalArgumentException e) {
log.error("商品类型能力配置包含无效的枚举值: {}", capability.getProductType(), e);
return false;
}
return true;
}
}

View File

@@ -6,6 +6,7 @@ import com.ycwl.basic.model.pc.source.req.SourceReqQuery;
import com.ycwl.basic.model.pc.source.resp.SourceRespVO;
import com.ycwl.basic.utils.ApiResponse;
import java.io.File;
import java.util.List;
/**
@@ -21,5 +22,5 @@ public interface SourceService {
ApiResponse<Integer> update(SourceEntity source);
ApiResponse cutVideo(Long id);
String uploadAndUpdateUrl(Long id, org.springframework.web.multipart.MultipartFile file);
String uploadAndUpdateUrl(Long id, File file);
}

View File

@@ -0,0 +1,121 @@
package com.ycwl.basic.service.pc.helper;
import com.ycwl.basic.util.TtlCacheMap;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Date;
/**
* 人脸匹配防重复服务
* 使用本地缓存实现2秒内的防重复调用机制
*
* @author Claude
* @since 2025-11-27
*/
@Slf4j
@Service
public class FaceMatchDedupService {
/**
* 防重复TTL时间(毫秒)
*/
private static final long DEFAULT_TTL_MILLIS = 2000L;
/**
* 缓存Key前缀
*/
private static final String KEY_PREFIX = "face:match:dedup:";
/**
* 防重复缓存
* Key: face:match:dedup:{scenicId}:{userId}
* Value: 时间戳
*/
private final TtlCacheMap<String, Long> dedupCache;
public FaceMatchDedupService() {
this.dedupCache = new TtlCacheMap<>(DEFAULT_TTL_MILLIS);
}
/**
* 检查是否应该跳过本次匹配(防重复)
*
* @param userId 用户ID
* @param scenicId 景区ID
* @return true-应该跳过,false-可以执行
*/
public boolean shouldSkip(Long userId, Long scenicId) {
if (userId == null || scenicId == null) {
return false;
}
String key = buildKey(userId, scenicId);
Long timestamp = dedupCache.get(key);
if (timestamp != null) {
long elapsedMs = System.currentTimeMillis() - timestamp;
log.info("防重复检查:检测到{}秒内的重复调用(已过{}ms),跳过匹配。userId={}, scenicId={}, 上次调用时间={}",
DEFAULT_TTL_MILLIS / 1000.0, elapsedMs, userId, scenicId, new Date(timestamp));
return true;
}
return false;
}
/**
* 标记本次匹配已执行(用于防重复)
*
* @param userId 用户ID
* @param scenicId 景区ID
*/
public void markMatched(Long userId, Long scenicId) {
if (userId == null || scenicId == null) {
return;
}
String key = buildKey(userId, scenicId);
long timestamp = System.currentTimeMillis();
dedupCache.put(key, timestamp, DEFAULT_TTL_MILLIS);
log.debug("防重复标记:记录匹配调用。userId={}, scenicId={}, TTL={}ms, timestamp={}",
userId, scenicId, DEFAULT_TTL_MILLIS, timestamp);
}
/**
* 清除指定用户的防重标记(用于测试或特殊场景)
*
* @param userId 用户ID
* @param scenicId 景区ID
*/
public void clearMark(Long userId, Long scenicId) {
if (userId == null || scenicId == null) {
return;
}
String key = buildKey(userId, scenicId);
dedupCache.remove(key);
log.info("防重复标记清除:userId={}, scenicId={}", userId, scenicId);
}
/**
* 构建缓存Key
* 格式:face:match:dedup:{scenicId}:{userId}
*
* @param userId 用户ID
* @param scenicId 景区ID
* @return 缓存Key
*/
private String buildKey(Long userId, Long scenicId) {
return KEY_PREFIX + scenicId + ":" + userId;
}
/**
* 获取缓存统计信息(用于监控)
*
* @return 统计信息字符串
*/
public String getStats() {
return dedupCache.getStats();
}
}

View File

@@ -65,6 +65,7 @@ import com.ycwl.basic.service.mobile.GoodsService;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.service.pc.ScenicService;
import com.ycwl.basic.constant.SourceType;
import com.ycwl.basic.service.pc.helper.FaceMatchDedupService;
import com.ycwl.basic.service.pc.helper.FaceMetricsRecorder;
import com.ycwl.basic.service.pc.helper.SearchResultMerger;
import com.ycwl.basic.service.pc.helper.ScenicConfigFacade;
@@ -175,6 +176,10 @@ public class FaceServiceImpl implements FaceService {
@Autowired
private FaceMatchingOrchestrator faceMatchingOrchestrator;
// 防重复服务
@Autowired
private FaceMatchDedupService faceMatchDedupService;
// 第二阶段的处理器
@Autowired
private SourceRelationProcessor sourceRelationProcessor;
@@ -326,7 +331,27 @@ public class FaceServiceImpl implements FaceService {
@Override
public SearchFaceRespVo matchFaceId(Long faceId) {
return faceMatchingOrchestrator.orchestrateMatching(faceId, false);
// 获取人脸信息用于防重检查
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.warn("人脸不存在,无法执行匹配,faceId: {}", faceId);
return null;
}
// 防重复检查:如果2秒内已调用过,则跳过
if (faceMatchDedupService.shouldSkip(face.getMemberId(), face.getScenicId())) {
log.info("防重复:跳过人脸匹配。faceId={}, userId={}, scenicId={}",
faceId, face.getMemberId(), face.getScenicId());
return null; // 静默忽略,返回null
}
// 正常执行人脸匹配
SearchFaceRespVo result = faceMatchingOrchestrator.orchestrateMatching(faceId, false);
// 执行完成后标记,防止2秒内重复调用
faceMatchDedupService.markMatched(face.getMemberId(), face.getScenicId());
return result;
}
@Override
@@ -430,7 +455,7 @@ public class FaceServiceImpl implements FaceService {
} else if (taskById.getStatus() == 3) {
contentPageVO.setLockType(2);
} else {
contentPageVO.setLockType(0);
contentPageVO.setLockType(-9); // 正在生成
}
contentPageVO.setContentType(0);
}

View File

@@ -25,6 +25,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.ycwl.basic.storage.StorageFactory;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
@@ -216,14 +217,14 @@ public class SourceServiceImpl implements SourceService {
}
@Override
public String uploadAndUpdateUrl(Long id, MultipartFile file) {
public String uploadAndUpdateUrl(Long id, File file) {
SourceRespVO source = sourceMapper.getById(id);
if (source == null) {
throw new BaseException("该素材不存在");
}
try {
IStorageAdapter adapter = scenicService.getScenicStorageAdapter(source.getScenicId());
String uploadedUrl = adapter.uploadFile(file, PHOTO_PATH, id + "_q_.jpg");
String uploadedUrl = adapter.uploadFile("image/jpeg", file, PHOTO_PATH, id + "_q_.jpg");
SourceEntity sourceUpd = new SourceEntity();
sourceUpd.setId(id);

Some files were not shown because too many files have changed in this diff Show More