Compare commits

...

180 Commits

Author SHA1 Message Date
5cc32ddf61 feat(order): 优化订单查询逻辑以支持景区关联数据
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 在member_plog_data子查询中增加scenic_id字段
- 添加puzzle_template与puzzle_generation_record的左连接
- 修改member_plog_data与其他表的连接条件以兼容景区ID匹配
- 支持通过goods_id或scenic_id关联member_plog_data表
- 提升订单详情中图片资源定位准确性
2025-12-14 00:04:06 +08:00
07987835ec fix(face): 修复人脸购买逻辑判断问题
- 修改AppPuzzleController中的人脸购买判断逻辑
- 增加对景区是否购买的前置判断
- 优化FaceServiceImpl中的人脸购买状态设置逻辑
- 确保模板购买状态的准确判断
- 避免重复查询价格计算服务
2025-12-13 23:47:29 +08:00
0a3f4119d7 feat(price): 添加pLog图商品到景区打包列表
- 在PHOTO_LOG情况下增加SimpleGoodsRespVO对象
- 对象包含景区ID、名称和产品类型信息
- 确保pLog图模板能正确显示在商品列表中
2025-12-13 23:43:31 +08:00
51c7de2474 feat(fill): 新增设备缩略图数据源策略
- 实现DeviceThumbImageDataSourceStrategy类,支持根据deviceIndex获取设备缩略图
- 支持从过滤后的机位列表或直接通过deviceIndex两种方式查询数据
- 默认使用LATEST排序策略,可配置type类型(默认为图片类型2)
- 添加对filteredDeviceIds上下文参数的支持,提升数据筛选灵活性
- 增强日志记录,便于调试和问题追踪
- 在DataSourceType枚举中新增DEVICE_THUMB_IMAGE类型定义
2025-12-13 21:47:41 +08:00
773d7f2254 refactor(service): 优化拼图模板处理逻辑
- 将遍历所有拼图模板改为只处理第一个模板
- 简化内容页面对象创建流程
- 保留原有的购买状态检查和价格计算逻辑
- 提高代码执行效率,避免不必要的循环操作
2025-12-13 21:41:11 +08:00
af131131ed fix(task): 修改任务创建接口中的自动标志参数类型
- 将 createTaskByFaceIdAndTemplateId 方法的 automatic 参数从 int 改为 boolean
- 更新方法实现以适配新的布尔值参数
- 移除无用的导入类和未使用的代码
- 调整任务实体中 automatic 字段的赋值逻辑以匹配新类型
- 删除已弃用的旧版重载方法
- 确保所有调用点传递正确的布尔值而非整数
- 优化代码结构并提高可读性
2025-12-13 19:19:21 +08:00
3f6f1508c5 feat(order): 增加faceId校验的订单购买检查功能
- 新增checkUserBuyFaceItem方法,支持校验用户购买商品时的人脸ID匹配
- 修改PriceBiz中商品类型设置,从13改为5
- 更新FaceServiceImpl中的购买检查逻辑,使用新的带faceId校验的方法
- 调整OrderServiceImpl中订单项的goodsType和goodsId设置逻辑
- 移除旧的checkUserBuyItem方法及相关缓存逻辑
- 新增ORDER_USER_FACE_TYPE_BUY_ITEM_CACHE_KEY缓存键定义
2025-12-13 19:00:25 +08:00
dbee1d9709 feat(puzzle): 使用虚拟线程优化拼图模板批量生成性能
- 将原有的串行模板生成逻辑改为并行处理
- 使用虚拟线程池提升高并发场景下的执行效率
- 通过 CompletableFuture 异步执行每个模板的生成任务
- 保留原有日志记录和异常处理机制
- 统计成功与失败数量并输出汇总日志
2025-12-13 17:38:48 +08:00
83d1096fdb feat(order): 添加vlog视频模板购买逻辑
- 在订单业务中处理类型为-1的商品(vlog视频模板)
- 调用视频仓库方法设置用户已购买模板
- 新增setUserIsBuyTemplate方法实现模板购买状态更新
- 查询面部关联视频并更新购买状态及清理缓存

feat(price): 增加拼图商品列表查询功能

- 在价格服务中加入对拼图模板的查询
- 设置拼图商品类型为13
- 将拼图模板信息加入返回的商品列表中
2025-12-13 14:13:59 +08:00
82925d203c feat(config): 添加优雅关机配置和智谱API密钥
- 在开发环境配置中启用优雅关机
- 设置每个关机阶段超时时间为60秒
- 添加智谱AI服务的API密钥配置
- 统一开发和生产环境的基础配置结构
2025-12-12 17:19:29 +08:00
3b11ddef6a feat(chat): 实现人脸智能聊天核心功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 新增小程序人脸聊天控制器 AppChatController,支持会话创建、消息收发、历史查询及会话关闭
- 集成智谱 GLM 模型客户端 GlmClient,支持流式文本生成与回调
- 新增聊天会话与消息实体类及 MyBatis 映射,实现数据持久化
- 提供 FaceChatService 接口及实现,封装聊天业务逻辑包括同步/流式消息发送
- 引入 zai-sdk 依赖以支持调用智谱 AI 大模型能力
- 支持基于人脸 ID 的唯一会话管理与用户权限校验
- 消息记录包含角色、内容、追踪 ID 及延迟信息,便于调试与分析
2025-12-11 17:45:49 +08:00
6e7b4729a8 feat(ai-cam): 新增使用人脸样本创建或获取Face记录功能
- 在AppAiCamController中新增/useSample/{faceSampleId}接口
- 实现通过人脸样本ID查找或创建Face记录的业务逻辑
- 自动关联AI相机照片到用户人脸记录
- 支持AI_CAM设备类型的二维码路径配置
- 完善人脸匹配及日志记录功能
- 添加相关实体类和工具类导入依赖
2025-12-09 16:20:50 +08:00
917cb37ccf feat(device): 添加获取设备基本信息的方法
- 新增 getDeviceBasic 方法直接返回 DeviceV2DTO 实例
- 添加设备基本信息查询的日志记录
- 实现通过设备ID获取设备详情的功能集成调用
2025-12-09 15:59:29 +08:00
7c0a3a63bb fix(order): 兼容旧逻辑并清理Redis缓存
- 在订单类型为3时设置Redis标识
- 删除冗余的Redis键值对清理操作
- 统一订单内容不可下载的处理逻辑
2025-12-08 13:51:28 +08:00
478467e124 fix(order): 更新商品类型13的名称显示
- 将"AI相机照片集"更正为"打卡点拍照"
- 同步更新OrderServiceImpl中的商品名称和订单类型
- 修改OrderMapper.xml中对应的商品名称映射逻辑
2025-12-08 13:43:11 +08:00
d5befd75e1 fix(pricing): 修复优惠券查询条件拼接问题
- 在每个查询条件后添加空格,避免SQL语法错误
- 确保动态SQL片段正确连接
- 优化时间范围查询条件的格式处理
2025-12-08 10:58:33 +08:00
b2c55c9feb refactor(printer): 优化照片处理管线与自动发券逻辑
- 调整自动发券判断条件,仅当存在type=3的source记录时触发
- 修改普通照片与拼图处理流程中的图像增强控制逻辑
- 移除冗余的图像缩放阶段,优化处理效率
- 增加processPhotoWithPipeline重载方法支持图像增强选项
- 重构水印配置方法,新增scale参数控制缩放比例
- 异步处理打印任务创建与推送,提升响应速度
- 复用processPhotoWithPipeline方法简化重打印处理逻辑
2025-12-07 21:42:48 +08:00
fef616c837 feat(image): 添加水印缩放功能支持
- 在 WatermarkConfig 中新增 scale 字段用于控制整体缩放倍数
- 在 WatermarkStage 中读取并传递 scale 参数到 WatermarkInfo
- 在 PrinterDefaultWatermarkOperator 中实现所有位置和尺寸的缩放逻辑
- 对偏移量、边距、字体大小、二维码尺寸等应用缩放因子
- 更新图像绘制相关参数计算方式以支持动态缩放
- 优化二维码圆形背景和头像绘制的缩放处理
- 确保缩放后的水印元素保持相对位置和视觉一致性
2025-12-07 21:42:11 +08:00
a5fe00052d feat(pricing): 支持发放多个首次打印优惠券
- 修改自动发券逻辑,支持发放多个符合条件的首次优惠券
- 更新查找优惠券方法,返回所有匹配的优惠券ID列表
- 添加发券过程中的异常处理,确保部分失败不影响其他券发放
- 记录详细的发券日志,包括成功、跳过和失败的数量
- 优化日志输出,提供更清晰的调试信息
2025-12-07 21:41:54 +08:00
349b702fc3 1 2025-12-06 22:42:05 +08:00
9f5a61247b feat(printer): 增加对source.type=3的特殊图片处理流程
- 新增ImageResizeStage、ImageSRStage和UpdateMemberPrintStage处理阶段
- 对type=3的图片增加超分辨率和图像增强处理
- 构建新的处理管线,包含下载、方向校正、超分、增强、上传等12个阶段
- 兼容旧版URL处理逻辑,针对type=3替换缩略图为原图URL
- 优化图片来源判断逻辑,增加source实体查询
- 完善处理日志记录和阶段状态控制
2025-12-06 22:42:05 +08:00
9321422e56 fix(mobile): 修复商品名称显示问题
- 修正商品类型为3时的名称显示逻辑
- 拍摄时间格式化后添加到商品名称中
- 优化商品名称前缀拼接逻辑
2025-12-06 22:42:05 +08:00
1834fe3ddd feat(order): 添加订单可下载状态查询接口
- 在AppOrderV2Controller中引入RedisTemplate依赖
- 新增/downloadable/{orderId} GET接口
- 通过检查Redis键值判断订单是否可下载
- 返回ApiResponse包装的布尔值表示下载状态
2025-12-06 21:23:58 +08:00
fa8f92d38b refactor(order): 调整图像处理逻辑与订单兼容性设置
- 将图像处理逻辑移至事务提交后执行
- 添加订单内容不可下载标识兼容旧逻辑
- 移除冗余的券服务注入依赖
- 清理订单相关缓存以确保数据一致性
2025-12-06 21:21:52 +08:00
df33e7929f feat(repository): 优化AI相机照片处理性能
- 引入CompletableFuture实现照片处理并发执行
- 创建专用线程池IMAGE_PROCESS_EXECUTOR管理异步任务
- 将原有串行处理逻辑改为并行处理
- 更新默认存储适配器从assets到assets-ext
2025-12-06 21:14:29 +08:00
554f55a7c1 feat(storage): 集成动态存储配置管理
- 引入ScenicConfigManager以支持景区级别的存储配置
- 添加StorageFactory和IStorageAdapter以实现灵活的存储适配
- 在图像处理流程中集成存储适配器的初始化逻辑
- 支持从配置中加载存储类型和相关参数
- 提供降级机制,默认使用assets存储适配器
- 增强SourceRepository对存储配置的依赖注入支持
2025-12-06 21:06:22 +08:00
f71149fd06 feat(order): 新增AI相机拍照套餐价格计算逻辑
- 在OrderBiz中增加对AI相机拍照套餐的价格计算处理
- 针对产品类型为AI_CAM_PHOTO_SET的场景实现价格查询逻辑
- 设置仅查询价格标志,避免实际使用优惠
- 补充价格对象的景区ID设置逻辑
2025-12-06 21:06:09 +08:00
e8eb8d816b refactor(repository): 暂时禁用图像超分辨率处理阶段
- 注释掉图像超分辨率处理阶段以优化处理流程
- 保留其他图像处理阶段(下载、增强、上传、清理)
- 为后续重新启用超分辨率功能预留接口配置
2025-12-06 20:00:40 +08:00
576d87d113 refactor(logging): 重构任务日志配置
- 将 DeviceVideoContinuityCheckTask 的专用日志 appender 重命名为通用 TASK_LOG
- 更新日志文件路径从 device_video_continuity_check_task.log 到 task.log
- 为多个任务类添加共享的日志记录器配置
- 包括 FaceCleaner、VideoPieceCleaner、DynamicTaskGenerator 和 DownloadNotificationTasker 的日志设置
2025-12-06 17:43:56 +08:00
a2378053a8 feat(printer): 打印订单成功后自动发放优惠券
- 在打印订单成功后调用自动发券服务
- 添加对自动发券异常的捕获与日志记录
- 确保发券失败不影响主业务流程
2025-12-06 17:32:21 +08:00
c92ea20575 feat(logging): 为设备视频连续性检查任务添加专用日志配置
- 新增 DeviceVideoContinuityCheckTask 专用日志文件
- 新增 FaceProcessingKafkaService 专用日志文件
- 新增 DeviceStorageOperator 专用日志文件
- 配置独立的日志滚动策略和文件命名规则
- 设置日志级别为 INFO 并禁用继承
- 限制最大历史文件数量为 30 天
- 设置单个日志文件最大大小为 10MB
- 总日志文件容量上限设置为 5GB
2025-12-06 16:11:31 +08:00
bb71cf9458 feat(image): 新增AI相机照片增强处理功能
- 在PipelineScene枚举中新增AI_CAM_ENHANCE场景
- 修改setUserIsBuyItem方法,增加对AI相机照片的图像处理逻辑
- 新增processAiCamImages方法,批量处理AI相机照片
- 新增processSingleAiCamImage方法,处理单张AI相机照片
- 新增buildEnhancerConfig方法,构建图像增强配置
- 实现图像处理管线:下载->超分->增强->上传->清理
- 添加处理结果URL回调更新机制
- 增加异常处理和日志记录,确保处理失败不影响主流程
2025-12-06 16:11:07 +08:00
7749faf807 feat(order): 添加AI相机照片集商品类型支持
- 在OrderServiceImpl中增加对商品类型13(AI相机照片集)的处理逻辑
- 新增listAiCamImageByFaceRelation方法用于查询AI相机图片数据
- 扩展订单详情展示逻辑,支持AI相机照片集的封面和拍摄时间显示
- 更新OrderMapper.xml,新增member_source_aicam_data查询片段
- 修改SQL映射,增加对goods_type=13情况的字段匹配规则
- 完善商品名称和订单类型的设置逻辑,区分AI相机照片集与其他类型
2025-12-06 14:42:16 +08:00
c42b055d5f feat(printer): 添加图片裁剪信息字段并实现裁剪功能
- 在 MemberPrintEntity 中新增 crop 字段用于存储裁剪信息
- 创建 Crop 类并添加 Lombok 注解以支持构造函数和 getter/setter
- 在 PrinterServiceImpl 中调用 smartCropAndFill 方法进行图片裁剪
- 设置默认旋转角度为 270 并将裁剪信息序列化后保存到数据库
- 更新 PrinterMapper.xml 配置文件以支持新字段的插入和查询
2025-12-06 14:41:06 +08:00
fe3bda28b4 feat(ai-cam): 增强AI摄像头人脸检测逻辑
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 引入时间范围限制配置,支持按分钟设置检测窗口
- 创建DetectResult内部类,记录人脸检测的分数和时间信息
- 优化照片筛选逻辑,先按分数排序再应用时间范围过滤
- 更新设备类型名称,将"微单"改为"AI微单"
- 增强日志记录,提供更详细的调试信息
2025-12-05 21:41:30 +08:00
66775ea48b feat(ai-cam): 增强AI摄像头人脸检测逻辑
- 引入时间范围限制配置,支持按分钟设置检测窗口
- 创建DetectResult内部类,记录人脸检测的分数和时间信息
- 优化照片筛选逻辑,先按分数排序再应用时间范围过滤
- 更新设备类型名称,将"微单"改为"AI微单"
- 增强日志记录,提供更详细的调试信息
2025-12-05 20:03:54 +08:00
125fadd6c5 feat(basic): 新增AI微单类型支持
- 在SourceType枚举中新增AI_CAM类型及其判断方法
- 在ProductType枚举中新增AI_CAM_PHOTO_SET类型
- 扩展SourceMapper接口及XML实现删除指定faceId和type的关联记录功能
- 更新AppAiCamServiceImpl服务逻辑,在添加新关联前先删除旧记录
- 修改GoodsServiceImpl以识别并处理AI微单类型的商品名称前缀
- 在FaceServiceImpl中增加对AI微单内容的查询与展示逻辑
- 优化face相关素材分类展示,确保AI微单正确归类显示
2025-12-05 19:58:53 +08:00
1f4a16f0e6 feat(ai-cam): 实现AI相机商品识别与会员关联功能
- 新增AppAiCamController控制器,提供获取AI相机识别商品和添加会员素材关联接口
- 实现AppAiCamService服务,完成从人脸识别日志到商品详情的转换逻辑
- 扩展FaceDetectLogAiCamMapper,支持根据faceId查询识别记录
- 扩展SourceMapper,新增根据faceSampleIds和type查询source列表的方法
- 添加设备配置管理,支持按设备设置识别分数阈值和照片数量限制
- 实现人脸识别结果解析,提取匹配度高的faceSampleId并去重处理
- 完成商品详情VO转换,包含素材URL、视频URL及购买状态等信息
- 支持批量添加会员与素材的关联关系,确保数据一致性校验
2025-12-05 17:52:46 +08:00
e9916d6aca fix(service): 修复ZTSourceDataService中照片类型设置逻辑
- 将硬编码的照片类型值替换为从消息对象获取的动态类型值
- 确保entity.setType()方法正确反映实际的消息来源类型
- 维持原有缩略图URL和设备ID等其他属性的设置逻辑不变
2025-12-05 17:35:18 +08:00
b71452b3ed refactor(face): 替换Strings工具类引用以优化代码
- 将org.apache.logging.log4j.util.Strings替换为org.apache.commons.lang3.StringUtils
- 统一使用StringUtils处理字符串判空逻辑
- 优化线程join条件判断中的字符串比较方式
- 更新所有相关字符串工具方法调用以保持一致性
2025-12-05 17:08:16 +08:00
4a82ee6c4d feat(ai): 实现AI相机人脸识别日志记录功能
- 引入DeviceRepository以获取景区内所有AI相机设备
- 修改searchAndLog方法逻辑,遍历所有AI相机设备进行人脸搜索
- 新增searchDeviceAndLog私有方法处理单个设备的人脸识别与日志记录
- 更新FaceDetectLogAiCamService接口定义,移除deviceId参数
- 在FaceServiceImpl中调用新的日志记录服务
- 删除不再使用的DeviceConfigManager和FaceRecoveryStrategy依赖
- 调整日志记录中的字段名称及异常处理逻辑
2025-12-05 16:54:47 +08:00
24bbb63bf7 feat(config): 添加Mybatis Plus分页插件和Mapper扫描配置
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 配置MybatisPlusInterceptor分页插件
- 添加@MapperScan注解扫描多个mapper包
- 为多个service注入添加@Lazy注解解决循环依赖
- 在VoucherServiceImpl和PuzzleGenerateServiceImpl中启用懒加载
- 优化订单服务中的依赖注入配置
2025-12-05 16:14:43 +08:00
ee13ef09f7 refactor(pricing): 优化优惠提供者初始化和依赖注入
- 使用 @Lazy 注解解决循环依赖问题
- 重构 DiscountDetectionServiceImpl 以延迟加载优惠提供者
- 移除构造函数中的直接依赖注入,改用 ObjectProvider
- 添加线程安全的提供者初始化机制
- 移除不必要的缓存注释
2025-12-05 16:03:00 +08:00
33c3a194ca refactor(kafka): 修改人脸库分组命名规则
- 将人脸库分组名称从 "ai-cam-{deviceId}" 更改为 "AiCam{deviceId}"
- 更新 FaceProcessingKafkaService 中的数据库名称生成逻辑
- 同步修改 FaceDetectLogAiCamServiceImpl 中的数据库名称使用方式
2025-12-05 16:01:46 +08:00
71a8d3b539 refactor(core): 添加 Lazy 注解解决循环依赖问题
- 在多个 Service 类中为注入的依赖添加 @Lazy 注解
- 修改了微信支付服务实现类中的依赖注入方式
- 更新了打印机服务实现类中的依赖注入配置
- 调整了统计拦截器和服务类中的依赖注入策略
- 优化了 FaceService 和相关 Repository 的注入方式
- 防止应用启动时因循环依赖导致的初始化失败
2025-12-05 15:22:19 +08:00
82626f615b refactor(auth): 延迟加载RedisTemplate和Mapper依赖
- 为RedisTemplate添加@Lazy注解实现延迟加载
- 为scenicAccountMapper添加@Lazy注解实现延迟加载
- 为adminUserMapper添加@Lazy注解实现延迟加载
- 避免应用启动时不必要的依赖初始化
- 提升系统启动性能和资源利用率
2025-12-05 15:12:14 +08:00
de2eadf764 feat(biz): 新增AI微单照片集商品类型支持
- 在PriceBiz类中添加对"AI_CAM_PHOTO_SET"商品类型的处理逻辑
- 当商品类型为"AI_CAM_PHOTO_SET"时,返回固定的照片集条目
- 商品名称设置为"AI微单照片集"以区分普通照片集
2025-12-05 13:48:14 +08:00
fd143830d3 refactor(entity): 修改人脸检测日志实体字段名称
- 将 faceSampleId 字段重命名为 faceId
- 更新相关服务实现类中的字段赋值逻辑
- 保持数据库字段映射一致性
2025-12-05 13:39:01 +08:00
68916f3f53 feat(ai-cam): 新增AI相机人脸识别日志功能
- 创建人脸检测日志实体类FaceDetectLogAiCamEntity
- 实现对应的MyBatis Mapper接口FaceDetectLogAiCamMapper
- 添加服务接口及实现类FaceDetectLogAiCamService
- 支持调用适配器进行人脸搜索并记录日志
- 记录搜索结果、匹配分数及原始响应数据
- 处理异常情况并记录错误信息到日志表中
2025-12-05 12:35:21 +08:00
e27ed7d971 feat(kafka): 新增AI相机人脸处理消息消费逻辑
- 新增AI相机专用Kafka主题(zt-ai-cam-face)监听
- 新增FaceSampleAiCamMapper及对应XML映射文件
- 实现AI相机人脸数据入库及状态更新逻辑
- 实现基于设备ID的人脸库分组策略
- 添加异步人脸识别处理及评分更新功能
- 增加预订单任务触发机制
- 补充安全的状态更新与异常处理机制
2025-12-05 11:30:45 +08:00
7a19f18962 fix(dto): 扩展照片类型判断逻辑
- 修改 isPhoto 方法以支持类型 2 和 3 作为照片类型
- 保持原有的空值检查逻辑不变
- 确保向后兼容性的同时增加新的照片类型支持
2025-12-05 10:52:06 +08:00
eade5f8092 feat(printer): 优化自动添加照片到打印列表逻辑
- 修改autoAddPhotosToPreferPrint方法返回值为List<SourceEntity>
- 当自动添加成功时直接返回添加的照片列表
- 添加失败或无照片时返回空列表而非数量
- 控制器根据返回结果判断是否生成二维码URL
- 提升代码可读性和维护性
2025-12-04 21:23:23 +08:00
42540e2dc4 feat(printer): 新增人脸绑定二维码生成功能
- 添加获取人脸绑定二维码接口,支持生成小程序二维码
- 实现二维码文件流输出及临时文件清理
- 修改人脸识别流程,保存人脸数据并返回faceId
- 调整自动添加照片到打印列表逻辑,返回添加数量
- 更新响应模型,增加二维码URL字段
- 优化人脸匹配逻辑,使用memberRelationRepository查询关联照片
- 修复BCE适配器图片下载地址问题,去除内网地址替换逻辑
2025-12-04 18:21:18 +08:00
15dda645b9 test(pipeline): 添加管道构建器测试类的包导入
- 导入 AbstractPipelineStage 类
- 导入 Pipeline 接口
- 导入 PipelineBuilder 类
- 导入 StageResult 类
2025-12-04 16:17:43 +08:00
17419d83e7 docs(pipeline): 添加Pipeline通用管线框架设计文档
- 新增CLAUDE.md文件,详细描述Pipeline框架的设计理念与使用方法
- 介绍责任链模式、Builder模式在Pipeline中的应用
- 说明动态Stage添加、降级策略、配置驱动等核心特性
- 提供完整的业务实现示例(人脸匹配、图片处理)
- 详细阐述Pipeline、PipelineContext、PipelineStage等核心组件
- 描述StageResult状态管理及@StageConfig注解使用方式
- 展示PipelineBuilder构建器模式的灵活用法
- 提供从Context创建到Stage实现再到Pipeline组装的全流程指导
- 总结最佳实践,包括错误处理策略、性能优化建议和测试方法
- 回答常见问题,如跳过Stage、动态添加Stage及超时处理等场景
2025-12-04 10:50:02 +08:00
ae92ba10a7 refactor(pipeline): 统一导入StageResult类路径
- 将多个测试类中的StageResult导入路径从face.pipeline.core统一调整为pipeline.core
- 修复StageExecutionException类缺少PipelineException导入的问题
- 确保所有Stage相关测试类使用一致的包引用路径
2025-12-04 09:19:56 +08:00
af60cc1540 test(pipeline): 添加 Pipeline 核心功能测试
- 增加 Pipeline 执行流程测试,验证 Stage 和 Hook 的执行顺序
- 添加失败场景测试,确保 Pipeline 在失败时能正确停止并跳过后续阶段
- 实现 Stage 配置外部开关控制测试,验证可选 Stage 的启用与禁用逻辑
- 创建 RecordingContext 类用于记录执行事件,便于断言生命周期钩子调用
- 构建 SimpleStage 和 OptionalStage 测试辅助类,支持自定义执行逻辑与条件判断
2025-12-03 22:15:05 +08:00
60b4473279 refactor(pipeline): 重构人脸匹配管线为核心管线模块
- 移除专用人脸匹配管线实现,统一使用通用管线模块
- 更新所有Stage类继承自通用管线Stage基类
- 调整包路径引用从face.pipeline到pipeline.core
- 修改上下文类实现通用管线上下文接口
- 删除冗余的人脸匹配专用注解和枚举类
- 更新工厂类引用至新的通用管线构建器
- 保持Stage功能逻辑不变仅调整继承结构
2025-12-03 21:47:43 +08:00
ecd5378b26 fix(pipeline): 增加防御性检查避免空指针异常
- 在多个阶段中增加对 memberSourceList 和 searchResult 的空值检查
- 当 memberSourceList 为空时跳过视频重切和购买状态处理逻辑
- 当 searchResult 为空时跳过人脸补救逻辑
- 增加对自定义匹配场景的判断,非该场景则跳过指标记录
- 为各个阶段添加详细的单元测试覆盖各种边界条件
2025-12-03 19:23:54 +08:00
8c08c8947e feat(pipeline): 增强人脸匹配流水线的健壮性
- 在BuildSourceRelationStage中增加sampleListIds空值检查与降级处理
- 在PersistRelationsStage中增加memberSourceList空值检查与提前跳过逻辑
- 为BuildSourceRelationStage、DeleteOldRelationsStage和PersistRelationsStage添加完整的单元测试覆盖
- 实现异常情况下的优雅降级与错误日志记录
- 完善上下文状态管理与阶段跳过机制
2025-12-03 18:49:03 +08:00
b165840176 feat(face): 添加新人脸任务状态设置逻辑及单元测试
- 在SetTaskStatusStage中增加新人脸用户判断逻辑,非新用户跳过任务状态设置
- 新增LoadFaceSamplesStage、SetTaskStatusStage和UpdateFaceResultStage的完整单元测试
- 完善各阶段异常处理和边界条件测试,提升代码健壮性
- 添加大量测试用例覆盖成功、失败、异常等多种执行路径
- 验证任务状态设置、人脸样本加载和识别结果更新的核心功能
2025-12-03 18:41:24 +08:00
71d6400a1e test(pipeline): 添加人脸匹配流水线单元测试
- 为CustomFaceSearchStage添加完整单元测试覆盖各种匹配模式
- 为人脸识别阶段FaceRecognitionStage编写测试用例
- 为上下文准备阶段PrepareContextStage增加测试验证
- 包含成功、失败、异常等边界情况测试
- 验证不同匹配模式下的结果合并逻辑
- 测试人工选择和自动匹配场景的处理差异
2025-12-03 18:27:53 +08:00
b3fa10e8fd fix(pipeline): 增强人脸匹配流水线的健壮性
- 在FilterByTimeRangeStage中增加空值检查和配置验证
- 在LoadMatchedSamplesStage中增加sampleListIds空值检查
- 添加完整的集成测试覆盖Pipeline工厂和Context构建
- 为FilterByDevicePhotoLimitStage添加全面的单元测试
- 为FilterByTimeRangeStage添加边界条件和异常处理测试
- 为LoadMatchedSamplesStage添加异常路径测试
2025-12-03 18:17:34 +08:00
96e75a458f feat(face-pipeline): 实现人脸匹配管线核心框架
- 新增Stage配置注解@StageConfig,支持Stage元数据和可选性控制
- 创建抽象基类AbstractFaceMatchingStage,提供Stage执行模板方法
- 实现FaceMatchingContext上下文类,用于Stage间状态和数据传递
- 构建Pipeline核心执行类,支持Stage动态添加和执行控制
- 添加PipelineBuilder构建器,支持链式组装管线
- 定义PipelineStage接口和StageResult结果类,规范Stage行为
- 新增人脸匹配场景枚举FaceMatchingScene和Stage可选模式枚举
- 创建管线异常类PipelineException和StageExecutionException
- 实现FaceMatchingPipelineFactory工厂类,支持多场景管线组装
- 添加拼图生成编排器PuzzleGenerationOrchestrator,支持异步批量生成
- 创建BuildSourceRelationStage等核心Stage实现类
2025-12-03 18:11:31 +08:00
d2ad14175d feat(printer): 新增打印机大屏人脸识别功能
- 添加根据人脸样本ID查询图像素材接口
- 实现上传人脸照片进行景区人脸识别逻辑
- 集成存储服务用于保存临时人脸图片
- 调用景区人脸识别适配器搜索匹配人脸
- 查询并返回匹配的人脸样本关联图像素材
- 创建人脸识别结果含素材列表的响应对象
2025-12-03 16:44:32 +08:00
06c0ade9b4 feat(scenic): 添加我的页面订单显示配置项
- 在AppScenicController中新增showMyPagePaid和showMyPageUnpaid配置返回
- 在ScenicConfigResp中添加对应的布尔类型字段定义
- 支持控制我的页面是否展示已付款和未付款订单的开关功能
2025-12-03 15:20:40 +08:00
36f85dbb63 feat(device): 支持按多个景区ID查询设备列表
- 在 DeviceV2Client 中新增 scenicIds 查询参数
- 修改 DeviceIntegrationService.listDevices 方法以支持 scenicIds 参数
- 优化参数优先级逻辑:scenicId 优先于 scenicIds
- 更新所有调用点以传递新的 scenicIds 参数
- 保持向后兼容性,确保原有接口行为不变
- 增加日志记录以便调试和监控参数使用情况
2025-12-02 09:39:04 +08:00
9becd6bfa7 fix(order): 修复商品视频URL设置错误并增加pLog类型处理
- 修复商品视频URL字段赋值错误,使用setVideoUrl替代setUrl
- 增加对商品类型为5(pLog)的特殊处理逻辑
- 设置pLog类型商品的默认名称和封面URL
- 确保pLog类型商品信息完整性和一致性
2025-12-01 15:24:04 +08:00
788184fc04 Fix: Remove deprecated CachingConfigurerSupport in CustomRedisCacheManager 2025-12-01 14:54:09 +08:00
3cf7c81162 chore(log): 移除日志中的特殊符号以提升可读性
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 删除了所有日志语句中的  符号
- 删除了所有日志语句中的  符号
- 删除了所有日志语句中的 ⚠️ 符号
- 统一了日志输出格式,使其更简洁清晰
- 保留了关键错误信息的记录方式
- 确保日志信息在不同终端下的一致性显示
2025-12-01 10:55:13 +08:00
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
4360ef1313 feat(device): 实现设备视频连续性检查功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 新增设备视频连续性检查控制器 DeviceVideoContinuityController
- 提供查询、手动触发和删除检查结果的 REST 接口
- 实现视频连续性检查核心逻辑,支持检测视频间隙
- 添加定时任务 DeviceVideoContinuityCheckTask 自动检查设备视频连续性
- 仅在生产环境(prod)启用,每天9点到18点间每5分钟执行一次
- 支持阿里云OSS和本地存储的视频连续性检查
- 检查结果缓存至 Redis,默认保留24小时
- 新增相关实体类: DeviceVideoContinuityCache、VideoContinuityGap、VideoContinuityResult
- 在存储操作接口中增加 checkVideoContinuity 和 checkRecentVideoContinuity 方法
- 为不支持的存储类型提供默认不支持连续性检查的实现
2025-11-24 14:02:53 +08:00
9278d4479f feat(printer): 优化拼图打印偏移处理逻辑
- 添加白边框并向上偏移内容以避免打印机偏移
- 替换原有的单纯向上偏移方法
- 弃用 shiftImageUp 方法,新增 addBorderAndShiftUp 方法
- 更新临时文件命名及清理逻辑
- 修改日志记录内容以反映新的处理方式
2025-11-22 00:07:18 +08:00
18bf51487d feat(printer): 优化拼图打印逻辑并调整日期格式
- 调整AppPuzzleController中recordId参数为固定值0L
- 修改FaceMatchingOrchestrator中的日期格式为"yyyy.MM.dd"
- 完善PrinterServiceImpl水印处理条件判断
- 新增针对sourceId为0时的拼图照片偏移处理逻辑
- 修复重复打印检查逻辑,使用resultImageUrl代替puzzleRecordId比较
- 增强异常处理和日志记录,提升系统稳定性
2025-11-21 23:25:34 +08:00
447e8799e8 refactor(repository): 移除冗余的用户购买状态查询逻辑
- 删除 SourceRepository 中的 getUserIsBuy 方法
- 删除 VideoRepository 中的 getUserIsBuy 方法
- 简化业务逻辑,减少重复代码
- 提高代码可维护性和清晰度
2025-11-21 22:24:32 +08:00
fd130c471f feat(order): 添加未来模板一口价购买逻辑
- 引入 IsBuyBatchRespVO 类以支持批量购买响应
- 实现视频模板买断逻辑,优先检查模板是否已购买
- 在商品类型为0时,查询视频模板并判断用户是否已购买
- 若已购买,直接返回订单ID及购买状态,跳过价格查询
- 保留原有价格查询逻辑作为兜底方案
2025-11-21 22:15:17 +08:00
c47c24a39a refactor(goods): 移除商品列表查询接口及关联逻辑
- 删除 GoodsService 中的 goodsList 接口定义
- 移除 GoodsServiceImpl 中 goodsList 方法的实现
- 清理相关导入语句和无用代码引用
- 简化商品服务模块,聚焦于源素材商品列表功能
2025-11-21 21:52:14 +08:00
97e3ab19a0 refactor(order): 重构订单购买逻辑并优化接口参数
- 调整 isBuy 方法参数顺序,增加 faceId 参数支持
- 删除冗余的购买检查方法和旧版 isBuy 重载方法
- 简化购买状态判断逻辑,移除重复代码
- 更新视频查看权限服务中的购买检查调用
- 修改人脸服务中景区 ID 类型为 Long
- 调整打印机服务中人脸查询方法参数类型
- 统一订单业务类中方法签名和调用方式
- 移除订单请求模型中无用字段注释
- 增加人脸 ID 列表字段支持批量查询
- 优化任务服务中购买状态检查逻辑
2025-11-21 21:45:26 +08:00
5b27cac6b0 feat(service): 优化商品查询逻辑并新增分组查询接口
- 在 SourceMapper 中新增 queryGroupedByFaceAndType 方法,支持按 faceId 和 type 分组查询
- 调整 orderBiz.isBuy 方法的参数顺序,统一调用格式
- 修改 GoodsServiceImpl 中源素材查询逻辑,使用新分组方法减少循环嵌套
- 简化源素材去重及过滤禁用类型的处理流程
- 提前获取景区配置信息,避免重复查询
- 优化代码结构,提升可读性和维护性
2025-11-21 21:43:37 +08:00
91f3632e2b fix(printer): 修复人脸照片统计逻辑
- 在统计人脸照片数量时增加状态过滤条件
- 仅统计状态为0的有效记录
- 避免已删除或无效数据影响统计结果
2025-11-21 21:06:28 +08:00
cd8ae491e2 feat(mobile): 实现基于人脸ID的商品列表查询功能
- 修改AppFaceController中list方法,将scenicId转换为Long类型传递
- 在AppGoodsController中注入FaceService,并在goodsList接口中调用faceService获取人脸列表
- 更新FaceMapper中的listByScenicAndUserId方法签名,统一scenicId参数类型为Long
- GoodsServiceImpl中新增listGoodsByFaceIdList方法,实现根据人脸ID列表查询相关商品逻辑
- 商品查询支持按成片vlog和源素材分类展示,并去重处理
- 优化GoodsService接口,增加listGoodsByFaceIdList方法定义
- OrderMapper.xml
2025-11-21 20:49:05 +08:00
d0d238d31d feat(order): 添加景区全免逻辑处理
- 引入ScenicConfigManager依赖
- 实现景区全免配置判断
- 设置全免订单价格为零
- 更新订单响应状态逻辑
2025-11-21 19:52:13 +08:00
2be30c6eb4 refactor(price): 重构价格购买方法命名以明确用途
- 将 isBuy 方法重命名为 isOnePriceBuy 以准确反映其功能
- 更新所有调用点以使用新的方法名
- 清理未使用的导入包和变量声明
- 移除与一口价购买无关的冗余代码引用
2025-11-21 19:48:54 +08:00
fb82329a88 fix(order): 修复订单购买状态判断逻辑
- 调整isBuy方法参数顺序,确保 memberId 和 scenicId 正确传递
- 在OrderBiz中设置默认buy状态为false,避免空指针异常
- 修改OrderMapper查询条件,增加refund_status=0过滤已退款订单
- 优化face服务中调用isBuy方法时的参数传递逻辑
2025-11-21 19:45:21 +08:00
4f0d6dc44f feat(order): 新增根据人脸ID查询购买记录功能
- 在OrderBiz中增加isBuy方法重载,支持通过人脸ID查询购买记录
- 修改AppPuzzleController中调用参数顺序,适配新方法签名
- 在OrderMapper接口中新增getUserBuyFaceItem方法定义
- 在OrderMapper.xml中实现getUserBuyFaceItem的SQL查询逻辑
- 调整FaceServiceImpl中相关调用逻辑,移除冗余配置获取代码
2025-11-21 19:27:53 +08:00
302b6811c4 feat(puzzle): 优化二维码生成与去重逻辑
- 避免重复上传已存在的微信小程序二维码
- 在去重检测中跳过 dateStr 字段以提高准确性
- 添加文件存在性检查,减少不必要的上传操作
- 记录并返回已存在文件的访问 URL
- 提升 puzzle 服务的性能与资源利用率
2025-11-21 18:11:41 +08:00
c0daa4d3b2 refactor(face): 优化拼图模板内容生成逻辑
- 修改拼图模板内容生成方式,支持多个模板内容生成
- 使用forEach循环处理每个模板,确保内容正确插入
- 保留原有价格计算和购买状态判断逻辑
- 确保contentId在记录存在时正确设置
- 维持原有的商品类型、分组和排序设置
2025-11-21 17:46:28 +08:00
83cfbc67e1 fix(repository): 修复人脸缓存获取逻辑
- 添加空值检查避免解析空字符串
- 提取缓存键避免重复格式化
- 优化缓存命中时的对象转换逻辑
2025-11-21 17:07:29 +08:00
8f918570d9 feat(puzzle): 动态设置拼图名称
- 注入PuzzleTemplateMapper依赖
- 根据模板ID获取拼图模板名称
- 使用模板名称替换硬编码的"三拼图"名称
2025-11-21 17:07:24 +08:00
f4a3dc9cae fix(order): 修复订单支付后商品创建时间获取逻辑
- 删除冗余的商品创建时间计算代码
- 优化订单支付后的统计记录逻辑
- 清理无用的日期比较操作
- 提升代码可读性和维护性
2025-11-21 17:04:12 +08:00
cd5ba23d59 feat(puzzle): 添加模板封面图片字段并更新相关逻辑
- 在PuzzleTemplateDTO和TemplateCreateRequest中新增coverImage字段
- 在PuzzleTemplateEntity中新增coverImage字段并映射到数据库
- 更新FaceServiceImpl以支持获取模板封面图片URL
- 修改Mapper XML文件以支持coverImage字段的读写操作
- 调整SQL查询和插入语句以包含新的coverImage字段
- 更新三拼图内容页面逻辑以使用模板封面图片URL
2025-11-21 16:04:59 +08:00
038b2e6f08 fix(order): 修复订单查询逻辑
- 在查询条件中添加了 goodsId 过滤
- 为避免多余数据返回,增加了 limit 1 限制
- 确保订单项查询的准确性与性能优化
2025-11-21 15:02:26 +08:00
caad0c2cf0 feat(order): 添加plog图商品类型支持
- 在OrderServiceImpl中增加对商品类型5的处理逻辑
- 设置商品名称和订单类型为"plog图"
- 在OrderMapper.xml中新增member_plog_data查询块
- 添加对goods_type为5时coverUrl和imgUrl的映射
- 增加对goods_type为5时商品名称的显示处理
- 新增member_plog_data表的左连接查询条件
2025-11-21 14:40:08 +08:00
259d99bde7 feat(face): 添加购买状态判断逻辑
- 在生成内容时增加对用户是否已购买的判断
- 根据购买状态设置内容的 isBuy 字段
- 调用 orderBiz.isBuy 方法检查购买状态
- 使用 scenicId 和 templateId 作为购买查询条件
2025-11-21 14:03:54 +08:00
0e2122910f feat(face): 新增人脸匹配编排流程中的任务状态管理
- 引入 TaskStatusBiz
2025-11-21 13:56:20 +08:00
e1a77a1614 feat(printer): 移除拼图照片自动裁剪功能
- 删除了从打印机配置获取打印尺寸的逻辑
- 移除了调用ImageUtils.smartCropAndFill进行图片裁剪的代码
- 去掉了裁剪后图片上传和临时文件清理的相关实现
- 简化了打印服务流程,直接使用原始图片URL
- 保留了cropUrl字段但不再进行实际裁剪操作
2025-11-21 11:47:52 +08:00
8791cf5910 fix(printer): 修复上传裁剪图片时的文件扩展名获取逻辑
- 将文件扩展名从resultImageUrl改为croppedFile.getName()中获取
- 确保上传裁剪后图片时能正确识别文件类型
- 避免因URL解析错误导致的文件扩展名丢失问题
2025-11-21 11:47:05 +08:00
a860319ea1 refactor(puzzle): 移除拼图生成记录中的复用逻辑
- 删除 PuzzleGenerationRecordEntity 中的 isDuplicate 和 originalRecordId 字段
- 移除插入记录时设置 isDuplicate 的逻辑
- 删除 FaceMatchingOrchestrator 中查询历史记录的逻辑
- 更新 Mapper XML 文件,移除相关字段和条件判断
- 简化生成流程,不再检查模板是否已生成
2025-11-21 11:41:11 +08:00
d5fc5c2565 feat(scenic): 添加新的景区ID到白名单
- 在白名单中新增景区ID: 4049850382325780480
- 完善景区列表分页查询功能的ID过滤逻辑
2025-11-21 11:02:56 +08:00
0db713b4a8 feat(puzzle): 实现拼图生成去重机制
- 新增内容哈希计算逻辑,基于元素内容生成SHA256哈希用于去重判断
- 添加重复图片检测功能,当所有IMAGE元素使用相同URL时抛出异常
- 实现历史记录查询接口,根据模板ID、内容哈希和景区ID查找重复记录
- 扩展生成响应对象,增加isDuplicate和originalRecordId字段标识复用情况
- 更新数据库实体和Mapper,新增content_hash、is_duplicate等字段支持去重
- 添加完整的单元测试和集成测试,覆盖去重检测、哈希计算等核心逻辑
- 引入DuplicateImageException和PuzzleBizException异常类完善错误处理
2025-11-21 11:02:43 +08:00
6ef710201c fix(order): 修正订单商品名称逻辑并更新购买检查参数
- 修改AppPuzzleController中isBuy方法的参数传递逻辑
- 在OrderServiceImpl中为未知类型添加默认商品名称
- 统一景区相关商品的命名规则
2025-11-21 10:03:10 +08:00
9123a1f6db feat(puzzle): 新增拼图功能模块
- 新增AppPuzzleController控制器,提供拼图相关接口
- 实现根据faceId查询拼图数量和记录列表功能
- 实现根据recordId查询拼图详情和下载拼图资源功能
- 实现拼图价格计算和导入打印列表功能
- 在FaceServiceImpl中集成拼图记录展示逻辑
- 在OrderServiceImpl中新增PHOTO_LOG产品类型处理
- 在PrinterService中实现从拼图添加到打印列表的功能
- 完善拼图记录转换为内容页面VO的逻辑处理
2025-11-20 23:11:04 +08:00
d458f918ed feat(text): 实现文本垂直居中对齐功能
- 修改TextElement类中的Y坐标计算逻辑
- 新增总文本高度计算和垂直偏移量
- 调整起始Y坐标以支持垂直居中对齐
- 保持原有逐行绘制逻辑不变
2025-11-20 23:10:50 +08:00
27e58d36d0 test(puzzle): 更新测试用例以适配新的执行结果结构
- 移除已弃用的 DeviceCountRangeConditionStrategy 策略注册
- 修改 PuzzleElementFillEngine 执行方法调用方式,使用 getDynamicData 获取动态数据
- 在 PuzzleGenerateServiceImplTest 中引入 FillResult 类型并更新 mock 返回值结构
- 统一调整所有相关测试断言逻辑以匹配新返回的数据格式
2025-11-20 23:10:27 +08:00
8c76c85ae2 feat(puzzle): 添加拼图生成记录检查逻辑
- 引入 PuzzleGenerationRecordEntity 和 PuzzleGenerationRecordMapper
- 在人脸匹配编排流程中查询已有拼图生成记录
- 增加模板重复生成判断逻辑,避免重复处理
- 跳过已生成模板并记录日志提示
2025-11-20 17:33:51 +08:00
8991d68673 docs(claude): 更新设备数量匹配策略的描述
- 修改模式1的匹配逻辑为实际机位数大于等于deviceCount
- 修改模式2的匹配逻辑为从指定列表过滤后数量大于等于deviceCount
- 保持配置顺序并只取前N个设备进行匹配
2025-11-20 17:27:31 +08:00
3b93e07a66 feat(fill): 更新机位数量匹配策略为大于等于匹配
- 修改策略注释说明匹配方式由精确匹配改为大于等于匹配
- 更新全局数量匹配逻辑,从 == 改为 >= 判断
- 更新列表数量匹配逻辑,从 == 改为 >= 判断
- 在列表匹配成功时,只取前 N 个机位存入 context.extra
- 调整日志描述,明确显示最小数量与实际数量的比较
- 更新单元测试用例以验证大于等于匹配逻辑
- 增加测试用例验证匹配成功时只取前 N 个机位的行为
- 调整测试用例名称和断言逻辑以适应新的匹配规则
2025-11-20 16:38:08 +08:00
c8054c60ab feat(puzzle): 启用规则匹配以增强拼图生成
- 在拼图生成请求中添加规则匹配选项
- 设置 requireRuleMatch 参数为 true 以启用高级验证
- 确保动态数据映射保持不变
- 保留现有质量与格式设置配置
2025-11-20 15:20:36 +08:00
2fd852c5c6 feat(puzzle): 增强拼图填充引擎功能
- 新增 requireRuleMatch 参数控制是否必须匹配规则
- 重构 DeviceCountConditionStrategy 支持两种匹配模式
- 移除已废弃的 DeviceCountRangeConditionStrategy
- 引入 FillResult 类封装填充结果信息
- 优化条件上下文和数据源上下文的 extra 字段类型
- 更新相关测试用例和文档说明
2025-11-20 15:11:13 +08:00
aaa8d8310a feat(source): 新增根据人脸和设备ID获取素材的功能
- 在SourceMapper接口中新增getSourceByFaceAndDeviceId方法
- 支持通过faceId、deviceId、type和排序策略查询特定素材
- 在XML映射文件中实现对应的SQL查询逻辑
- 支持多种排序策略:最新、最早、评分降序、评分升序、随机和已购买优先
- 查询结果限制为一条记录
2025-11-20 14:55:28 +08:00
8d2d0901fd feat(face): 添加景区名称和日期到动态数据
- 引入日期工具类以支持日期格式化
- 在基础动态数据中增加景区名称字段
- 添加当前日期字符串到基础动态数据
- 为后续模板生成提供更丰富的上下文信息
2025-11-20 13:51:04 +08:00
d1381c93b0 feat(puzzle): 更新拼图元素填充引擎执行方法参数
- 在engine.execute方法调用中增加scenicId参数
- 修改测试用例以适应新的方法签名
- 确保所有相关测试验证逻辑正确性
- 更新PuzzleGenerateServiceImplTest中的fillEngine调用参数
- 调整verify语句匹配新参数列表
- 保持原有功能逻辑不变仅扩展参数传递
2025-11-20 11:41:22 +08:00
536f2866f6 feat(puzzle): 添加统计人脸ID生成记录数量功能
- 在PuzzleGenerationRecordMapper接口中新增countByFaceId方法
- 在PuzzleGenerationRecordMapper.xml中实现对应的SQL查询
- 支持根据faceId统计生成记录的数量
2025-11-20 11:41:09 +08:00
4cbd0dc255 feat(puzzle): 新增微信小程序二维码生成功能
- 在DataSourceContext中新增scenicId字段用于景区关联
- 实现WechatQrcodeDataSourceStrategy策略类,支持生成并上传微信小程序码
- 扩展DataSourceType枚举,增加WECHAT_QRCODE类型
- 修改PuzzleElementFillEngine执行方法,支持传入scenicId参数
- 在PuzzleGenerateServiceImpl中集成二维码自动生成逻辑
- 新增generateWechatQrcode方法用于生成并上传小程序码到OSS
- 完善日志记录和异常处理机制
- 添加必要的工具类和存储服务依赖注入
2025-11-20 11:00:53 +08:00
90cf0d44c9 feat(video-review): 优化视频评价导出功能,支持机位名称动态表头
- 引入DeviceRepository用于批量查询机位名称
- 在导出逻辑中收集并排序机位ID,确保表头顺序一致
- 动态生成Excel表头,使用实际机位名称替代原始JSON字段
- 调整单元格样式以支持自动换行,提升可读性
- 更新mapper配置,关联template表获取模板名称
- 优化列宽自适应逻辑,为机位列设置最小宽度保障显示效果
- 日志记录中增加导出机位数量统计信息
2025-11-20 11:00:29 +08:00
d387f11173 feat(video): 添加视频模板ID和名称字段
- 在VideoReviewRespDTO中新增templateId字段
- 在VideoReviewRespDTO中新增templateName字段
- 添加相应字段的注释说明
- 支持关联查询video表获取模板信息
2025-11-20 10:48:08 +08:00
f6d6a63977 feat(puzzle): 修改生成记录查询逻辑以支持人脸ID
- 将查询条件从 orderId 更改为 faceId
- 更新 Mapper 接口方法名和参数
- 修改 XML 映射文件中的字段和查询条件
- 调整插入记录时使用的字段名称
- 更新基础列定义以反映新的字段结构
2025-11-20 10:48:08 +08:00
67aebd5770 refactor(puzzle): 移除填充规则中的景区ID依赖
- 删除 PuzzleFillRuleDTO、PuzzleFillRuleSaveRequest 和 PuzzleFillRuleEntity 中的 scenicId 字段
- 从 ConditionContext 和 DataSourceContext 中移除 scenicId 属性
- 更新 PuzzleElementFillEngine 的 execute 方法,不再接收和传递 scenicId 参数
- 修改 PuzzleGenerateServiceImpl 中调用填充引擎的逻辑,去除 scenicId 判断和传参
- 调整 PuzzleFillRuleMapper.xml 配置文件,移除 scenic_id 映射关系
- 更新所有相关单元测试用例,删除对 scenicId 的引用和验证
- 简化规则查询方法,由 listByTemplateAndScenic 改为 listByTemplateId
- 移除因缺少 scenicId 而产生的警告日志和特殊处理分支
2025-11-19 23:23:08 +08:00
6d18a770b8 feat(puzzle): 实现人脸匹配后异步生成拼图模板功能
- 移除查询规则时的景区ID参数,简化规则加载逻辑
- 为人脸匹配编排器添加拼图模板服务依赖
- 新增异步生成拼图模板方法,在人脸识别成功后触发
- 优化Mapper接口,添加@Mapper注解并移除冗余查询方法
- 更新文档说明,同步修改规则查询方式描述
- 清理SourceMapper中重复的deleted条件过滤逻辑
2025-11-19 22:48:01 +08:00
b6cbb18a7f docs(puzzle): 更新Claude模块文档结构
- 移除了联系方式和维护者信息
- 简化了文档结尾的元数据部分
- 优化了设备ID匹配策略文档的引用格式
2025-11-19 18:29:45 +08:00
cfb3625ac0 feat(puzzle): 实现智能自动填充引擎和安全增强
- 新增拼图元素自动填充引擎 PuzzleElementFillEngine
- 支持基于规则的条件匹配和数据源解析
- 实现机位数量、机位ID等多维度条件策略
- 添加 DEVICE_IMAGE、USER_AVATAR 等数据源类型支持
- 增加景区隔离校验确保模板使用安全性
- 强化图片下载安全校验,防范 SSRF 攻击
- 支持本地文件路径解析和公网 URL 安全检查
- 完善静态值数据源策略支持 localPath 配置
- 优化生成流程中 faceId 和 scenicId 的校验逻辑
- 补充相关单元测试覆盖核心功能点
2025-11-19 17:28:41 +08:00
cb17ea527b refactor(SourceMapper): 优化查询条件动态拼接逻辑
- 使用<where>标签替换原有静态where条件
- 添加对scenicId、isBuy、type、faceId参数的动态判断
- 确保只有非空参数参与SQL查询条件构建
- 提高SQL语句可读性和维护性
- 避免因缺少条件导致的语法错误风险
2025-11-19 15:08:59 +08:00
625ad910c9 feat(printer): 添加素材打印状态查询功能
- 在PrinterMapper中新增countFacePhoto方法用于统计用户打印素材数量
- 创建GoodsDetailPrintSceneVO类继承GoodsDetailVO并添加inList字段
- 修改GoodsReqQuery类添加scene字段用于标识打印场景
- 在GoodsServiceImpl中注入PrinterMapper并实现打印状态判断逻辑
- 在PrinterMapper.xml中添加对应的SQL查询语句
- 移除BaseContextHandler引入,优化代码依赖关系
2025-11-19 15:08:39 +08:00
778afaaa83 feat(puzzle): 实现拼图自动填充规则引擎及相关功能
- 新增拼图填充规则管理Controller、DTO、Entity等核心类
- 实现条件评估策略模式,支持多种匹配规则
- 实现数据源解析策略模式,支持多种数据来源
- 新增拼图元素自动填充引擎,支持优先级匹配和动态填充
- 在SourceMapper中增加设备统计和查询相关方法
- 在PuzzleGenerateRequest中新增faceId字段用于触发自动填充
- 完善相关枚举类和工具类,提升系统可维护性和扩展性
2025-11-19 11:10:23 +08:00
de421cf0d5 chore(build): 移除跳过测试编译的 Maven 插件配置
- 删除了 maven-compiler-plugin 中跳过测试编译的配置
- 移除了对测试文件排除的设置
- 清理了插件中不必要的 Java 21 预览功能启用参数
2025-11-19 10:16:24 +08:00
3ddf7bd0e9 feat(image): 添加图片180度旋转功能
- 新增rotateImage180方法实现图片180度旋转
- 支持源文件读取和目标文件写入
- 使用AffineTransform实现图像旋转变换
- 保持图片原始尺寸不变
- 添加详细的异常处理和资源释放
- 移除对270度旋转的限制检查
2025-11-18 17:32:04 +08:00
208202ba41 feat(image): 添加水印四边偏移支持
- 在 WatermarkInfo 中新增 offsetTop、offsetBottom、offsetLeft 和 offsetRight 字段
- 在 PrinterDefaultWatermarkOperator 中实现四边偏移逻辑,默认值为 0
- 根据图片方向设置不同的偏移值,横图左偏移 40 像素,竖图下偏移 30 像素
- 调整二维码和文字位置计算方式以应用偏移量
- 优化水印处理流程,确保偏移参数正确传递和使用
2025-11-18 16:27:19 +08:00
6e84a5fd43 fix(printer): 调整二维码边距和图片旋转逻辑
- 修改二维码距离左边缘的图片宽度比例从 0.075 为 0.05
- 修正图片旋转角度判断逻辑,确保横向处理正确
- 移除下载 URL 中的域名替换操作,使用原始地址直接下载
2025-11-18 16:06:19 +08:00
8e48bd92cc feat(pricing): 添加新的产品类型枚举值
- 新增 PHOTO_LOG 类型,表示 pLog 图
- 新增 PHOTO_VLOG 类型,表示 pLog 视频
2025-11-18 15:49:41 +08:00
23181e9f08 fix(video): 完善视频删除逻辑以排除被评价的视频
- 修改删除条件,增加对视频评价关联表的检查
- 确保已被评价的视频不会被误删
- 防止因外键约束导致的删除失败问题
2025-11-18 14:42:20 +08:00
42e806df76 feat(puzzle): 添加批量替换模板元素功能
- 在 PuzzleTemplateController 中新增 replaceElements 接口
- 在 PuzzleElementMapper 中新增 getByTemplateIdAndKey 查询方法
- 在 PuzzleTemplateServiceImpl 中实现 replaceElements 业务逻辑
- 在 IPuzzleTemplateService 接口中定义 replaceElements 方法
- 在 PuzzleElementMapper.xml 中添加对应 SQL 查询语句
2025-11-18 12:47:24 +08:00
a49e581915 fix(printer): 修复打印照片方向检测逻辑
- 修改图片方向判断方式,从文件检测改为读取crop配置中的rotation值
- 添加异常处理机制,确保旋转角度解析失败时能正确抛出异常
- 保持竖图自动旋转为横图的处理逻辑不变
2025-11-18 12:27:18 +08:00
af60e95529 feat(puzzle): 添加模板分页查询功能并优化DTO序列化
- 在PuzzleTemplateController中新增pageTemplates接口支持分页查询
- 为ElementCreateRequest和PuzzleElementDTO添加@JsonProperty注解优化JSON序列化
- 实现PuzzleTemplateServiceImpl中的pageTemplates分页逻辑
- 使用PageHelper实现分页查询并限制最大页面大小为100
- 在IPuzzleTemplateService接口中定义pageTemplates方法签名及文档说明
- 添加参数校验确保page和pageSize的有效性
- 返回PageResponse对象封装分页结果供前端使用
2025-11-18 12:05:21 +08:00
bb2367c5a6 feat(video-review): 支持机位多维度评价功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 新增NestedMapTypeHandler处理嵌套Map与JSON互转
- 修改VideoReview相关实体类和DTO以支持嵌套Map结构
- 更新数据库查询逻辑以适配新的评价数据结构
- 优化平均分计算方法以处理多机位多维度评分
- 完善MyBatis配置中的typeHandler引用
- 补充视频查询接口返回任务开始结束时间字段
- 修正SQL关联查询条件确保数据准确性
2025-11-18 10:14:42 +08:00
3d361200b0 refactor(puzzle): 重构元素DTO及新增元素基类
- 将ElementCreateRequest和PuzzleElementDTO中的elementType从Integer改为String
- 删除所有类型特定字段,新增config和configMap支持JSON配置
- 新增BaseElement抽象基类定义元素通用行为
- 添加ElementConfig接口和具体实现类ImageConfig、TextConfig
- 创建ElementFactory工厂类和ElementRegistrar注册器
- 新增ElementType枚举和ElementValidationException异常类
- 实现ImageElement和TextElement具体元素类
- 添加Position位置信息封装类
2025-11-18 08:13:38 +08:00
5c49a5af9e refactor(video): 重构视频审核服务异常处理
- 将 javax.servlet.http.HttpServletResponse 替换为 jakarta.servlet.http.HttpServletResponse
- 使用 BaseException 替换 BizException 处理业务异常
- 修改视频查询方法 selectById 为 getEntity
- 统一参数校验和用户登录状态检查的异常抛出方式
2025-11-18 00:54:11 +08:00
a5ffb86790 feat(pom): 添加Apache POI依赖以支持Excel文件处理
- 添加poi依赖版本5.4.0
- 添加poi-ooxml依赖版本5.4.0
- 支持Excel文件的读写操作
- 为后续Excel导出功能做准备
- 统一管理POI相关依赖版本
- 更新项目依赖结构
2025-11-18 00:53:55 +08:00
755ba1153e feat(video): 新增视频评价功能及购买状态查询
- 移除TaskController上的@Deprecated注解
- 在VideoController中新增/checkBuyStatus接口用于查询视频购买状态
- 新增VideoReviewController控制器,提供评价管理功能
- 新增MapTypeHandler用于处理Map类型与JSON字段的转换
- 在VideoMapper中增加countBuyRecordByVideoId方法查询视频购买记录
- 新增视频评价相关实体类、DTO及Mapper接口
- 实现VideoReviewService服务类,支持评价新增、分页查询、统计分析和Excel导出
- 在VideoServiceImpl中实现checkVideoBuyStatus方法
- 修改VideoMapper.xml,关联task表并查询task_params字段
- 新增VideoReviewMapper.xml配置文件,实现评价相关SQL查询
2025-11-17 23:37:04 +08:00
ebf05ab189 fix(pricing): 修复优惠券计算逻辑,防止优惠金额溢出
- 重构优惠券折扣计算方法,确保固定金额优惠券不超过适用商品总价
- 修改百分比优惠券计算逻辑,基于适用商品总价而非购物车总价
- 新增适用商品总价计算方法,支持按商品类型过滤
- 添加防止优惠金额超出适用商品总价的保护逻辑
- 完善无商品类型限制时的全商品适用逻辑
- 增加多种边界情况和多SKU场景的单元测试
- 修复百分比优惠券最大折扣限制的计算顺序问题
2025-11-17 17:38:46 +08:00
e2b450682b feat(puzzle): 优化拼图生成逻辑并新增完整测试套件
- 在PuzzleGenerateServiceImpl中改进图片上传逻辑,支持contentType指定
- 在PuzzleImageRenderer中优化背景图片缩放算法,使用原生Java方法提升性能
- 修改scaleImage方法实现,完善多种图片适配模式(COVER、CONTAIN、FILL等)
- 新增PuzzleRealScenarioIntegrationTest集成测试类,覆盖真实业务场景
- 添加PuzzleTemplateServiceImplTest单元测试,使用Mockito模拟数据库交互
- 创建MockImageUtil工具类,支持测试过程中生成各类模拟图片
- 构建PuzzleTestDataBuilder测试数据构造器,简化测试模板和元素创建
- 增加RealScenarioTestHelper辅助类,提升测试代码复用性
-
2025-11-17 16:50:53 +08:00
443f92ff92 feat(puzzle): 实现拼图生成功能模块
- 新增拼图生成控制器 PuzzleGenerateController,支持 /api/puzzle/generate 接口
- 新增拼图模板管理控制器 PuzzleTemplateController,提供完整的 CRUD 和元素管理功能
- 定义拼图相关 DTO 类,包括模板、元素、生成请求与响应等数据传输对象
- 创建拼图相关的实体类 PuzzleTemplateEntity、PuzzleElementEntity 和 PuzzleGenerationRecordEntity
- 实现 Mapper 接口用于数据库操作,支持模板和元素的增删改查及生成记录管理
- 开发 PuzzleGenerateServiceImpl 服务,完成从模板渲染到图片上传的完整流程
- 提供 PuzzleTemplateServiceImpl 服务,实现模板及其元素的全生命周期管理
- 引入 PuzzleImageRenderer 工具类负责图像合成渲染逻辑
- 支持将生成结果上传至 OSS 并记录生成过程的日志和元数据
2025-11-17 12:54:56 +08:00
630d344b5a refactor(order): 移除冗余的用户ID参数
- 删除了 OrderBiz 中 listImageByFaceRelation 方法调用时多余的 memberId 参数
- 更新了 SourceRepository 中 listVideoByFaceRelation 和 listImageByFaceRelation
  方法的参数列表,移除了不再需要的 userId 参数
- 简化了 AppScenicServiceImpl 中 ScenicAppVO 对象的属性设置逻辑,去除了部分字段赋值
2025-11-17 11:12:08 +08:00
9eb3fd3e58 refactor(controller): 调整 RenderWorkerWithStatusDTO 包路径
- 将 RenderWorkerWithStatusDTO 从 controller.dto 包移动到 dto 包
- 更新了相关的 import 路径引用
- 保持类的功能和接口不变
2025-11-17 11:07:02 +08:00
3463dcc9ae Merge branch 'remove_face_member_limit' 2025-11-17 10:42:53 +08:00
d408c47963 feat(mobile): 增强人脸数据访问控制并优化订单查询逻辑
- 在删除人脸数据前增加用户权限校验,确保操作安全
- 移除订单详情接口中的用户身份强制绑定,简化查询流程
- 更新视频与图片资源查询方法,去除冗余的用户ID参数
- 调整Mapper层SQL语句,解耦人脸关联数据对用户的依赖
- 优化服务层代码结构,提升数据获取效率与一致性
2025-11-17 10:06:32 +08:00
9d708ae20c fix(voucher): 移除人脸验证中的用户ID检查
- 删除了人脸验证中不必要的用户ID匹配检查
- 简化了自动领取凭证的逻辑流程
- 保留了景区ID的设置以确保业务连续性
2025-11-17 01:02:31 +08:00
392 changed files with 38991 additions and 1599 deletions

35
pom.xml
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>
@@ -267,11 +273,30 @@
<version>5.0.0</version>
</dependency>
<!-- 智谱AI SDK -->
<dependency>
<groupId>ai.z.openapi</groupId>
<artifactId>zai-sdk</artifactId>
<version>0.1.3</version>
</dependency>
<!-- Spring Kafka -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- Apache POI - 处理Excel文件 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.4.0</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.4.0</version>
</dependency>
</dependencies>
<build>
@@ -299,16 +324,6 @@
<skip>${skipTests}</skip>
</configuration>
</plugin>
<!-- 跳过测试编译 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<testExcludes>
<testExclude>**/*Test.java</testExclude>
</testExcludes>
</configuration>
</plugin>
</plugins>
</build>

View File

@@ -1,6 +1,5 @@
package com.ycwl.basic;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@@ -9,8 +8,6 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@MapperScan(basePackages = "com.ycwl.basic.mapper")
@MapperScan(basePackages = "com.ycwl.basic.*.mapper")
public class Application {
public static void main(String[] args) {

View File

@@ -1,10 +1,12 @@
package com.ycwl.basic.biz;
import com.ycwl.basic.enums.StatisticEnum;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.OrderMapper;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.mapper.StatisticsMapper;
import com.ycwl.basic.mapper.VideoMapper;
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.order.PriceObj;
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
@@ -145,6 +147,21 @@ public class OrderBiz {
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
priceObj.setFaceId(goodsId);
break;
case 13:
PriceCalculationRequest aiCamCalculationRequest = new PriceCalculationRequest();
ProductItem aiCamProductItem = new ProductItem();
aiCamProductItem.setProductType(ProductType.AI_CAM_PHOTO_SET);
aiCamProductItem.setProductId(scenicId.toString());
aiCamProductItem.setPurchaseCount(1);
aiCamProductItem.setScenicId(scenicId.toString());
aiCamCalculationRequest.setProducts(Collections.singletonList(aiCamProductItem));
aiCamCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
PriceCalculationResult aiCamPriceCalculationResult = iPriceCalculationService.calculatePrice(aiCamCalculationRequest);
priceObj.setPrice(aiCamPriceCalculationResult.getFinalAmount());
priceObj.setSlashPrice(aiCamPriceCalculationResult.getOriginalAmount());
priceObj.setFaceId(goodsId);
priceObj.setScenicId(scenicId);
break;
}
return priceObj;
}
@@ -160,90 +177,46 @@ public class OrderBiz {
return null;
}
}
public IsBuyRespVO isBuy(Long userId, Long scenicId, int goodsType, Long goodsId) {
public IsBuyRespVO isBuy(Long scenicId, Long memberId, Long faceId, int goodsType, Long goodsId) {
IsBuyRespVO respVO = new IsBuyRespVO();
boolean isBuy = orderRepository.checkUserBuyItem(userId, goodsType, goodsId);
// 模板购买逻辑
if (!isBuy) {
if (goodsType == 0) {
VideoEntity video = videoRepository.getVideo(goodsId);
if (video == null) {
respVO.setGoodsType(goodsType);
respVO.setGoodsId(goodsId);
OrderEntity orderEntity = orderMapper.getUserBuyFaceItem(memberId, faceId, goodsType, goodsId);
if (orderEntity != null) {
respVO.setOrderId(orderEntity.getId());
respVO.setBuy(true);
respVO.setFree(false);
return respVO;
}
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
if (Boolean.TRUE.equals(scenicConfig.getBoolean("all_free"))) {
// 景区全免
respVO.setFree(true);
respVO.setOrigPrice(BigDecimal.ZERO);
respVO.setSlashPrice(BigDecimal.ZERO);
return respVO;
}
// 未来模板一口价
if (goodsType == 0) {
// 视频,可以买断模板
VideoEntity video = videoRepository.getVideo(goodsId);
if (video != null && video.getTemplateId() != null) {
OrderEntity templateBuy = orderMapper.getUserBuyFaceItem(memberId, faceId, -1, video.getTemplateId());
if (templateBuy != null) {
respVO.setOrderId(templateBuy.getId());
respVO.setBuy(true);
respVO.setFree(false);
return respVO;
}
TaskEntity task = videoTaskRepository.getTaskById(video.getTaskId());
Long templateId = video.getTemplateId();
// -1为整个模板购买
OrderEntity orderEntity = orderRepository.getUserBuyItem(userId, -1, templateId);
if (orderEntity != null && task != null) {
respVO.setOrderId(orderEntity.getId());
if (orderEntity.getFaceId() != null && task.getFaceId() != null) {
isBuy = orderEntity.getFaceId().equals(task.getFaceId());
}
}
}
}
// 免费送逻辑,之前已经赠送了的
if (!isBuy) {
isBuy = switch (goodsType) {
case 0 -> videoRepository.getUserIsBuy(userId, goodsId);
case 1, 2 -> sourceRepository.getUserIsBuy(userId, goodsType, goodsId);
default -> false;
};
} else {
OrderEntity orderEntity = orderRepository.getUserBuyItem(userId, goodsType, goodsId);
if (orderEntity != null) {
respVO.setOrderId(orderEntity.getId());
}
}
respVO.setBuy(isBuy);
// 还是没买
if (!isBuy) {
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId);
if (priceObj == null) {
return respVO;
}
FaceEntity face = faceRepository.getFace(priceObj.getFaceId());
respVO.setShare(true);
if (face != null && face.getMemberId().equals(userId)) {
respVO.setShare(false);
}
respVO.setFree(priceObj.isFree());
respVO.setGoodsType(goodsType);
respVO.setGoodsId(goodsId);
respVO.setOrigPrice(priceObj.getPrice());
respVO.setSlashPrice(priceObj.getSlashPrice());
switch (goodsType) {
case 0: // vlog
VideoEntity video = videoRepository.getVideo(goodsId);
TaskEntity taskById = videoTaskRepository.getTaskById(video.getTaskId());
if (taskById != null) {
CouponRecordQueryResp recordQueryResp = couponBiz.queryUserCouponRecord(scenicId, userId, taskById.getFaceId(), taskById.getTemplateId().toString());
if (recordQueryResp.isUsable()) {
respVO.setCouponId(recordQueryResp.getCouponId());
respVO.setCouponRecordId(recordQueryResp.getId());
CouponEntity coupon = recordQueryResp.getCoupon();
if (coupon != null) {
respVO.setCouponPrice(coupon.calculateDiscountPrice(priceObj.getPrice()));
}
}
}
break;
case 1:
case 2:
CouponRecordQueryResp recordQueryResp = couponBiz.queryUserCouponRecord(scenicId, userId, goodsId, String.valueOf(goodsType));
if (recordQueryResp.isUsable()) {
respVO.setCouponId(recordQueryResp.getCouponId());
respVO.setCouponRecordId(recordQueryResp.getId());
CouponEntity coupon = recordQueryResp.getCoupon();
if (coupon != null) {
respVO.setCouponPrice(coupon.calculateDiscountPrice(priceObj.getPrice()));
}
}
break;
}
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId);
if (priceObj == null) {
return respVO;
}
respVO.setBuy(false);
respVO.setOrigPrice(priceObj.getPrice());
respVO.setSlashPrice(priceObj.getSlashPrice());
return respVO;
}
@@ -257,13 +230,20 @@ public class OrderBiz {
orderRepository.updateOrder(orderId, orderUpdate);
orderItems.forEach(item -> {
switch (item.getGoodsType()) {
case -1: // vlog视频模板
videoRepository.setUserIsBuyTemplate(order.getMemberId(), item.getGoodsId(), order.getId(), order.getFaceId());
break;
case 0: // vlog视频
videoRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
break;
case 1: // 视频原素材
case 2: // 照片原素材
case 13: // AI微单
sourceRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId(), order.getId());
break;
case 3:
printerService.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
break;
}
});
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
@@ -271,38 +251,6 @@ public class OrderBiz {
if (couponRecordId != null) {
couponBiz.userUseCoupon(order.getMemberId(), order.getFaceId(), couponRecordId, orderId);
}
//支付时间
OrderAppRespVO orderDetail = orderMapper.appDetail(orderId);
Date payAt = orderDetail.getPayAt();
//商品创建时间
Date goodsCreateTime = new Date();
if (!orderDetail.getOrderItemList().isEmpty()) {
OrderItemVO orderItemVO = orderDetail.getOrderItemList().getFirst();
switch (orderItemVO.getGoodsType()) {
case 0:
VideoEntity video = videoRepository.getVideo(orderItemVO.getGoodsId());
if (video != null) {
goodsCreateTime = video.getCreateTime();
}
break;
case 1:
List<SourceEntity> imageSource = sourceMapper.listImageByFaceRelation(order.getMemberId(), orderItemVO.getGoodsId());
Optional<SourceEntity> min = imageSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
if (min.isPresent()) {
goodsCreateTime = min.get().getCreateTime();
}
break;
case 2:
List<SourceEntity> videoSource = sourceMapper.listImageByFaceRelation(order.getMemberId(), orderItemVO.getGoodsId());
Optional<SourceEntity> minTime = videoSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
if (minTime.isPresent()) {
goodsCreateTime = minTime.get().getCreateTime();
}
break;
}
}
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
statisticsRecordAddReq.setMemberId(order.getMemberId());
Long enterType = statisticsMapper.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());
@@ -330,9 +278,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); // 更新完了,清理下
@@ -352,9 +302,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); // 更新完了,清理下
@@ -362,10 +314,14 @@ public class OrderBiz {
}
/**
* 检查用户是否购买了指定商品
* 提供给PriceBiz使用,避免循环调用
* 检查用户是否购买了指定商品,并额外校验订单的faceId是否匹配
* @param userId 用户ID
* @param faceId 人脸ID
* @param goodsType 商品类型
* @param goodsId 商品ID
* @return 是否已购买且faceId匹配
*/
public boolean checkUserBuyItem(Long userId, int goodsType, Long goodsId) {
return orderRepository.checkUserBuyItem(userId, goodsType, goodsId);
public boolean checkUserBuyFaceItem(Long userId, Long faceId, int goodsType, Long goodsId) {
return orderRepository.checkUserBuyFaceItem(userId, faceId, goodsType, goodsId);
}
}

View File

@@ -1,19 +1,20 @@
package com.ycwl.basic.biz;
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
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.pricing.entity.PriceOnePriceConfig;
import com.ycwl.basic.pricing.service.IOnePricePurchaseService;
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;
@@ -29,7 +30,6 @@ import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Component
@@ -51,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<>();
@@ -72,6 +76,117 @@ public class PriceBiz {
goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
}
}
// 拼图
puzzleTemplateMapper.list(scenicId, null, 1).forEach(puzzleTemplate -> {
GoodsListRespVO goods = new GoodsListRespVO();
goods.setGoodsId(puzzleTemplate.getId());
goods.setGoodsName(puzzleTemplate.getName());
goods.setGoodsType(5);
goodsList.add(goods);
});
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 "AI_CAM_PHOTO_SET":
// 返回固定的照片集条目
goodsList.add(new SimpleGoodsRespVO(scenicId, "AI微单照片集", productType));
break;
case "PHOTO_LOG":
// 从 template 表查询pLog模板
goodsList.add(new SimpleGoodsRespVO(scenicId, "pLog图<景区打包>", productType));
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;
}
@@ -95,7 +210,7 @@ public class PriceBiz {
}).collect(Collectors.toList());
}
public IsBuyBatchRespVO isBuy(Long userId, Long faceId, Long scenicId, Integer type, String goodsIds) {
public IsBuyBatchRespVO isOnePriceBuy(Long userId, Long faceId, Long scenicId, Integer type, String goodsIds) {
IsBuyBatchRespVO respVO = new IsBuyBatchRespVO();
PriceConfigEntity priceConfig = priceRepository.getPriceConfigByScenicTypeGoods(scenicId, type, goodsIds);
if (priceConfig == null) {
@@ -166,7 +281,7 @@ public class PriceBiz {
allContentsPurchased = false;
break;
}
boolean hasPurchasedTemplate = orderBiz.checkUserBuyItem(userId, -1, videoEntities.getFirst().getVideoId());
boolean hasPurchasedTemplate = orderBiz.checkUserBuyFaceItem(userId, faceId, -1, videoEntities.getFirst().getVideoId());
if (!hasPurchasedTemplate) {
allContentsPurchased = false;
break;
@@ -178,7 +293,7 @@ public class PriceBiz {
if (scenicConfig != null) {
// 检查录像集
if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceVideo())) {
boolean hasPurchasedRecording = orderBiz.checkUserBuyItem(userId, 1, faceId);
boolean hasPurchasedRecording = orderBiz.checkUserBuyFaceItem(userId, faceId, 1, faceId);
if (!hasPurchasedRecording) {
allContentsPurchased = false;
}
@@ -186,7 +301,7 @@ public class PriceBiz {
// 检查照片集
if (allContentsPurchased && !Boolean.TRUE.equals(scenicConfig.getDisableSourceImage())) {
boolean hasPurchasedPhoto = orderBiz.checkUserBuyItem(userId, 2, faceId);
boolean hasPurchasedPhoto = orderBiz.checkUserBuyFaceItem(userId, faceId, 2, faceId);
if (!hasPurchasedPhoto) {
allContentsPurchased = false;
}

View File

@@ -1,34 +1,26 @@
package com.ycwl.basic.config;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
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.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* @author wenshijia
* @date 2021年07月05日 18:34
* 修改redis缓存序列化器
*/
@Configuration
@EnableCaching
public class CustomRedisCacheManager extends CachingConfigurerSupport {
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig();
configuration = configuration.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer)).entryTtl(Duration.ofMinutes(1));
return configuration;
}
public class CustomRedisCacheManager {
@Autowired
private ObjectMapper objectMapper;
/**
* 处理redis连接工具显示redis key值显示乱码问题,value值没处理
@@ -45,10 +37,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

@@ -3,6 +3,7 @@ package com.ycwl.basic.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -11,6 +12,15 @@ import org.springframework.context.annotation.Configuration;
* @date 2021年06月04日 9:42
*/
@Configuration
@MapperScan(basePackages = {
"com.ycwl.basic.mapper",
"com.ycwl.basic.order.mapper",
"com.ycwl.basic.pricing.mapper",
"com.ycwl.basic.product.mapper",
"com.ycwl.basic.profitsharing.mapper",
"com.ycwl.basic.puzzle.mapper",
"com.ycwl.basic.stats.mapper"
})
public class MybatisPlusPageConfig {
/* 旧版本配置

View File

@@ -16,7 +16,12 @@ public enum SourceType {
/**
* 图片类型
*/
IMAGE(2, "图片");
IMAGE(2, "图片"),
/**
* AI微单类型
*/
AI_CAM(3, "AI微单");
private final int code;
private final String description;
@@ -68,4 +73,14 @@ public enum SourceType {
public static boolean isImage(Integer code) {
return code != null && code == IMAGE.code;
}
/**
* 判断给定的代码是否为AI微单类型
*
* @param code 类型代码
* @return true-是AI微单,false-不是AI微单
*/
public static boolean isAiCam(Integer code) {
return code != null && code == AI_CAM.code;
}
}

View File

@@ -0,0 +1,94 @@
package com.ycwl.basic.controller;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewStatisticsRespDTO;
import com.ycwl.basic.service.VideoReviewService;
import com.ycwl.basic.utils.ApiResponse;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URLEncoder;
/**
* 视频评价Controller
* 管理端使用,通过token角色控制权限
*/
@Slf4j
@RestController
@RequestMapping("/api/video-review/v1")
public class VideoReviewController {
@Autowired
private VideoReviewService videoReviewService;
/**
* 新增视频评价
*
* @param reqDTO 评价信息
* @return 评价ID
*/
@PostMapping("/add")
public ApiResponse<Long> addReview(@RequestBody VideoReviewAddReqDTO reqDTO) {
log.info("新增视频评价,videoId: {}", reqDTO.getVideoId());
Long reviewId = videoReviewService.addReview(reqDTO);
return ApiResponse.success(reviewId);
}
/**
* 分页查询评价列表
*
* @param reqDTO 查询条件
* @return 分页结果
*/
@GetMapping("/list")
public ApiResponse<PageInfo<VideoReviewRespDTO>> getReviewList(VideoReviewListReqDTO reqDTO) {
log.info("查询视频评价列表,pageNum: {}, pageSize: {}", reqDTO.getPageNum(), reqDTO.getPageSize());
PageInfo<VideoReviewRespDTO> pageInfo = videoReviewService.getReviewList(reqDTO);
return ApiResponse.success(pageInfo);
}
/**
* 获取评价统计数据
*
* @return 统计结果
*/
@GetMapping("/statistics")
public ApiResponse<VideoReviewStatisticsRespDTO> getStatistics() {
log.info("获取视频评价统计数据");
VideoReviewStatisticsRespDTO statistics = videoReviewService.getStatistics();
return ApiResponse.success(statistics);
}
/**
* 导出评价数据到Excel
*
* @param reqDTO 查询条件
* @param response HTTP响应
*/
@GetMapping("/export")
public void exportReviews(VideoReviewListReqDTO reqDTO, HttpServletResponse response) {
log.info("导出视频评价数据");
try {
// 设置响应头
String fileName = "video_reviews_" + System.currentTimeMillis() + ".xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"));
// 导出数据
videoReviewService.exportReviews(reqDTO, response.getOutputStream());
response.getOutputStream().flush();
} catch (IOException e) {
log.error("导出视频评价数据失败", e);
throw new RuntimeException("导出失败: " + e.getMessage());
}
}
}

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.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.pipeline.core.Pipeline;
import com.ycwl.basic.pipeline.core.PipelineBuilder;
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,81 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.model.jwt.JwtInfo;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
import com.ycwl.basic.service.mobile.AppAiCamService;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* AI相机相关接口
*/
@Slf4j
@RestController
@RequestMapping("/api/mobile/ai_cam/v1")
@RequiredArgsConstructor
public class AppAiCamController {
private final AppAiCamService appAiCamService;
/**
* 根据faceId获取AI相机识别到的商品列表
* @param faceId 人脸ID
* @return 商品详情列表
*/
@GetMapping("/{faceId}/content")
public ApiResponse<List<GoodsDetailVO>> getAiCamGoods(@PathVariable Long faceId) {
try {
List<GoodsDetailVO> goods = appAiCamService.getAiCamGoodsByFaceId(faceId);
return ApiResponse.success(goods);
} catch (Exception e) {
log.error("获取AI相机商品失败: faceId={}", faceId, e);
return ApiResponse.fail("获取商品列表失败");
}
}
/**
* 批量添加会员与source的关联关系
* @param faceId 人脸ID
* @param sourceIds source ID列表
* @return 添加结果
*/
@PostMapping("/{faceId}/relations")
public ApiResponse<String> addMemberSourceRelations(
@PathVariable Long faceId,
@RequestBody List<Long> sourceIds
) {
try {
int count = appAiCamService.addMemberSourceRelations(faceId, sourceIds);
return ApiResponse.success("成功添加" + count + "条关联记录");
} catch (IllegalArgumentException e) {
log.warn("添加关联失败: faceId={}, error={}", faceId, e.getMessage());
return ApiResponse.fail(e.getMessage());
} catch (Exception e) {
log.error("添加关联失败: faceId={}", faceId, e);
return ApiResponse.fail("添加关联失败");
}
}
/**
* 使用人脸样本创建或获取Face记录
* @param faceSampleId 人脸样本ID
* @return 人脸识别响应
*/
@GetMapping("/useSample/{faceSampleId}")
public ApiResponse<FaceRecognizeResp> useSample(@PathVariable Long faceSampleId) {
try {
JwtInfo worker = JwtTokenUtil.getWorker();
FaceRecognizeResp resp = appAiCamService.useSample(worker.getUserId(), faceSampleId);
return ApiResponse.success(resp);
} catch (Exception e) {
log.error("使用人脸样本失败: faceSampleId={}", faceSampleId, e);
return ApiResponse.fail("使用人脸样本失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,98 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.model.jwt.JwtInfo;
import com.ycwl.basic.model.mobile.chat.*;
import com.ycwl.basic.service.mobile.FaceChatService;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
import java.util.concurrent.CompletableFuture;
/**
* 小程序人脸智能聊天接口。
*/
@Slf4j
@RestController
@RequestMapping("/api/mobile/chat/v1")
@RequiredArgsConstructor
public class AppChatController {
private final FaceChatService faceChatService;
/**
* 获取或创建会话(同一人脸只保留一条)。
*/
@PostMapping("/faces/{faceId}/conversation")
public ApiResponse<ChatConversationVO> createConversation(@PathVariable Long faceId) {
JwtInfo worker = JwtTokenUtil.getWorker();
ChatConversationVO vo = faceChatService.getOrCreateConversation(faceId, worker.getUserId());
return ApiResponse.success(vo);
}
/**
* 同步发送消息,适用于短回复或前端自行轮询。
*/
@PostMapping("/conversations/{conversationId}/messages")
public ApiResponse<ChatSendMessageResp> sendMessage(@PathVariable Long conversationId,
@RequestBody ChatSendMessageReq req) {
JwtInfo worker = JwtTokenUtil.getWorker();
ChatSendMessageResp resp = faceChatService.sendMessage(conversationId, worker.getUserId(),
req.getContent(), req.getTraceId());
return ApiResponse.success(resp);
}
/**
* 流式返回,使用 HTTP chunked。小程序侧用 wx.request 的 onChunkReceived 消费。
*/
@PostMapping(value = "/conversations/{conversationId}/messages/stream", produces = "text/plain;charset=UTF-8")
public ResponseBodyEmitter streamMessage(@PathVariable Long conversationId,
@RequestBody ChatSendMessageReq req) {
JwtInfo worker = JwtTokenUtil.getWorker();
ResponseBodyEmitter emitter = new ResponseBodyEmitter(30_000L);
CompletableFuture.runAsync(() -> {
try {
faceChatService.sendMessageStream(
conversationId,
worker.getUserId(),
req.getContent(),
req.getTraceId(),
chunk -> {
try {
emitter.send(chunk, new MediaType("text", "plain", java.nio.charset.StandardCharsets.UTF_8));
} catch (Exception e) {
emitter.completeWithError(e);
}
});
emitter.complete();
} catch (Exception e) {
log.error("streamMessage error", e);
emitter.completeWithError(e);
}
});
return emitter;
}
/**
* 查询历史消息,cursor 为最后一条 seq,limit 为条数。
*/
@GetMapping("/conversations/{conversationId}/messages")
public ApiResponse<ChatMessagePageResp> listMessages(@PathVariable Long conversationId,
@RequestParam(value = "cursor", required = false) Integer cursor,
@RequestParam(value = "limit", required = false) Integer limit) {
JwtInfo worker = JwtTokenUtil.getWorker();
ChatMessagePageResp resp = faceChatService.listMessages(conversationId, cursor, limit, worker.getUserId());
return ApiResponse.success(resp);
}
@PostMapping("/conversations/{conversationId}/close")
public ApiResponse<String> closeConversation(@PathVariable Long conversationId) {
JwtInfo worker = JwtTokenUtil.getWorker();
faceChatService.closeConversation(conversationId, worker.getUserId());
return ApiResponse.success("OK");
}
}

View File

@@ -1,13 +1,16 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.model.jwt.JwtInfo;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.face.FaceStatusResp;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.model.mobile.face.FaceRecognitionUpdateReq;
import com.ycwl.basic.model.mobile.face.FaceRecognitionDetailVO;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
@@ -28,6 +31,8 @@ public class AppFaceController {
@Autowired
private FaceService faceService;
@Autowired
private FaceRepository faceRepository;
/**
* 1、上传人脸照片
@@ -54,7 +59,7 @@ public class AppFaceController {
public ApiResponse<List<FaceRespVO>> list(@PathVariable("scenicId") String scenicId) {
JwtInfo worker = JwtTokenUtil.getWorker();
Long userId = worker.getUserId();
List<FaceRespVO> list = faceService.listByUser(userId, scenicId);
List<FaceRespVO> list = faceService.listByUser(userId, Long.parseLong(scenicId));
return ApiResponse.success(list);
}
@@ -65,6 +70,18 @@ public class AppFaceController {
@DeleteMapping("/{faceId}")
public ApiResponse<String> deleteFace(@PathVariable("faceId") Long faceId) {
// 添加权限检查:验证当前用户是否拥有该 face
JwtInfo worker = JwtTokenUtil.getWorker();
Long userId = worker.getUserId();
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
throw new BaseException("人脸数据不存在");
}
if (!face.getMemberId().equals(userId)) {
throw new BaseException("无权删除此人脸");
}
return faceService.deleteFace(faceId);
}

View File

@@ -4,7 +4,9 @@ import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.exception.CheckTokenException;
import com.ycwl.basic.model.jwt.JwtInfo;
import com.ycwl.basic.model.mobile.goods.*;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
import com.ycwl.basic.service.mobile.GoodsService;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
@@ -24,11 +26,17 @@ public class AppGoodsController {
@Autowired
private GoodsService goodsService;
@Autowired
private FaceService faceService;
// 商品列表
@PostMapping("/goodsList")
public ApiResponse<List<GoodsPageVO>> goodsList(@RequestBody GoodsReqQuery query) {
return goodsService.goodsList(query);
JwtInfo worker = JwtTokenUtil.getWorker();
Long userId = worker.getUserId();
List<FaceRespVO> faceRespVOS = faceService.listByUser(userId, query.getScenicId());
List<Long> faceIds = faceRespVOS.stream().map(FaceRespVO::getId).toList();
return goodsService.listGoodsByFaceIdList(faceIds, query.getIsBuy(), query.getScenicId());
}
// 源素材(原片/照片)商品列表

View File

@@ -93,9 +93,9 @@ public class AppOrderController {
}
@GetMapping("/scenic/{scenicId}/query")
public ApiResponse<IsBuyRespVO> isBuy(@PathVariable("scenicId") Long scenicId, @RequestParam("type") Integer type, @RequestParam("goodsId") Long goodsId) {
public ApiResponse<IsBuyRespVO> isBuy(@PathVariable("scenicId") Long scenicId, @RequestParam("type") Integer type, @RequestParam("goodsId") Long goodsId, @RequestParam(value = "faceId", required = false) Long faceId) {
Long userId = Long.parseLong(BaseContextHandler.getUserId());
return ApiResponse.success(orderBiz.isBuy(userId, scenicId, type, goodsId));
return ApiResponse.success(orderBiz.isBuy(scenicId, userId, faceId, type, goodsId));
}
@GetMapping("/scenic/{scenicId}/queryBatchPrice")
@@ -108,7 +108,7 @@ public class AppOrderController {
}
faceId = lastFaceByUserId.getId();
}
IsBuyBatchRespVO buy = priceBiz.isBuy(userId, faceId, scenicId, type, goodsIds);
IsBuyBatchRespVO buy = priceBiz.isOnePriceBuy(userId, faceId, scenicId, type, goodsIds);
if (buy == null) {
return ApiResponse.fail("该套餐暂未开放购买");
}

View File

@@ -29,6 +29,7 @@ import com.ycwl.basic.order.dto.PaymentParamsResponse;
import com.ycwl.basic.order.dto.PaymentCallbackResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
@@ -55,6 +56,7 @@ public class AppOrderV2Controller {
private final VideoTaskRepository videoTaskRepository;
private final TemplateRepository templateRepository;
private final VideoRepository videoRepository;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 移动端价格计算
@@ -86,7 +88,7 @@ public class AppOrderV2Controller {
TaskEntity task = videoTaskRepository.getTaskById(video.getTaskId());
request.setFaceId(task.getFaceId());
}
case RECORDING_SET, PHOTO_SET -> request.setFaceId(Long.valueOf(productItem.getProductId()));
case RECORDING_SET, PHOTO_SET, AI_CAM_PHOTO_SET -> request.setFaceId(Long.valueOf(productItem.getProductId()));
}
}
@@ -119,6 +121,14 @@ public class AppOrderV2Controller {
Integer count = sourceMapper.countUser(sourceReqQuery);
product.setQuantity(count);
break;
case AI_CAM_PHOTO_SET:
SourceReqQuery aiPhotoSetReqQuery = new SourceReqQuery();
aiPhotoSetReqQuery.setMemberId(currentUserId);
aiPhotoSetReqQuery.setType(13);
aiPhotoSetReqQuery.setFaceId(face.getId());
Integer _count = sourceMapper.countUser(aiPhotoSetReqQuery);
product.setQuantity(_count);
break;
default:
log.warn("未知的商品类型,跳过重复购买检查: productType={}", product.getProductType());
break;
@@ -262,36 +272,21 @@ public class AppOrderV2Controller {
}
/**
* 用户查询自己的订单详情
* 查询订单详情
*/
@GetMapping("/detail/{orderId}")
public ApiResponse<OrderV2DetailResponse> getUserOrderDetail(@PathVariable("orderId") Long orderId) {
String currentUserIdStr = BaseContextHandler.getUserId();
if (currentUserIdStr == null) {
log.warn("用户未登录");
return ApiResponse.fail("用户未登录");
}
Long currentUserId = Long.valueOf(currentUserIdStr);
log.info("用户查询订单详情: userId={}, orderId={}", currentUserId, orderId);
log.info("查询订单详情: orderId={}", orderId);
try {
OrderV2DetailResponse detail = orderService.getOrderDetail(orderId);
if (detail == null) {
return ApiResponse.fail("订单不存在");
}
// 验证订单是否属于当前用户
if (!currentUserId.equals(detail.getMemberId())) {
log.warn("用户尝试访问他人订单: userId={}, orderId={}, orderOwner={}",
currentUserId, orderId, detail.getMemberId());
return ApiResponse.fail("无权访问该订单");
}
return ApiResponse.success(detail);
} catch (Exception e) {
log.error("查询用户订单详情失败: userId={}, orderId={}", currentUserId, orderId, e);
log.error("查询订单详情失败: orderId={}", orderId, e);
return ApiResponse.fail("查询失败:" + e.getMessage());
}
}
@@ -356,4 +351,9 @@ public class AppOrderV2Controller {
return "FAIL";
}
}
@GetMapping("/downloadable/{orderId}")
public ApiResponse<Boolean> getDownloadableOrder(@PathVariable("orderId") Long orderId) {
return ApiResponse.success(!redisTemplate.hasKey("order_content_not_downloadable_" + orderId));
}
}

View File

@@ -0,0 +1,237 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.constant.SourceType;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
import com.ycwl.basic.pricing.dto.ProductItem;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.pricing.service.IPriceCalculationService;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/mobile/puzzle/v1")
@RequiredArgsConstructor
public class AppPuzzleController {
private final PuzzleGenerationRecordMapper recordMapper;
private final FaceRepository faceRepository;
private final IPriceCalculationService iPriceCalculationService;
private final PrinterService printerService;
private final OrderBiz orderBiz;
/**
* 根据faceId查询三拼图数量
*/
@GetMapping("/count/{faceId}")
public ApiResponse<Integer> countByFaceId(@PathVariable("faceId") Long faceId) {
if (faceId == null) {
return ApiResponse.fail("faceId不能为空");
}
int count = recordMapper.countByFaceId(faceId);
return ApiResponse.success(count);
}
/**
* 根据faceId查询所有三拼图记录
*/
@GetMapping("/list/{faceId}")
public ApiResponse<List<ContentPageVO>> listByFaceId(@PathVariable("faceId") Long faceId) {
if (faceId == null) {
return ApiResponse.fail("faceId不能为空");
}
List<PuzzleGenerationRecordEntity> records = recordMapper.listByFaceId(faceId);
List<ContentPageVO> result = records.stream()
.map(this::convertToContentPageVO)
.collect(Collectors.toList());
return ApiResponse.success(result);
}
/**
* 根据recordId查询单个三拼图记录
*/
@GetMapping("/detail/{recordId}")
public ApiResponse<ContentPageVO> getByRecordId(@PathVariable("recordId") Long recordId) {
if (recordId == null) {
return ApiResponse.fail("recordId不能为空");
}
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录");
}
ContentPageVO result = convertToContentPageVO(record);
return ApiResponse.success(result);
}
/**
* 根据recordId下载拼图资源
*/
@GetMapping("/download/{recordId}")
public ApiResponse<List<String>> download(@PathVariable("recordId") Long recordId) {
if (recordId == null) {
return ApiResponse.fail("recordId不能为空");
}
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录");
}
String resultImageUrl = record.getResultImageUrl();
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
return ApiResponse.fail("该拼图记录没有可用的图片URL");
}
return ApiResponse.success(Collections.singletonList(resultImageUrl));
}
/**
* 根据recordId查询拼图价格
*/
@GetMapping("/price/{recordId}")
public ApiResponse<PriceCalculationResult> getPriceByRecordId(@PathVariable("recordId") Long recordId) {
if (recordId == null) {
return ApiResponse.fail("recordId不能为空");
}
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录");
}
FaceEntity face = faceRepository.getFace(record.getFaceId());
if (face == null) {
return ApiResponse.fail("未找到对应的人脸信息");
}
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
ProductItem productItem = new ProductItem();
productItem.setProductType(ProductType.PHOTO_LOG);
productItem.setProductId(record.getTemplateId().toString());
productItem.setPurchaseCount(1);
productItem.setScenicId(face.getScenicId().toString());
calculationRequest.setProducts(Collections.singletonList(productItem));
calculationRequest.setUserId(face.getMemberId());
calculationRequest.setFaceId(record.getFaceId());
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
PriceCalculationResult calculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
return ApiResponse.success(calculationResult);
}
/**
* 将拼图导入到打印列表
*/
@PostMapping("/import-to-print/{recordId}")
public ApiResponse<Integer> importToPrint(@PathVariable("recordId") Long recordId) {
if (recordId == null) {
return ApiResponse.fail("recordId不能为空");
}
// 查询拼图记录
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录");
}
// 检查是否有图片URL
String resultImageUrl = record.getResultImageUrl();
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
return ApiResponse.fail("该拼图记录没有可用的图片URL");
}
// 获取人脸信息
FaceEntity face = faceRepository.getFace(record.getFaceId());
if (face == null) {
return ApiResponse.fail("未找到对应的人脸信息");
}
// 调用服务添加到打印列表
Integer memberPrintId = printerService.addUserPhotoFromPuzzle(
face.getMemberId(),
face.getScenicId(),
record.getFaceId(),
resultImageUrl,
0L // 打印特有
);
if (memberPrintId == null) {
return ApiResponse.fail("添加到打印列表失败");
}
return ApiResponse.success(memberPrintId);
}
/**
* 将PuzzleGenerationRecordEntity转换为ContentPageVO
*/
private ContentPageVO convertToContentPageVO(PuzzleGenerationRecordEntity record) {
ContentPageVO vo = new ContentPageVO();
// 内容类型为3(拼图)
vo.setContentType(3);
// 源素材类型为3(拼图)
vo.setSourceType(3);
vo.setGroup("拼图");
// 只要存在记录,lockType不为0(设置为-1表示已生成)
vo.setLockType(-1);
// 通过faceId填充scenicId的信息
FaceEntity face = faceRepository.getFace(record.getFaceId());
if (record.getFaceId() != null) {
vo.setScenicId(face.getScenicId());
}
// contentId为生成记录id
vo.setContentId(record.getId());
// templateCoverUrl和生成的图是一致的
vo.setTemplateCoverUrl(record.getResultImageUrl());
// 设置模板ID
vo.setTemplateId(record.getTemplateId());
IsBuyRespVO isBuyScenic = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), 5, face.getScenicId());
if (isBuyScenic.isBuy()) {
vo.setIsBuy(1);
} else {
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), 5, record.getTemplateId());
if (isBuyRespVO.isBuy()) {
vo.setIsBuy(1);
} else {
vo.setIsBuy(0);
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
ProductItem productItem = new ProductItem();
productItem.setProductType(ProductType.PHOTO_LOG);
productItem.setProductId(record.getTemplateId().toString());
productItem.setPurchaseCount(1);
productItem.setScenicId(face.getScenicId().toString());
calculationRequest.setProducts(Collections.singletonList(productItem));
calculationRequest.setUserId(face.getMemberId());
calculationRequest.setFaceId(record.getFaceId());
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
PriceCalculationResult calculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
if (calculationResult.getFinalAmount().compareTo(BigDecimal.ZERO) > 0) {
vo.setFreeCount(0);
} else {
vo.setFreeCount(1);
}
}
}
return vo;
}
}

View File

@@ -48,6 +48,7 @@ public class AppScenicController {
add("3932535453961555968");
add("3936121342868459520");
add("3936940597855784960");
add("4049850382325780480");
}};
// 分页查询景区列表
@@ -87,6 +88,8 @@ public class AppScenicController {
resp.setPrintEnableManual(scenicConfig.getBoolean("print_enable_manual", true));
resp.setSceneMode(scenicConfig.getInteger("scene_mode", 0));
resp.setPrintEnable(scenicConfig.getBoolean("print_enable", false));
resp.setShowMyPagePaid(scenicConfig.getBoolean("show_my_page_paid", true));
resp.setShowMyPageUnpaid(scenicConfig.getBoolean("show_my_page_unpaid", true));
return ApiResponse.success(resp);
}

View File

@@ -55,7 +55,7 @@ public class AppTaskController {
@PostMapping("/submit")
public ApiResponse<String> submitVideoTask(@RequestBody VideoTaskReq videoTaskReq) {
taskService.createTaskByFaceIdAndTemplateId(videoTaskReq.getFaceId(),videoTaskReq.getTemplateId(),0);
taskService.createTaskByFaceIdAndTemplateId(videoTaskReq.getFaceId(),videoTaskReq.getTemplateId(),false);
return ApiResponse.success("成功");
}
}

View File

@@ -64,9 +64,6 @@ public class AppVoucherController {
if (face == null) {
throw new BaseException("请选择人脸");
}
if (!face.getMemberId().equals(Long.valueOf(BaseContextHandler.getUserId()))) {
throw new BaseException("自动领取失败");
}
req.setScenicId(face.getScenicId());
VoucherCodeResp result = voucherCodeService.claimVoucher(req);
return ApiResponse.success(result);

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

@@ -53,9 +53,9 @@ public class DeviceV2Controller {
if (pageSize > 100) {
pageSize = 100;
}
try {
PageResponse<DeviceV2DTO> response = deviceIntegrationService.listDevices(page, pageSize, name, no, type, isActive, scenicId);
PageResponse<DeviceV2DTO> response = deviceIntegrationService.listDevices(page, pageSize, name, no, type, isActive, scenicId, null);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("分页查询设备核心信息列表失败", e);
@@ -380,7 +380,7 @@ public class DeviceV2Controller {
@RequestParam(defaultValue = "10") Integer pageSize) {
log.info("获取景区所有设备列表, scenicId: {}, page: {}, pageSize: {}", scenicId, page, pageSize);
try {
PageResponse<DeviceV2DTO> response = deviceIntegrationService.listDevices(page, pageSize, name, no, type, null, scenicId);
PageResponse<DeviceV2DTO> response = deviceIntegrationService.listDevices(page, pageSize, name, no, type, null, scenicId, null);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("获取景区所有设备列表失败, scenicId: {}", scenicId, e);

View File

@@ -0,0 +1,106 @@
package com.ycwl.basic.controller.pc;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
import com.ycwl.basic.task.DeviceVideoContinuityCheckTask;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
/**
* 设备视频连续性检查控制器
* 提供查询设备视频连续性检查结果的接口
*
* @author Claude Code
* @date 2025-09-01
*/
@Slf4j
@RestController
@RequestMapping("/api/device/video-continuity")
@RequiredArgsConstructor
public class DeviceVideoContinuityController {
private static final String REDIS_KEY_PREFIX = "device:video:continuity:";
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private final DeviceVideoContinuityCheckTask checkTask;
/**
* 查询设备最近的视频连续性检查结果
*
* @param deviceId 设备ID
* @return 检查结果
*/
@GetMapping("/{deviceId}")
public ApiResponse<DeviceVideoContinuityCache> getDeviceContinuityResult(@PathVariable Long deviceId) {
log.info("查询设备 {} 的视频连续性检查结果", deviceId);
try {
String redisKey = REDIS_KEY_PREFIX + deviceId;
String cacheJson = redisTemplate.opsForValue().get(redisKey);
if (cacheJson == null) {
log.warn("未找到设备 {} 的视频连续性检查结果", deviceId);
return ApiResponse.buildResponse(404, null, "未找到该设备的检查结果,可能设备未配置存储或尚未执行检查");
}
DeviceVideoContinuityCache cache = objectMapper.readValue(cacheJson, DeviceVideoContinuityCache.class);
return ApiResponse.success(cache);
} catch (Exception e) {
log.error("查询设备 {} 视频连续性检查结果失败", deviceId, e);
return ApiResponse.buildResponse(500, null, "查询失败: " + e.getMessage());
}
}
/**
* 手动触发设备视频连续性检查
* 注意:仅用于测试和紧急情况,正常情况下由定时任务自动执行
*
* @param deviceId 设备ID
* @return 检查结果
*/
@PostMapping("/{deviceId}/check")
public ApiResponse<DeviceVideoContinuityCache> manualCheck(@PathVariable Long deviceId) {
log.info("手动触发设备 {} 的视频连续性检查", deviceId);
try {
DeviceVideoContinuityCache result = checkTask.manualCheck(deviceId);
return ApiResponse.success(result);
} catch (Exception e) {
log.error("手动检查设备 {} 视频连续性失败", deviceId, e);
return ApiResponse.buildResponse(500, null, "检查失败: " + e.getMessage());
}
}
/**
* 删除设备的视频连续性检查缓存
* 用于清理过期或错误的缓存数据
*
* @param deviceId 设备ID
* @return 删除结果
*/
@DeleteMapping("/{deviceId}")
public ApiResponse<String> deleteContinuityCache(@PathVariable Long deviceId) {
log.info("删除设备 {} 的视频连续性检查缓存", deviceId);
try {
String redisKey = REDIS_KEY_PREFIX + deviceId;
Boolean deleted = redisTemplate.delete(redisKey);
if (deleted != null && deleted) {
return ApiResponse.success("缓存删除成功");
} else {
return ApiResponse.buildResponse(404, null, "未找到该设备的缓存数据");
}
} catch (Exception e) {
log.error("删除设备 {} 视频连续性检查缓存失败", deviceId, e);
return ApiResponse.buildResponse(500, null, "删除失败: " + e.getMessage());
}
}
}

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

@@ -1,6 +1,6 @@
package com.ycwl.basic.controller.pc;
import com.ycwl.basic.controller.dto.RenderWorkerWithStatusDTO;
import com.ycwl.basic.dto.RenderWorkerWithStatusDTO;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.render.dto.worker.CreateRenderWorkerRequest;
import com.ycwl.basic.integration.render.dto.worker.RenderWorkerV2DTO;

View File

@@ -60,9 +60,9 @@ public class ScenicV2Controller {
if (pageSize > 100) {
pageSize = 100;
}
try {
PageResponse<ScenicV2DTO> response = scenicIntegrationService.listScenics(page, pageSize, status, name);
PageResponse<ScenicV2DTO> response = scenicIntegrationService.listScenics(page, pageSize, status, name, null);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("分页查询景区核心信息列表失败", e);
@@ -156,7 +156,7 @@ public class ScenicV2Controller {
log.info("查询景区列表, status: {}", status);
try {
// 默认查询1000条数据,第1页
PageResponse<ScenicV2DTO> scenics = scenicIntegrationService.listScenics(1, 1000, status, null);
PageResponse<ScenicV2DTO> scenics = scenicIntegrationService.listScenics(1, 1000, status, null, null);
return ApiResponse.success(scenics);
} catch (Exception e) {
log.error("查询景区列表失败, status: {}", status, e);

View File

@@ -13,7 +13,6 @@ import org.springframework.web.bind.annotation.*;
*/
@RestController
@RequestMapping("/api/task/v1")
@Deprecated
// 任务列表管理
public class TaskController {

View File

@@ -40,4 +40,16 @@ public class VideoController {
return videoService.getById(id);
}
/**
* 查询视频是否被购买
*
* @param videoId 视频ID
* @return 是否已购买
*/
@GetMapping("/checkBuyStatus")
public ApiResponse<Boolean> checkBuyStatus(@RequestParam("videoId") Long videoId) {
Boolean isBuy = videoService.checkVideoBuyStatus(videoId);
return ApiResponse.success(isBuy);
}
}

View File

@@ -1,34 +1,56 @@
package com.ycwl.basic.controller.printer;
import cn.hutool.core.date.DateUtil;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.SourceRepository;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.service.task.TaskFaceService;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.model.printer.FaceRecognizeWithSourcesResp;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.service.pc.ScenicService;
import com.ycwl.basic.storage.utils.StorageUtil;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.SnowFlakeUtil;
import com.ycwl.basic.utils.WxMpUtil;
import jakarta.websocket.server.PathParam;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import jakarta.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import static com.ycwl.basic.constant.StorageConstant.USER_FACE;
@IgnoreToken
// 打印机大屏对接接口
@@ -40,6 +62,14 @@ public class PrinterTvController {
private final DeviceRepository deviceRepository;
private final ScenicRepository scenicRepository;
private final FaceRepository faceRepository;
private final TaskFaceService faceService;
private final FaceService pcFaceService;
private final ScenicService scenicService;
private final SourceMapper sourceMapper;
private final FaceMapper faceMapper;
private final MemberRelationRepository memberRelationRepository;
private final SourceRepository sourceRepository;
private final PrinterService printerService;
/**
* 获取景区列表
@@ -70,10 +100,20 @@ public class PrinterTvController {
@GetMapping("/{sampleId}/qrcode")
public void getQrcode(@PathVariable("sampleId") Long sampleId, HttpServletResponse response) throws Exception {
File qrcode = new File("qrcode_"+sampleId+".jpg");
FaceSampleEntity faceSample = faceRepository.getFaceSample(sampleId);
if (faceSample == null) {
response.setStatus(404);
return;
}
String targetPath = "pages/printer/from_sample";
DeviceV2DTO device = deviceRepository.getDeviceBasic(faceSample.getDeviceId());
if (device.getType().equals("AI_CAM")) {
// AI_CAM,需要修改path
targetPath = "pages/ai-cam/from_sample";
}
try {
FaceSampleEntity faceSample = faceRepository.getFaceSample(sampleId);
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(faceSample.getScenicId());
WxMpUtil.generateUnlimitedWXAQRCode(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), "pages/printer/from_sample", sampleId.toString(), qrcode);
WxMpUtil.generateUnlimitedWXAQRCode(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), targetPath, sampleId.toString(), qrcode);
// 设置响应头
response.setContentType("image/jpeg");
@@ -96,4 +136,142 @@ public class PrinterTvController {
}
}
}
/**
* 获取人脸绑定二维码
* 生成小程序二维码,用于绑定人脸到用户账号
*
* @param faceId 人脸ID
* @param response HTTP响应
*/
@GetMapping("/face/{faceId}/qrcode")
public void getFaceQrcode(@PathVariable("faceId") Long faceId, HttpServletResponse response) throws Exception {
File qrcode = new File("qrcode_face_" + faceId + ".jpg");
try {
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
response.setStatus(404);
return;
}
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(face.getScenicId());
if (scenicMpConfig == null) {
response.setStatus(500);
return;
}
WxMpUtil.generateUnlimitedWXAQRCode(
scenicMpConfig.getAppId(),
scenicMpConfig.getAppSecret(),
"pages/videoSynthesis/bind_face",
faceId.toString(),
qrcode
);
// 设置响应头
response.setContentType("image/jpeg");
response.setHeader("Content-Disposition", "inline; filename=\"" + qrcode.getName() + "\"");
// 将二维码文件写入响应输出流
try (FileInputStream fis = new FileInputStream(qrcode);
OutputStream os = response.getOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.flush();
}
} finally {
// 删除临时文件
if (qrcode.exists()) {
qrcode.delete();
}
}
}
/**
* 根据人脸样本ID查询图像素材
*
* @param faceSampleId 人脸样本ID
* @return type=2且face_sample_id匹配的source记录
*/
@GetMapping("/{faceSampleId}/source")
public ApiResponse<SourceEntity> getSourceByFaceSampleId(@PathVariable Long faceSampleId) {
SourceEntity source = sourceMapper.getBySampleIdAndType(faceSampleId, 2);
if (source == null) {
return ApiResponse.fail("未找到对应的图像素材");
}
return ApiResponse.success(source);
}
/**
* 打印机大屏人脸识别
* 上传照片,在景区人脸库中搜索匹配的人脸样本,返回识别结果和匹配到的图像素材
*
* @param file 人脸照片文件
* @param scenicId 景区ID
* @return 人脸识别结果和匹配的source列表
*/
@PostMapping("/{scenicId}/faceRecognize")
public ApiResponse<FaceRecognizeWithSourcesResp> faceRecognize(
@RequestParam("file") MultipartFile file,
@PathVariable Long scenicId) throws Exception {
// 1. 上传人脸照片到存储
IStorageAdapter adapter = StorageFactory.use("faces");
String filePath = StorageUtil.joinPath(USER_FACE, DateUtil.format(new Date(), "yyyy-MM-dd"));
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.split("\\.", 2)[1];
String fileName = UUID.randomUUID() + "." + suffix;
String faceUrl = adapter.uploadFile(file, filePath, fileName);
// 2. 保存人脸数据到数据库
Long faceId = SnowFlakeUtil.getLongId();
FaceEntity faceEntity = new FaceEntity();
faceEntity.setId(faceId);
faceEntity.setScenicId(scenicId);
faceEntity.setFaceUrl(faceUrl);
faceEntity.setCreateAt(new Date());
faceEntity.setMemberId(0L); // 打印机大屏端没有用户ID
faceMapper.add(faceEntity);
// 3. 在景区人脸库中搜索(注意:这里使用scenicId作为数据库名,搜索的是景区内的人脸样本)
pcFaceService.matchFaceId(faceId);
// 4. 自动添加照片到打印列表,并获取添加成功的照片列表
List<SourceEntity> addedSources = printerService.autoAddPhotosToPreferPrint(faceId);
// 5. 根据自动添加结果决定返回的sources
List<SourceEntity> sources;
if (addedSources != null && !addedSources.isEmpty()) {
// 如果自动添加成功,返回添加的照片列表
sources = addedSources;
} else {
// 如果自动添加为空,按原逻辑查询匹配到的图像素材(type=2)
sources = new ArrayList<>();
List<MemberSourceEntity> memberSourceEntities = memberRelationRepository.listSourceByFaceRelation(faceId, 2);
for (MemberSourceEntity memberSourceEntity : memberSourceEntities) {
SourceEntity source = sourceRepository.getSource(memberSourceEntity.getSourceId());
if (source != null) {
sources.add(source);
}
}
}
// 6. 构造响应
FaceRecognizeWithSourcesResp resp = new FaceRecognizeWithSourcesResp();
resp.setUrl(faceUrl);
resp.setFaceId(faceId);
resp.setScenicId(scenicId);
resp.setSources(sources);
// 只有当添加了照片时才返回二维码URL
if (addedSources != null && !addedSources.isEmpty()) {
resp.setQrcodeUrl("https://zhentuai.com/printer/v1/tv/face/" + faceId + "/qrcode");
} else {
resp.setQrcodeUrl(null);
}
return ApiResponse.success(resp);
}
}

View File

@@ -0,0 +1,155 @@
package com.ycwl.basic.device.entity.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 设备视频连续性检查缓存实体
* 用于存储在Redis中的检查结果
*
* @author Claude Code
* @date 2025-09-01
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DeviceVideoContinuityCache implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 设备ID
*/
private Long deviceId;
/**
* 检查时间
*/
private Date checkTime;
/**
* 检查的开始时间
*/
private Date startTime;
/**
* 检查的结束时间
*/
private Date endTime;
/**
* 是否支持连续性检查
*/
private Boolean support;
/**
* 视频是否连续
*/
private Boolean continuous;
/**
* 视频总数
*/
private Integer totalVideos;
/**
* 总时长(毫秒)
*/
private Long totalDurationMs;
/**
* 允许的最大间隙(毫秒)
*/
private Long maxAllowedGapMs;
/**
* 间隙数量
*/
private Integer gapCount;
/**
* 间隙列表(简化版,只包含关键信息)
*/
private List<GapInfo> gaps;
/**
* 间隙信息简化类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class GapInfo implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 前一个文件名
*/
private String beforeFileName;
/**
* 后一个文件名
*/
private String afterFileName;
/**
* 间隙时长(毫秒)
*/
private Long gapMs;
/**
* 间隙开始时间
*/
private Date gapStartTime;
/**
* 间隙结束时间
*/
private Date gapEndTime;
}
/**
* 从VideoContinuityResult创建缓存对象
*
* @param deviceId 设备ID
* @param result 检查结果
* @param startTime 检查开始时间
* @param endTime 检查结束时间
* @return 缓存对象
*/
public static DeviceVideoContinuityCache fromResult(Long deviceId, VideoContinuityResult result,
Date startTime, Date endTime) {
DeviceVideoContinuityCache cache = new DeviceVideoContinuityCache();
cache.setDeviceId(deviceId);
cache.setCheckTime(new Date());
cache.setStartTime(startTime);
cache.setEndTime(endTime);
cache.setSupport(result.isSupport());
cache.setContinuous(result.isContinuous());
cache.setTotalVideos(result.getTotalVideos());
cache.setTotalDurationMs(result.getTotalDurationMs());
cache.setMaxAllowedGapMs(result.getMaxAllowedGapMs());
cache.setGapCount(result.getGapCount());
// 转换间隙列表
if (result.getGaps() != null && !result.getGaps().isEmpty()) {
List<GapInfo> gapInfos = result.getGaps().stream()
.map(gap -> new GapInfo(
gap.getBeforeFile() != null ? gap.getBeforeFile().getName() : null,
gap.getAfterFile() != null ? gap.getAfterFile().getName() : null,
gap.getGapMs(),
gap.getGapStartTime(),
gap.getGapEndTime()
))
.toList();
cache.setGaps(gapInfos);
}
return cache;
}
}

View File

@@ -0,0 +1,40 @@
package com.ycwl.basic.device.entity.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
/**
* 视频连续性检查中的间隙信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class VideoContinuityGap {
/**
* 间隙前的视频文件
*/
private FileObject beforeFile;
/**
* 间隙后的视频文件
*/
private FileObject afterFile;
/**
* 间隙时长(毫秒)
*/
private long gapMs;
/**
* 间隙开始时间(前一个视频的endTime)
*/
private Date gapStartTime;
/**
* 间隙结束时间(后一个视频的createTime)
*/
private Date gapEndTime;
}

View File

@@ -0,0 +1,56 @@
package com.ycwl.basic.device.entity.common;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 视频连续性检查结果
*/
@Data
public class VideoContinuityResult {
/**
* 是否支持连续性检查功能
*/
private boolean support;
/**
* 视频是否连续(所有间隙都在允许范围内)
*/
private boolean continuous;
/**
* 检测到的间隙列表
*/
private List<VideoContinuityGap> gaps = new ArrayList<>();
/**
* 视频文件总数
*/
private int totalVideos;
/**
* 总时长(毫秒)
*/
private long totalDurationMs;
/**
* 允许的最大间隙(毫秒)
*/
private long maxAllowedGapMs;
/**
* 添加一个间隙
*/
public void addGap(VideoContinuityGap gap) {
this.gaps.add(gap);
}
/**
* 获取间隙数量
*/
public int getGapCount() {
return gaps.size();
}
}

View File

@@ -1,12 +1,50 @@
package com.ycwl.basic.device.operator;
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import lombok.Setter;
import java.util.Calendar;
import java.util.Date;
public abstract class ADeviceStorageOperator implements IDeviceStorageOperator {
@Setter
protected DeviceEntity device;
@Setter
protected DeviceConfigEntity deviceConfig;
/**
* 默认实现:不支持视频连续性检查
*
* @param startDate 开始时间
* @param endDate 结束时间
* @param maxGapMs 允许的最大间隔时间(毫秒)
* @return support=false的结果
*/
@Override
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
VideoContinuityResult result = new VideoContinuityResult();
result.setSupport(false);
result.setContinuous(false);
result.setTotalVideos(0);
result.setTotalDurationMs(0);
result.setMaxAllowedGapMs(maxGapMs);
return result;
}
/**
* 默认实现:不支持视频连续性检查
*
* @return support=false的结果
*/
@Override
public VideoContinuityResult checkRecentVideoContinuity() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, -2);
Date endDate = calendar.getTime();
calendar.add(Calendar.MINUTE, -5);
Date startDate = calendar.getTime();
return checkVideoContinuity(startDate, endDate, 2000L);
}
}

View File

@@ -3,6 +3,8 @@ package com.ycwl.basic.device.operator;
import cn.hutool.core.date.DateUtil;
import com.ycwl.basic.utils.JacksonUtil;
import com.ycwl.basic.device.entity.common.FileObject;
import com.ycwl.basic.device.entity.common.VideoContinuityGap;
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.storage.entity.AliOssStorageConfig;
@@ -98,4 +100,104 @@ public class AliOssStorageOperator extends ADeviceStorageOperator {
String prefix = dateFormat.format(calendar.getTime());
return removeFilesByPrefix(prefix);
}
/**
* 检查视频片段的连续性
*
* @param startDate 开始时间
* @param endDate 结束时间
* @param maxGapMs 允许的最大间隔时间(毫秒)
* @return 包含缺口信息的验证结果
*/
@Override
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
VideoContinuityResult result = new VideoContinuityResult();
result.setSupport(true);
result.setMaxAllowedGapMs(maxGapMs);
// 获取时间范围内的视频列表
List<FileObject> fileList = getFileListByDtRange(startDate, endDate);
if (fileList == null || fileList.isEmpty()) {
result.setContinuous(false);
result.setTotalVideos(0);
result.setTotalDurationMs(0);
log.warn("未找到指定时间范围内的视频文件: {} - {}", startDate, endDate);
return result;
}
result.setTotalVideos(fileList.size());
// 只有一个视频文件时,认为是连续的
if (fileList.size() == 1) {
FileObject file = fileList.get(0);
long duration = file.getEndTime().getTime() - file.getCreateTime().getTime();
result.setContinuous(true);
result.setTotalDurationMs(duration);
return result;
}
// 检查相邻视频之间的间隙
long totalDuration = 0;
for (int i = 0; i < fileList.size() - 1; i++) {
FileObject currentFile = fileList.get(i);
FileObject nextFile = fileList.get(i + 1);
// 计算当前视频的时长
totalDuration += currentFile.getEndTime().getTime() - currentFile.getCreateTime().getTime();
// 计算间隙: 后一个视频的开始时间 - 前一个视频的结束时间
long gapMs = nextFile.getCreateTime().getTime() - currentFile.getEndTime().getTime();
// 如果间隙超过允许值,记录该间隙
if (gapMs > maxGapMs) {
VideoContinuityGap gap = new VideoContinuityGap();
gap.setBeforeFile(currentFile);
gap.setAfterFile(nextFile);
gap.setGapMs(gapMs);
gap.setGapStartTime(currentFile.getEndTime());
gap.setGapEndTime(nextFile.getCreateTime());
result.addGap(gap);
log.debug("检测到视频间隙: {} -> {}, 间隙时长: {}ms",
currentFile.getName(), nextFile.getName(), gapMs);
}
}
// 加上最后一个视频的时长
FileObject lastFile = fileList.get(fileList.size() - 1);
totalDuration += lastFile.getEndTime().getTime() - lastFile.getCreateTime().getTime();
result.setTotalDurationMs(totalDuration);
result.setContinuous(result.getGapCount() == 0);
log.info("视频连续性检查完成: 总视频数={}, 总时长={}ms, 间隙数={}, 连续={}",
result.getTotalVideos(), result.getTotalDurationMs(), result.getGapCount(), result.isContinuous());
return result;
}
/**
* 检查近期视频的连续性(测试用)
* 时间范围: 当前时间向前2分钟后,再向前10分钟(即前12分钟到前2分钟)
* 允许的最大间隙: 2秒
*
* @return 包含缺口信息的验证结果
*/
@Override
public VideoContinuityResult checkRecentVideoContinuity() {
Calendar calendar = Calendar.getInstance();
// 结束时间: 当前时间 - 2分钟
calendar.add(Calendar.MINUTE, -2);
Date endDate = calendar.getTime();
// 开始时间: 当前时间 - 12分钟 (再向前10分钟)
calendar.add(Calendar.MINUTE, -10);
Date startDate = calendar.getTime();
log.info("检查近期视频连续性: {} - {}", startDate, endDate);
// 允许的最大间隙为2秒(2000毫秒)
return checkVideoContinuity(startDate, endDate, 2000L);
}
}

View File

@@ -2,6 +2,7 @@ package com.ycwl.basic.device.operator;
import com.ycwl.basic.device.IDeviceCommon;
import com.ycwl.basic.device.entity.common.FileObject;
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
import java.util.Date;
import java.util.List;
@@ -19,10 +20,29 @@ public interface IDeviceStorageOperator extends IDeviceCommon {
List<FileObject> getFileListByDtRange(Date startDate, Date endDate);
/**
* 删除指定日期之前的文件不包含指定的日期当天
* 删除指定日期之前的文件,不包含指定的日期当天
*
* @param date 指定日期不包含指定日期当天
* @param date 指定日期,不包含指定日期当天
* @return
*/
boolean removeFilesBeforeDate(Date date);
/**
* 检查视频片段的连续性
*
* @param startDate 开始时间
* @param endDate 结束时间
* @param maxGapMs 允许的最大间隔时间(毫秒)
* @return 包含缺口信息的验证结果
*/
VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs);
/**
* 检查近期视频的连续性(便捷方法)
* 时间范围: 当前时间向前2分钟后,再向前5分钟(即前7分钟到前2分钟)
* 允许的最大间隙: 2秒
*
* @return 包含缺口信息的验证结果
*/
VideoContinuityResult checkRecentVideoContinuity();
}

View File

@@ -1,10 +1,12 @@
package com.ycwl.basic.device.operator;
import com.ycwl.basic.device.entity.common.FileObject;
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import lombok.Setter;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@@ -34,4 +36,24 @@ public class LocalStorageOperator implements IDeviceStorageOperator {
return false;
}
@Override
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
VideoContinuityResult result = new VideoContinuityResult();
result.setSupport(false);
result.setContinuous(false);
result.setTotalVideos(0);
result.setTotalDurationMs(0);
result.setMaxAllowedGapMs(maxGapMs);
return result;
}
@Override
public VideoContinuityResult checkRecentVideoContinuity() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, -2);
Date endDate = calendar.getTime();
calendar.add(Calendar.MINUTE, -5);
Date startDate = calendar.getTime();
return checkVideoContinuity(startDate, endDate, 2000L);
}
}

View File

@@ -1,4 +1,4 @@
package com.ycwl.basic.controller.dto;
package com.ycwl.basic.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;

View File

@@ -59,7 +59,7 @@ public class ZTSourceMessage {
* 判断是否为照片
*/
public boolean isPhoto() {
return sourceType != null && sourceType == 2;
return sourceType != null && (sourceType == 2 || sourceType == 3);
}
}

View File

@@ -0,0 +1,290 @@
package com.ycwl.basic.face.pipeline.core;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.face.pipeline.enums.FaceMatchingScene;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.pipeline.core.PipelineContext;
import lombok.Getter;
import lombok.Setter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 人脸匹配管线上下文
* 在各个Stage之间传递状态和数据
*/
@Getter
@Setter
public class FaceMatchingContext implements PipelineContext {
// ==================== 核心字段(构造时必填)====================
/**
* 人脸ID(必填)
*/
private final Long faceId;
/**
* 是否新用户
*/
private final boolean isNew;
// ==================== 场景标识 ====================
/**
* 场景标识
*/
private FaceMatchingScene scene;
/**
* 手动选择的样本ID(自定义匹配场景)
*/
private List<Long> faceSampleIds;
// ==================== 中间状态 ====================
/**
* 人脸实体
*/
private FaceEntity face;
/**
* 景区配置管理器
*/
private ScenicConfigManager scenicConfig;
/**
* 人脸识别适配器
*/
private IFaceBodyAdapter faceBodyAdapter;
/**
* 人脸搜索结果
*/
private SearchFaceRespVo searchResult;
/**
* 人脸样本列表(自定义匹配场景)
*/
private List<FaceSampleEntity> faceSamples;
/**
* 匹配到的样本ID列表
*/
private List<Long> sampleListIds;
/**
* 源文件关联列表
*/
private List<MemberSourceEntity> memberSourceList;
/**
* 免费源文件ID列表
*/
private List<Long> freeSourceIds;
/**
* 人脸选择后置模式配置(自定义匹配场景)
* 0: 并集, 1: 交集, 2: 直接使用
*/
private Integer faceSelectPostMode;
// ==================== 输出结果 ====================
/**
* 最终结果
*/
private SearchFaceRespVo finalResult;
// ==================== Stage配置 ====================
/**
* Stage开关配置表
* Key: stageId, Value: 是否启用
*/
private Map<String, Boolean> stageEnabledMap = new HashMap<>();
// ==================== 构造函数(私有)====================
private FaceMatchingContext(Builder builder) {
this.faceId = builder.faceId;
this.isNew = builder.isNew;
this.scene = builder.scene;
this.faceSampleIds = builder.faceSampleIds;
}
// ==================== 静态工厂方法 ====================
/**
* 获取 Builder
*/
public static Builder builder() {
return new Builder();
}
/**
* 快速创建自动匹配场景Context
*/
public static FaceMatchingContext forAutoMatching(Long faceId, boolean isNew) {
return FaceMatchingContext.builder()
.faceId(faceId)
.isNew(isNew)
.scene(FaceMatchingScene.AUTO_MATCHING)
.build();
}
/**
* 快速创建自定义匹配场景Context
*/
public static FaceMatchingContext forCustomMatching(Long faceId, List<Long> faceSampleIds) {
return FaceMatchingContext.builder()
.faceId(faceId)
.isNew(false)
.faceSampleIds(faceSampleIds)
.scene(FaceMatchingScene.CUSTOM_MATCHING)
.build();
}
/**
* 快速创建仅识别场景Context
*/
public static FaceMatchingContext forRecognitionOnly(Long faceId) {
return FaceMatchingContext.builder()
.faceId(faceId)
.isNew(false)
.scene(FaceMatchingScene.RECOGNITION_ONLY)
.build();
}
// ==================== 业务方法 ====================
/**
* 判断指定Stage是否启用
*
* @param stageId Stage唯一标识
* @param defaultEnabled 默认值(当配置未指定时使用)
* @return true-启用, false-禁用
*/
@Override
public boolean isStageEnabled(String stageId, boolean defaultEnabled) {
return stageEnabledMap.getOrDefault(stageId, defaultEnabled);
}
/**
* 判断指定Stage是否启用(默认为false)
*
* @param stageId Stage唯一标识
* @return true-启用, false-禁用
*/
@Override
public boolean isStageEnabled(String stageId) {
return stageEnabledMap.getOrDefault(stageId, false);
}
/**
* 设置指定Stage的启用状态
*
* @param stageId Stage唯一标识
* @param enabled 是否启用
* @return this(支持链式调用)
*/
public FaceMatchingContext setStageState(String stageId, boolean enabled) {
stageEnabledMap.put(stageId, enabled);
return this;
}
/**
* 启用指定Stage
*
* @param stageId Stage唯一标识
* @return this(支持链式调用)
*/
public FaceMatchingContext enableStage(String stageId) {
stageEnabledMap.put(stageId, true);
return this;
}
/**
* 禁用指定Stage
*
* @param stageId Stage唯一标识
* @return this(支持链式调用)
*/
public FaceMatchingContext disableStage(String stageId) {
stageEnabledMap.put(stageId, false);
return this;
}
/**
* 批量设置Stage启用状态
*
* @param stages Stage配置Map(stageId -> enabled)
* @return this(支持链式调用)
*/
public FaceMatchingContext setStages(Map<String, Boolean> stages) {
if (stages != null) {
stageEnabledMap.putAll(stages);
}
return this;
}
/**
* 清空所有Stage配置
*
* @return this(支持链式调用)
*/
public FaceMatchingContext clearStages() {
stageEnabledMap.clear();
return this;
}
// ==================== Builder ====================
public static class Builder {
private Long faceId;
private boolean isNew = false;
private FaceMatchingScene scene;
private List<Long> faceSampleIds;
public Builder faceId(Long faceId) {
this.faceId = faceId;
return this;
}
public Builder isNew(boolean isNew) {
this.isNew = isNew;
return this;
}
public Builder scene(FaceMatchingScene scene) {
this.scene = scene;
return this;
}
public Builder faceSampleIds(List<Long> faceSampleIds) {
this.faceSampleIds = faceSampleIds;
return this;
}
public FaceMatchingContext build() {
// 参数校验
if (faceId == null) {
throw new IllegalArgumentException("faceId is required");
}
if (scene == null) {
throw new IllegalArgumentException("scene is required");
}
// 自定义匹配场景必须提供faceSampleIds
if (scene == FaceMatchingScene.CUSTOM_MATCHING && (faceSampleIds == null || faceSampleIds.isEmpty())) {
throw new IllegalArgumentException("faceSampleIds is required for CUSTOM_MATCHING scene");
}
return new FaceMatchingContext(this);
}
}
}

View File

@@ -0,0 +1,25 @@
package com.ycwl.basic.face.pipeline.enums;
/**
* 人脸匹配场景枚举
*/
public enum FaceMatchingScene {
/**
* 自动人脸匹配
* 新用户上传人脸后自动执行匹配,或老用户重新匹配
*/
AUTO_MATCHING,
/**
* 自定义人脸匹配
* 用户手动选择人脸样本进行匹配
*/
CUSTOM_MATCHING,
/**
* 仅识别
* 只执行人脸识别,不处理后续业务逻辑(源文件关联、任务创建等)
*/
RECOGNITION_ONLY
}

View File

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

View File

@@ -0,0 +1,234 @@
package com.ycwl.basic.face.pipeline.factory;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.core.Pipeline;
import com.ycwl.basic.pipeline.core.PipelineBuilder;
import com.ycwl.basic.face.pipeline.enums.FaceMatchingScene;
import com.ycwl.basic.face.pipeline.stages.*;
import com.ycwl.basic.service.pc.helper.ScenicConfigFacade;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 人脸匹配Pipeline工厂
* 负责为不同场景组装Pipeline
*
* 支持的场景:
* 1. 自动人脸匹配(新用户/老用户)
* 2. 自定义人脸匹配
* 3. 仅识别
*/
@Slf4j
@Component
public class FaceMatchingPipelineFactory {
// ==================== 通用 Stage(13个)====================
@Autowired
private PrepareContextStage prepareContextStage;
@Autowired
private RecordMetricsStage recordMetricsStage;
@Autowired
private FaceRecognitionStage faceRecognitionStage;
@Autowired
private FaceRecoveryStage faceRecoveryStage;
@Autowired
private UpdateFaceResultStage updateFaceResultStage;
@Autowired
private BuildSourceRelationStage buildSourceRelationStage;
@Autowired
private ProcessFreeSourceStage processFreeSourceStage;
@Autowired
private ProcessBuyStatusStage processBuyStatusStage;
@Autowired
private HandleVideoRecreationStage handleVideoRecreationStage;
@Autowired
private PersistRelationsStage persistRelationsStage;
@Autowired
private CreateTaskStage createTaskStage;
@Autowired
private SetTaskStatusStage setTaskStatusStage;
@Autowired
private GeneratePuzzleStage generatePuzzleStage;
// ==================== 自定义匹配专属 Stage(6个)====================
@Autowired
private RecordCustomMatchMetricsStage recordCustomMatchMetricsStage;
@Autowired
private LoadFaceSamplesStage loadFaceSamplesStage;
@Autowired
private CustomFaceSearchStage customFaceSearchStage;
@Autowired
private LoadMatchedSamplesStage loadMatchedSamplesStage;
@Autowired
private FilterByTimeRangeStage filterByTimeRangeStage;
@Autowired
private FilterByDevicePhotoLimitStage filterByDevicePhotoLimitStage;
@Autowired
private DeleteOldRelationsStage deleteOldRelationsStage;
// ==================== 辅助服务 ====================
@Autowired
private ScenicConfigFacade scenicConfigFacade;
/**
* 创建自动人脸匹配Pipeline
*
* @param isNew 是否新用户
* @return Pipeline
*/
public Pipeline<FaceMatchingContext> createAutoMatchingPipeline(boolean isNew) {
PipelineBuilder<FaceMatchingContext> builder = new PipelineBuilder<>("AutoMatching-" + (isNew ? "New" : "Old"));
// 1. 准备上下文
builder.addStage(prepareContextStage);
// 2. 新用户设置任务状态
if (isNew) {
builder.addStage(setTaskStatusStage);
}
// 3. 记录识别次数
builder.addStage(recordMetricsStage);
// 4. 执行人脸识别
builder.addStage(faceRecognitionStage);
// 5. 人脸识别补救
builder.addStage(faceRecoveryStage);
// 6. 更新人脸结果
builder.addStage(updateFaceResultStage);
// 7. 构建源文件关联
builder.addStage(buildSourceRelationStage);
// 8. 处理免费源文件逻辑
builder.addStage(processFreeSourceStage);
// 9. 处理购买状态
builder.addStage(processBuyStatusStage);
// 10. 处理视频重切
builder.addStage(handleVideoRecreationStage);
// 11. 持久化关联关系
builder.addStage(persistRelationsStage);
// 12. 创建任务
builder.addStage(createTaskStage);
// 13. 异步生成拼图模板
builder.addStage(generatePuzzleStage);
log.debug("创建自动人脸匹配Pipeline: isNew={}, stageCount={}", isNew, builder.build().getStageCount());
return builder.build();
}
/**
* 创建自定义人脸匹配Pipeline
*
* @return Pipeline
*/
public Pipeline<FaceMatchingContext> createCustomMatchingPipeline() {
PipelineBuilder<FaceMatchingContext> builder = new PipelineBuilder<>("CustomMatching");
// 1. 准备上下文
builder.addStage(prepareContextStage);
// 2. 记录自定义匹配次数
builder.addStage(recordCustomMatchMetricsStage);
// 3. 加载用户选择的人脸样本
builder.addStage(loadFaceSamplesStage);
// 4. 根据配置执行自定义人脸搜索
builder.addStage(customFaceSearchStage);
// 5. 加载匹配样本实体到缓存
builder.addStage(loadMatchedSamplesStage);
// 6. 应用时间范围筛选
builder.addStage(filterByTimeRangeStage);
// 7. 应用设备照片数量限制筛选
builder.addStage(filterByDevicePhotoLimitStage);
// 8. 更新人脸结果
builder.addStage(updateFaceResultStage);
// 9. 删除旧关系数据
builder.addStage(deleteOldRelationsStage);
// 10. 构建源文件关联
builder.addStage(buildSourceRelationStage);
// 11. 处理免费源文件逻辑
builder.addStage(processFreeSourceStage);
// 12. 处理购买状态
builder.addStage(processBuyStatusStage);
// 13. 处理视频重切
builder.addStage(handleVideoRecreationStage);
// 14. 持久化关联关系
builder.addStage(persistRelationsStage);
// 15. 创建任务
builder.addStage(createTaskStage);
log.debug("创建自定义人脸匹配Pipeline: stageCount={}", builder.build().getStageCount());
return builder.build();
}
/**
* 创建仅识别Pipeline
* 只执行人脸识别,不处理后续业务逻辑
*
* @return Pipeline
*/
public Pipeline<FaceMatchingContext> createRecognitionOnlyPipeline() {
PipelineBuilder<FaceMatchingContext> builder = new PipelineBuilder<>("RecognitionOnly");
// 1. 准备上下文
builder.addStage(prepareContextStage);
// 2. 执行人脸识别
builder.addStage(faceRecognitionStage);
// 3. 人脸识别补救
builder.addStage(faceRecoveryStage);
log.debug("创建仅识别Pipeline: stageCount={}", builder.build().getStageCount());
return builder.build();
}
/**
* 根据场景创建Pipeline
*
* @param scene 场景
* @param isNew 是否新用户(仅AUTO_MATCHING场景需要)
* @return Pipeline
*/
public Pipeline<FaceMatchingContext> createPipeline(FaceMatchingScene scene, boolean isNew) {
return switch (scene) {
case AUTO_MATCHING -> createAutoMatchingPipeline(isNew);
case CUSTOM_MATCHING -> createCustomMatchingPipeline();
case RECOGNITION_ONLY -> createRecognitionOnlyPipeline();
};
}
/**
* 根据Context创建Pipeline
*
* @param context 上下文
* @return Pipeline
*/
public Pipeline<FaceMatchingContext> createPipeline(FaceMatchingContext context) {
return createPipeline(context.getScene(), context.isNew());
}
}

View File

@@ -0,0 +1,156 @@
package com.ycwl.basic.face.pipeline.helper;
import cn.hutool.core.date.DateUtil;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.puzzle.service.IPuzzleTemplateService;
import com.ycwl.basic.repository.ScenicRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 拼图生成编排器
* 负责编排拼图模板的批量生成逻辑
*
* 职责:
* 1. 查询景区的所有启用拼图模板
* 2. 构建动态数据
* 3. 逐个生成拼图图片
* 4. 记录统计信息
*
* 设计说明:
* - 从GeneratePuzzleStage中抽离出来,符合"薄Stage,厚Service"原则
* - Stage只负责触发异步任务,业务逻辑由此Orchestrator承担
*/
@Slf4j
@Service
public class PuzzleGenerationOrchestrator {
@Autowired
private IPuzzleTemplateService puzzleTemplateService;
@Autowired
private IPuzzleGenerateService puzzleGenerateService;
@Autowired
private ScenicRepository scenicRepository;
/**
* 异步生成景区所有启用的拼图模板
*
* @param scenicId 景区ID
* @param faceId 人脸ID
* @param memberId 会员ID
* @param faceUrl 人脸URL
*/
public void generateAllTemplatesAsync(Long scenicId, Long faceId, Long memberId, String faceUrl) {
new Thread(() -> {
try {
log.info("开始异步生成景区拼图模板: scenicId={}, faceId={}", scenicId, faceId);
// 1. 查询该景区所有启用状态的拼图模板
List<PuzzleTemplateDTO> templateList = puzzleTemplateService.listTemplates(
scenicId, null, 1); // 查询启用状态的模板
if (templateList == null || templateList.isEmpty()) {
log.info("景区不存在启用的拼图模板,跳过生成: scenicId={}", scenicId);
return;
}
log.info("景区存在 {} 个启用的拼图模板,开始逐个生成: scenicId={}", templateList.size(), scenicId);
// 2. 获取景区信息用于动态数据
ScenicV2DTO scenicBasic = scenicRepository.getScenicBasic(scenicId);
// 3. 准备公共动态数据
Map<String, String> baseDynamicData = buildBaseDynamicData(faceId, faceUrl, scenicBasic);
// 4. 使用虚拟线程池并行生成所有模板
java.util.concurrent.atomic.AtomicInteger successCount = new java.util.concurrent.atomic.AtomicInteger(0);
java.util.concurrent.atomic.AtomicInteger failCount = new java.util.concurrent.atomic.AtomicInteger(0);
try (java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
// 为每个模板创建一个异步任务
List<java.util.concurrent.CompletableFuture<Void>> futures = templateList.stream()
.map(template -> java.util.concurrent.CompletableFuture.runAsync(() -> {
try {
generateSingleTemplate(scenicId, faceId, memberId, template, baseDynamicData);
successCount.incrementAndGet();
} catch (Exception e) {
log.error("拼图生成失败: scenicId={}, templateCode={}, templateName={}",
scenicId, template.getCode(), template.getName(), e);
failCount.incrementAndGet();
}
}, executor))
.toList();
// 等待所有任务完成
java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join();
}
log.info("景区拼图模板批量生成完成: scenicId={}, 总数={}, 成功={}, 失败={}",
scenicId, templateList.size(), successCount.get(), failCount.get());
} catch (Exception e) {
// 异步任务失败不影响主流程,仅记录日志
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
}
}, "PuzzleTemplateGenerator-" + scenicId + "-" + faceId).start();
}
/**
* 构建基础动态数据
*/
private Map<String, String> buildBaseDynamicData(Long faceId, String faceUrl, ScenicV2DTO scenicBasic) {
Map<String, String> baseDynamicData = new HashMap<>();
if (faceUrl != null) {
baseDynamicData.put("faceImage", faceUrl);
baseDynamicData.put("userAvatar", faceUrl);
}
baseDynamicData.put("faceId", String.valueOf(faceId));
baseDynamicData.put("scenicName", scenicBasic.getName());
baseDynamicData.put("scenicText", scenicBasic.getName());
baseDynamicData.put("dateStr", DateUtil.format(new Date(), "yyyy.MM.dd"));
return baseDynamicData;
}
/**
* 生成单个拼图模板
*/
private void generateSingleTemplate(Long scenicId, Long faceId, Long memberId,
PuzzleTemplateDTO template,
Map<String, String> baseDynamicData) {
log.info("开始生成拼图: scenicId={}, templateCode={}, templateName={}",
scenicId, template.getCode(), template.getName());
// 构建生成请求
PuzzleGenerateRequest generateRequest = new PuzzleGenerateRequest();
generateRequest.setScenicId(scenicId);
generateRequest.setUserId(memberId);
generateRequest.setFaceId(faceId);
generateRequest.setBusinessType("face_matching");
generateRequest.setTemplateCode(template.getCode());
generateRequest.setOutputFormat("PNG");
generateRequest.setQuality(90);
generateRequest.setDynamicData(new HashMap<>(baseDynamicData));
generateRequest.setRequireRuleMatch(true);
// 调用拼图生成服务
PuzzleGenerateResponse response = puzzleGenerateService.generate(generateRequest);
log.info("拼图生成成功: scenicId={}, templateCode={}, imageUrl={}",
scenicId, template.getCode(), response.getImageUrl());
}
}

View File

@@ -0,0 +1,109 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.service.pc.processor.SourceRelationProcessor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 构建源文件关联Stage
* 负责根据匹配到的样本ID构建member_source关联关系
*
* 职责:
* 1. 从context.sampleListIds读取匹配的样本ID列表
* 2. 调用sourceRelationProcessor.processMemberSources()生成MemberSourceEntity列表
* 3. 更新context.memberSourceList
*
* 前置条件: context.sampleListIds不为空
* 后置条件: context.memberSourceList已设置
*/
@Slf4j
@Component
@StageConfig(
stageId = "build_source_relation",
optionalMode = StageOptionalMode.UNSUPPORT,
description = "构建源文件关联关系"
)
public class BuildSourceRelationStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private SourceRelationProcessor sourceRelationProcessor;
@Override
public String getName() {
return "BuildSourceRelation";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 只有当sampleListIds不为空时才执行
List<Long> sampleListIds = context.getSampleListIds();
if (sampleListIds == null || sampleListIds.isEmpty()) {
// 从searchResult中获取
if (context.getSearchResult() != null) {
sampleListIds = context.getSearchResult().getSampleListIds();
context.setSampleListIds(sampleListIds);
}
}
if (sampleListIds == null || sampleListIds.isEmpty()) {
log.debug("sampleListIds为空,跳过源文件关联,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
List<Long> sampleListIds = context.getSampleListIds();
Long faceId = context.getFaceId();
// 防御性检查:sampleListIds为空
if (sampleListIds == null || sampleListIds.isEmpty()) {
// 尝试从searchResult中获取
if (context.getSearchResult() != null) {
sampleListIds = context.getSearchResult().getSampleListIds();
if (sampleListIds != null && !sampleListIds.isEmpty()) {
context.setSampleListIds(sampleListIds);
} else {
log.debug("sampleListIds为空,跳过源文件关联,faceId={}", faceId);
return StageResult.skipped("sampleListIds为空");
}
} else {
log.debug("sampleListIds为空,跳过源文件关联,faceId={}", faceId);
return StageResult.skipped("sampleListIds为空");
}
}
try {
// 处理源文件关联
List<MemberSourceEntity> memberSourceEntityList =
sourceRelationProcessor.processMemberSources(sampleListIds, context.getFace());
if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) {
log.warn("未找到有效的源文件,faceId={}, sampleListIds={}", faceId, sampleListIds);
return StageResult.skipped("未找到有效的源文件");
}
context.setMemberSourceList(memberSourceEntityList);
log.info("构建源文件关联成功: faceId={}, 关联源文件数={}", faceId, memberSourceEntityList.size());
return StageResult.success(String.format("构建了%d个源文件关联", memberSourceEntityList.size()));
} catch (Exception e) {
log.error("构建源文件关联失败,faceId={}, sampleListIds={}", faceId, sampleListIds, e);
// 源文件关联失败不影响主流程,返回降级
return StageResult.degraded("构建源文件关联失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,73 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.biz.TaskStatusBiz;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.service.pc.helper.ScenicConfigFacade;
import com.ycwl.basic.service.task.TaskService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 创建任务Stage
* 负责根据配置决定是否自动创建任务
*
* 职责:
* 1. 检查face_select_first配置
* 2. 如果配置为false,则调用taskService.autoCreateTaskByFaceId()
* 3. 如果配置为true,则设置任务状态为2(等待用户选择)
*/
@Slf4j
@Component
@StageConfig(
stageId = "create_task",
optionalMode = StageOptionalMode.UNSUPPORT,
description = "根据配置创建视频任务"
)
public class CreateTaskStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private ScenicConfigFacade scenicConfigFacade;
@Autowired
private TaskService taskService;
@Autowired
private TaskStatusBiz taskStatusBiz;
@Override
public String getName() {
return "CreateTask";
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
Long scenicId = context.getFace().getScenicId();
Long faceId = context.getFaceId();
try {
boolean faceSelectFirst = scenicConfigFacade.isFaceSelectFirst(scenicId);
if (!faceSelectFirst) {
// 配置为自动创建任务
taskService.autoCreateTaskByFaceId(faceId);
log.info("自动创建任务成功: faceId={}", faceId);
return StageResult.success("自动创建任务成功");
} else {
// 配置为等待用户选择
taskStatusBiz.setFaceCutStatus(faceId, 2);
log.debug("景区配置 face_select_first=true,跳过自动创建任务: faceId={}", faceId);
return StageResult.skipped("等待用户手动选择");
}
} catch (Exception e) {
log.error("创建任务失败,faceId={}", faceId, e);
// 任务创建失败不影响主流程,返回降级而不是失败
return StageResult.degraded("任务创建失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,121 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.service.pc.helper.SearchResultMerger;
import com.ycwl.basic.service.task.TaskFaceService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 自定义人脸搜索Stage
* 负责根据faceSelectPostMode执行不同的搜索策略
*
* 职责:
* 1. 从context.faceSelectPostMode读取配置
* 2. 模式2: 直接使用用户选择的样本,不搜索
* 3. 模式0/1: 对每个样本搜索,然后合并结果
* 4. 更新context.searchResult
*/
@Slf4j
@Component
@StageConfig(
stageId = "custom_face_search",
optionalMode = StageOptionalMode.FORCE_ON,
description = "根据配置执行自定义人脸搜索"
)
public class CustomFaceSearchStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private TaskFaceService taskFaceService;
@Autowired
private SearchResultMerger resultMerger;
@Override
public String getName() {
return "CustomFaceSearch";
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
Integer faceSelectPostMode = context.getFaceSelectPostMode();
List<FaceSampleEntity> faceSamples = context.getFaceSamples();
List<Long> faceSampleIds = context.getFaceSampleIds();
Long faceId = context.getFaceId();
if (faceSelectPostMode == null) {
faceSelectPostMode = 0; // 默认为并集模式
}
log.debug("face_select_post_mode配置值: {}, faceId={}", faceSelectPostMode, faceId);
try {
SearchFaceRespVo mergedResult;
// 模式2:不搜索,直接使用用户选择的faceSampleIds
if (Integer.valueOf(2).equals(faceSelectPostMode)) {
log.debug("使用模式2:直接使用用户选择的人脸样本,不进行搜索,faceId={}", faceId);
mergedResult = resultMerger.createDirectResult(faceSampleIds);
// 保留原始matchResult
if (context.getFace().getMatchResult() != null) {
mergedResult.setSearchResultJson(context.getFace().getMatchResult());
}
} else {
// 模式0(并集)和模式1(交集):需要进行搜索
List<SearchFaceRespVo> searchResults = new ArrayList<>();
for (FaceSampleEntity faceSample : faceSamples) {
try {
SearchFaceRespVo result = taskFaceService.searchFace(
context.getFaceBodyAdapter(),
String.valueOf(context.getFace().getScenicId()),
faceSample.getFaceUrl(),
"自定义人脸匹配");
if (result != null) {
searchResults.add(result);
}
} catch (Exception e) {
log.warn("人脸样本搜索失败,faceSampleId={}, faceUrl={}, faceId={}",
faceSample.getId(), faceSample.getFaceUrl(), faceId, e);
// 继续处理其他样本,不中断整个流程
}
}
if (searchResults.isEmpty()) {
log.warn("所有人脸样本搜索都失败,faceId={}, faceSampleIds={}", faceId, faceSampleIds);
throw new BaseException("人脸识别失败,请重试");
}
// 根据模式整合多个搜索结果
mergedResult = resultMerger.merge(searchResults, faceSelectPostMode);
}
context.setSearchResult(mergedResult);
context.setSampleListIds(mergedResult.getSampleListIds());
log.info("自定义人脸搜索完成: faceId={}, mode={}, 匹配数={}",
faceId, faceSelectPostMode,
mergedResult.getSampleListIds() != null ? mergedResult.getSampleListIds().size() : 0);
return StageResult.success(String.format("自定义搜索完成,模式=%d", faceSelectPostMode));
} catch (BaseException e) {
throw e;
} catch (Exception e) {
log.error("自定义人脸搜索失败,faceId={}, faceSampleIds={}", faceId, faceSampleIds, e);
return StageResult.failed("自定义人脸搜索失败", e);
}
}
}

View File

@@ -0,0 +1,74 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.mapper.VideoMapper;
import com.ycwl.basic.repository.MemberRelationRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 删除旧关系Stage
* 负责在保存新关系前,删除该人脸的旧数据关系
*
* 职责:
* 1. 删除member_source中该人脸的未购买关系
* 2. 删除member_video中该人脸的未购买关系
* 3. 清除缓存
*/
@Slf4j
@Component
@StageConfig(
stageId = "delete_old_relations",
optionalMode = StageOptionalMode.FORCE_ON,
description = "删除人脸旧关系数据"
)
public class DeleteOldRelationsStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private SourceMapper sourceMapper;
@Autowired
private VideoMapper videoMapper;
@Autowired
private MemberRelationRepository memberRelationRepository;
@Override
public String getName() {
return "DeleteOldRelations";
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
Long faceId = context.getFaceId();
Long memberId = context.getFace().getMemberId();
try {
log.debug("删除人脸旧关系数据:faceId={}, memberId={}", faceId, memberId);
// 1. 删除member_source中的未购买关系
sourceMapper.deleteNotBuyFaceRelation(memberId, faceId);
// 2. 删除member_video中的未购买关系
videoMapper.deleteNotBuyFaceRelations(memberId, faceId);
// 3. 清除缓存
memberRelationRepository.clearSCacheByFace(faceId);
log.debug("人脸旧关系数据删除完成:faceId={}", faceId);
return StageResult.success("旧关系数据已删除");
} catch (Exception e) {
log.error("删除旧关系数据失败,faceId={}", faceId, e);
// 删除失败不影响主流程,返回降级
return StageResult.degraded("删除旧关系数据失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,73 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.service.task.TaskFaceService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 人脸识别Stage
* 负责执行核心的人脸识别搜索
*
* 职责:
* 1. 调用taskFaceService.searchFace()执行人脸搜索
* 2. 将结果存入context.searchResult
* 3. 识别失败则返回FAILED
*/
@Slf4j
@Component
@StageConfig(
stageId = "face_recognition",
optionalMode = StageOptionalMode.FORCE_ON,
description = "执行人脸识别搜索"
)
public class FaceRecognitionStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private TaskFaceService taskFaceService;
@Override
public String getName() {
return "FaceRecognition";
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
try {
SearchFaceRespVo searchResult = taskFaceService.searchFace(
context.getFaceBodyAdapter(),
String.valueOf(context.getFace().getScenicId()),
context.getFace().getFaceUrl(),
"人脸识别");
if (searchResult == null) {
log.warn("人脸识别返回结果为空,faceId={}", context.getFaceId());
return StageResult.failed("人脸识别失败,请换一张试试把~");
}
context.setSearchResult(searchResult);
log.info("人脸识别完成: faceId={}, score={}, 匹配数={}",
context.getFaceId(),
searchResult.getScore(),
searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0);
return StageResult.success(String.format("识别成功,匹配数=%d",
searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0));
} catch (BaseException e) {
throw e;
} catch (Exception e) {
log.error("人脸识别服务调用失败,faceId={}, scenicId={}",
context.getFaceId(), context.getFace().getScenicId(), e);
return StageResult.failed("人脸识别失败,请换一张试试把~", e);
}
}
}

View File

@@ -0,0 +1,86 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.service.pc.processor.FaceRecoveryStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 人脸识别补救Stage
* 负责执行人脸识别的补救逻辑(降级)
*
* 职责:
* 1. 从context.searchResult读取识别结果
* 2. 调用faceRecoveryStrategy.executeFaceRecoveryLogic()执行补救
* 3. 如果触发补救,更新searchResult并返回DEGRADED
* 4. 否则返回SUCCESS
*/
@Slf4j
@Component
@StageConfig(
stageId = "face_recovery",
optionalMode = StageOptionalMode.SUPPORT,
description = "执行人脸识别补救逻辑",
defaultEnabled = true
)
public class FaceRecoveryStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private FaceRecoveryStrategy faceRecoveryStrategy;
@Override
public String getName() {
return "FaceRecovery";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 只有当searchResult不为空时才执行
if (context.getSearchResult() == null) {
log.debug("searchResult为空,跳过补救逻辑,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
SearchFaceRespVo searchResult = context.getSearchResult();
Long faceId = context.getFaceId();
// 防御性检查:searchResult为空
if (searchResult == null) {
log.debug("searchResult为空,跳过补救逻辑,faceId={}", faceId);
return StageResult.skipped("searchResult为空");
}
try {
// 执行补救逻辑(补救逻辑内部会判断是否需要触发)
SearchFaceRespVo recoveredResult = faceRecoveryStrategy.executeFaceRecoveryLogic(
searchResult,
context.getScenicConfig(),
context.getFaceBodyAdapter(),
context.getFace().getScenicId());
// 如果结果发生变化,说明触发了补救
if (recoveredResult != searchResult) {
context.setSearchResult(recoveredResult);
log.info("触发补救逻辑,重新搜索: faceId={}", faceId);
return StageResult.degraded("触发补救逻辑,重新搜索");
}
return StageResult.success("无需补救");
} catch (Exception e) {
log.error("补救逻辑执行失败,faceId={}", faceId, e);
// 补救失败不影响主流程,返回降级
return StageResult.degraded("补救逻辑执行失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,229 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.repository.DeviceRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.stream.Collectors;
/**
* 按设备照片数量限制筛选样本Stage
* 负责根据设备配置的照片数量限制(limit_photo)筛选匹配样本
*
* 职责:
* 1. 从context.faceSamples读取样本实体缓存
* 2. 按设备ID分组
* 3. 对每个设备,根据其limit_photo配置筛选样本:
* - 如果样本数 > limit_photo + 2: 按时间排序,去掉首尾,保留中间limit_photo张
* - 如果样本数 > limit_photo + 1: 按时间排序,去掉尾部,保留前limit_photo张
* - 如果样本数 > limit_photo: 保留前limit_photo张
* - 否则: 保留全部
* 4. 更新context.sampleListIds
*
* 前置条件: context.faceSamples不为空 (由LoadMatchedSamplesStage加载)
* 配置说明: limit_photo=0或null表示不限制数量
*/
@Slf4j
@Component
@StageConfig(
stageId = "filter_by_device_photo_limit",
optionalMode = StageOptionalMode.SUPPORT,
description = "按设备照片数量限制筛选样本",
defaultEnabled = true
)
public class FilterByDevicePhotoLimitStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private DeviceRepository deviceRepository;
@Override
public String getName() {
return "FilterByDevicePhotoLimit";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 检查faceSamples是否为空
List<FaceSampleEntity> faceSamples = context.getFaceSamples();
if (faceSamples == null || faceSamples.isEmpty()) {
log.debug("faceSamples为空,跳过设备照片限制筛选,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
List<FaceSampleEntity> faceSamples = context.getFaceSamples();
List<Long> sampleListIds = context.getSampleListIds();
Long faceId = context.getFaceId();
// 防御性检查:faceSamples为空
if (faceSamples == null || faceSamples.isEmpty()) {
log.debug("faceSamples为空,跳过设备照片限制筛选,faceId={}", faceId);
return StageResult.skipped("faceSamples为空");
}
try {
// 1. 构建样本ID到实体的映射
Map<Long, FaceSampleEntity> sampleMap = faceSamples.stream()
.collect(Collectors.toMap(FaceSampleEntity::getId, sample -> sample, (a, b) -> a));
// 2. 按设备ID分组
Map<Long, List<FaceSampleEntity>> deviceSamplesMap = new LinkedHashMap<>();
Set<Long> passthroughSampleIds = new LinkedHashSet<>();
for (Long sampleId : sampleListIds) {
FaceSampleEntity sample = sampleMap.get(sampleId);
if (sample == null || sample.getDeviceId() == null) {
passthroughSampleIds.add(sampleId); // 无设备ID的样本直接保留
continue;
}
deviceSamplesMap
.computeIfAbsent(sample.getDeviceId(), key -> new ArrayList<>())
.add(sample);
}
// 3. 对每个设备应用照片数量限制
Map<Long, Integer> limitCache = new HashMap<>();
Set<Long> retainedSampleIds = new LinkedHashSet<>(passthroughSampleIds);
for (Map.Entry<Long, List<FaceSampleEntity>> entry : deviceSamplesMap.entrySet()) {
Long deviceId = entry.getKey();
List<FaceSampleEntity> deviceSamples = entry.getValue();
// 读取设备配置
Integer limitPhoto = limitCache.computeIfAbsent(deviceId, id -> {
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(id);
return deviceConfig != null ? deviceConfig.getInteger("limit_photo") : null;
});
List<Long> retainedForDevice = applyLimitForDevice(deviceId, deviceSamples, limitPhoto);
retainedSampleIds.addAll(retainedForDevice);
}
// 4. 按原始顺序保留筛选后的样本ID
List<Long> resultIds = sampleListIds.stream()
.filter(retainedSampleIds::contains)
.collect(Collectors.toList());
// 5. 更新context
context.setSampleListIds(resultIds);
log.info("设备照片数量限制筛选完成: faceId={}, 原始样本数={}, 筛选后数={}",
faceId, sampleListIds.size(), resultIds.size());
return StageResult.success(String.format("设备限制筛选: %d → %d",
sampleListIds.size(), resultIds.size()));
} catch (Exception e) {
log.error("设备照片数量限制筛选失败,faceId={}", faceId, e);
// 筛选失败不影响主流程,返回降级
return StageResult.degraded("设备照片数量限制筛选失败: " + e.getMessage());
}
}
/**
* 对单个设备的样本应用照片数量限制
*/
private List<Long> applyLimitForDevice(Long deviceId, List<FaceSampleEntity> deviceSamples, Integer limitPhoto) {
List<Long> deviceSampleIds = deviceSamples.stream()
.map(FaceSampleEntity::getId)
.collect(Collectors.toList());
// 无限制或限制数量<=0,保留全部
if (limitPhoto == null || limitPhoto <= 0) {
log.debug("设备照片限制: 设备ID={}, 无限制, 保留{}张照片", deviceId, deviceSampleIds.size());
return deviceSampleIds;
}
int sampleCount = deviceSamples.size();
// 样本数 > limit_photo + 2: 按时间排序,去掉首尾
if (sampleCount > (limitPhoto + 2)) {
List<Long> retained = processDeviceSamples(deviceSamples, limitPhoto, true);
log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 去首尾后最终{}张",
deviceId, limitPhoto, sampleCount, retained.size());
return retained;
}
// 样本数 > limit_photo + 1: 按时间排序,去掉尾部
if (sampleCount > (limitPhoto + 1)) {
List<Long> retained = processDeviceSamples(deviceSamples, limitPhoto, false);
log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 去尾部后最终{}张",
deviceId, limitPhoto, sampleCount, retained.size());
return retained;
}
// 样本数 > limit_photo: 保留前limit_photo张
if (sampleCount > limitPhoto) {
List<Long> retained = deviceSamples.stream()
.limit(limitPhoto)
.map(FaceSampleEntity::getId)
.collect(Collectors.toList());
log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 取前{}张",
deviceId, limitPhoto, sampleCount, retained.size());
return retained;
}
// 样本数 <= limit_photo: 保留全部
log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 无需筛选, 保留全部",
deviceId, limitPhoto, sampleCount);
return deviceSampleIds;
}
/**
* 处理设备样本,根据参数决定是否去掉首尾
*
* @param deviceSamples 设备样本列表
* @param limitPhoto 限制数量
* @param removeBoth 是否去掉首尾,true去掉首尾,false只去掉尾部
* @return 处理后的样本ID列表
*/
private List<Long> processDeviceSamples(List<FaceSampleEntity> deviceSamples, int limitPhoto, boolean removeBoth) {
// 创建原始排序的索引映射,用于后续恢复排序
Map<Long, Integer> originalIndexMap = new HashMap<>();
for (int i = 0; i < deviceSamples.size(); i++) {
originalIndexMap.put(deviceSamples.get(i).getId(), i);
}
// 按创建时间排序
List<FaceSampleEntity> sortedByCreateTime = deviceSamples.stream()
.sorted(Comparator.comparing(FaceSampleEntity::getCreateAt))
.collect(Collectors.toList());
// 根据参数决定去掉首尾还是只去掉尾部
List<FaceSampleEntity> filteredSamples;
if (removeBoth && sortedByCreateTime.size() > 2) {
// 去掉首尾
filteredSamples = sortedByCreateTime.subList(1, sortedByCreateTime.size() - 1);
} else if (!removeBoth && sortedByCreateTime.size() > 1) {
// 只去掉尾部
filteredSamples = sortedByCreateTime.subList(0, sortedByCreateTime.size() - 1);
} else {
filteredSamples = sortedByCreateTime;
}
// 取前limitPhoto个
List<FaceSampleEntity> limitedSamples = filteredSamples.stream()
.limit(limitPhoto)
.collect(Collectors.toList());
// 按原始顺序排序
List<Long> resultIds = limitedSamples.stream()
.sorted(Comparator.comparing(sample -> originalIndexMap.get(sample.getId())))
.map(FaceSampleEntity::getId)
.collect(Collectors.toList());
return resultIds;
}
}

View File

@@ -0,0 +1,136 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.stream.Collectors;
/**
* 按时间范围筛选样本Stage
* 负责根据景区配置的游览时间(tour_time)筛选匹配样本
*
* 职责:
* 1. 从context.scenicConfig读取tour_time配置(分钟)
* 2. 从context.faceSamples读取样本实体缓存
* 3. 找到最新的样本,以其拍摄时间为基准
* 4. 筛选出时间范围内(最新样本时间 ± tour_time分钟)的样本
* 5. 更新context.sampleListIds
*
* 前置条件:
* - context.faceSamples不为空 (由LoadMatchedSamplesStage加载)
* - context.scenicConfig配置了tour_time
*
* 配置说明: tour_time=0或null表示不限制时间范围
*/
@Slf4j
@Component
@StageConfig(
stageId = "filter_by_time_range",
optionalMode = StageOptionalMode.SUPPORT,
description = "按游览时间范围筛选样本",
defaultEnabled = true
)
public class FilterByTimeRangeStage extends AbstractPipelineStage<FaceMatchingContext> {
@Override
public String getName() {
return "FilterByTimeRange";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 检查faceSamples是否为空
List<FaceSampleEntity> faceSamples = context.getFaceSamples();
if (faceSamples == null || faceSamples.isEmpty()) {
log.debug("faceSamples为空,跳过时间范围筛选,faceId={}", context.getFaceId());
return false;
}
// 检查是否配置了tour_time
Integer tourMinutes = context.getScenicConfig() != null
? context.getScenicConfig().getInteger("tour_time")
: null;
if (tourMinutes == null || tourMinutes <= 0) {
log.debug("景区未配置tour_time或配置为0,跳过时间范围筛选,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
List<FaceSampleEntity> faceSamples = context.getFaceSamples();
List<Long> sampleListIds = context.getSampleListIds();
Long faceId = context.getFaceId();
// 防御性检查:faceSamples为空
if (faceSamples == null || faceSamples.isEmpty()) {
log.debug("faceSamples为空,跳过时间范围筛选,faceId={}", faceId);
return StageResult.skipped("faceSamples为空");
}
// 防御性检查:tour_time配置
Integer tourMinutes = context.getScenicConfig() != null
? context.getScenicConfig().getInteger("tour_time")
: null;
if (tourMinutes == null || tourMinutes <= 0) {
log.debug("景区未配置tour_time或配置为0,跳过时间范围筛选,faceId={}", faceId);
return StageResult.skipped("未配置tour_time");
}
try {
// 1. 构建样本ID到实体的映射
Map<Long, FaceSampleEntity> sampleMap = faceSamples.stream()
.collect(Collectors.toMap(FaceSampleEntity::getId, sample -> sample, (a, b) -> a));
// 2. 找到最新的样本(拍摄时间最晚)
FaceSampleEntity topMatchSample = faceSamples.stream()
.filter(sample -> sample.getCreateAt() != null)
.max(Comparator.comparing(FaceSampleEntity::getCreateAt))
.orElse(null);
if (topMatchSample == null || topMatchSample.getCreateAt() == null) {
log.warn("未找到有效的样本拍摄时间,保留所有样本,faceId={}", faceId);
return StageResult.success("样本无拍摄时间,保留所有");
}
Date referenceTime = topMatchSample.getCreateAt();
long referenceMillis = referenceTime.getTime();
long tourMillis = tourMinutes * 60 * 1000L;
// 3. 筛选时间范围内的样本
List<Long> filteredIds = sampleListIds.stream()
.filter(sampleId -> {
FaceSampleEntity sample = sampleMap.get(sampleId);
if (sample == null || sample.getCreateAt() == null) {
return false; // 无时间信息的样本被过滤
}
long timeDiff = Math.abs(sample.getCreateAt().getTime() - referenceMillis);
return timeDiff <= tourMillis;
})
.collect(Collectors.toList());
// 4. 更新context
context.setSampleListIds(filteredIds);
log.info("时间范围筛选完成: faceId={}, tour_time={}分钟, 原始样本数={}, 筛选后数={}",
faceId, tourMinutes, sampleListIds.size(), filteredIds.size());
return StageResult.success(String.format("时间筛选: %d → %d",
sampleListIds.size(), filteredIds.size()));
} catch (Exception e) {
log.error("时间范围筛选失败,faceId={}", faceId, e);
// 筛选失败不影响主流程,返回降级
return StageResult.degraded("时间范围筛选失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,66 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.face.pipeline.helper.PuzzleGenerationOrchestrator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 生成拼图模板Stage
* 负责触发景区拼图模板的异步生成任务
*
* 职责:
* 1. 从context读取必要参数(scenicId, faceId, memberId, faceUrl)
* 2. 调用puzzleOrchestrator.generateAllTemplatesAsync()触发异步生成
* 3. 立即返回,不等待生成完成
*
* 业务说明:
* - 拼图生成是异步的,不影响主流程
* - 具体的拼图生成逻辑由PuzzleGenerationOrchestrator负责
* - Stage只负责触发任务,符合"薄Stage,厚Service"原则
*/
@Slf4j
@Component
@StageConfig(
stageId = "generate_puzzle",
optionalMode = StageOptionalMode.SUPPORT,
description = "异步生成拼图模板",
defaultEnabled = true
)
public class GeneratePuzzleStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private PuzzleGenerationOrchestrator puzzleOrchestrator;
@Override
public String getName() {
return "GeneratePuzzle";
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
Long scenicId = context.getFace().getScenicId();
Long faceId = context.getFaceId();
Long memberId = context.getFace().getMemberId();
String faceUrl = context.getFace().getFaceUrl();
try {
// 触发异步生成拼图模板
puzzleOrchestrator.generateAllTemplatesAsync(scenicId, faceId, memberId, faceUrl);
log.debug("拼图模板异步生成任务已提交: scenicId={}, faceId={}", scenicId, faceId);
return StageResult.success("拼图模板已提交异步生成");
} catch (Exception e) {
log.error("提交拼图生成任务失败: scenicId={}, faceId={}", scenicId, faceId, e);
// 拼图生成失败不影响主流程,返回降级
return StageResult.degraded("提交拼图生成任务失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,92 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.service.pc.processor.VideoRecreationHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 处理视频重切Stage
* 负责触发视频重新切片处理
*
* 职责:
* 1. 从context读取必要参数(scenicId, memberSourceList, faceId, memberId, sampleListIds, isNew)
* 2. 调用videoRecreationHandler.handleVideoRecreation()触发视频重切
*
* 前置条件: context.memberSourceList不为空
* 业务说明: 视频重切用于根据人脸识别结果重新生成个性化视频片段
*/
@Slf4j
@Component
@StageConfig(
stageId = "handle_video_recreation",
optionalMode = StageOptionalMode.SUPPORT,
description = "处理视频重切逻辑",
defaultEnabled = true
)
public class HandleVideoRecreationStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private VideoRecreationHandler videoRecreationHandler;
@Override
public String getName() {
return "HandleVideoRecreation";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 只有当memberSourceList不为空时才执行
List<MemberSourceEntity> memberSourceList = context.getMemberSourceList();
if (memberSourceList == null || memberSourceList.isEmpty()) {
log.debug("memberSourceList为空,跳过视频重切,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
Long scenicId = context.getFace().getScenicId();
List<MemberSourceEntity> memberSourceEntityList = context.getMemberSourceList();
Long faceId = context.getFaceId();
Long memberId = context.getFace().getMemberId();
List<Long> sampleListIds = context.getSampleListIds();
boolean isNew = context.isNew();
// 防御性检查:memberSourceList为空
if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) {
log.debug("memberSourceList为空,跳过视频重切,faceId={}", faceId);
return StageResult.skipped("memberSourceList为空");
}
try {
// 处理视频重切
videoRecreationHandler.handleVideoRecreation(
scenicId,
memberSourceEntityList,
faceId,
memberId,
sampleListIds,
isNew);
log.info("视频重切处理完成: faceId={}, scenicId={}, 源文件数={}",
faceId, scenicId, memberSourceEntityList.size());
return StageResult.success("视频重切处理完成");
} catch (Exception e) {
log.error("处理视频重切失败,faceId={}", faceId, e);
// 视频重切失败不影响主流程,返回降级
return StageResult.degraded("视频重切处理失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,73 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.mapper.FaceSampleMapper;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 加载人脸样本Stage
* 负责加载用户选择的人脸样本数据
*
* 职责:
* 1. 从context.faceSampleIds读取用户选择的样本ID列表
* 2. 调用faceSampleMapper.listByIds()加载样本实体
* 3. 更新context.faceSamples
*/
@Slf4j
@Component
@StageConfig(
stageId = "load_face_samples",
optionalMode = StageOptionalMode.FORCE_ON,
description = "加载用户选择的人脸样本"
)
public class LoadFaceSamplesStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private FaceSampleMapper faceSampleMapper;
@Override
public String getName() {
return "LoadFaceSamples";
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
List<Long> faceSampleIds = context.getFaceSampleIds();
Long faceId = context.getFaceId();
if (faceSampleIds == null || faceSampleIds.isEmpty()) {
log.warn("faceSampleIds为空,faceId={}", faceId);
return StageResult.failed("faceSampleIds不能为空");
}
try {
List<FaceSampleEntity> faceSamples = faceSampleMapper.listByIds(faceSampleIds);
if (faceSamples.isEmpty()) {
log.warn("未找到指定的人脸样本,faceSampleIds: {}, faceId={}", faceSampleIds, faceId);
throw new BaseException("未找到指定的人脸样本");
}
context.setFaceSamples(faceSamples);
log.info("加载人脸样本成功: faceId={}, sampleCount={}", faceId, faceSamples.size());
return StageResult.success(String.format("加载了%d个人脸样本", faceSamples.size()));
} catch (BaseException e) {
throw e;
} catch (Exception e) {
log.error("加载人脸样本失败,faceId={}, faceSampleIds={}", faceId, faceSampleIds, e);
return StageResult.failed("加载人脸样本失败", e);
}
}
}

View File

@@ -0,0 +1,95 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.mapper.FaceSampleMapper;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 加载匹配样本实体Stage
* 负责将sampleListIds对应的样本实体加载到context.faceSamples,供后续Stage使用
*
* 职责:
* 1. 从context.sampleListIds读取匹配到的样本ID列表
* 2. 调用faceSampleMapper.listByIds()批量加载样本实体
* 3. 更新context.faceSamples作为样本实体缓存
*
* 设计目的:
* - 避免后续多个Stage重复调用faceSampleMapper.listByIds()
* - 统一加载时机,提高性能
* - 为后续筛选Stage提供样本实体数据源
*
* 前置条件: context.sampleListIds不为空
*
* 应用场景: 自定义匹配场景,在CustomFaceSearchStage之后
*/
@Slf4j
@Component
@StageConfig(
stageId = "load_matched_samples",
optionalMode = StageOptionalMode.UNSUPPORT,
description = "加载匹配样本实体到缓存"
)
public class LoadMatchedSamplesStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private FaceSampleMapper faceSampleMapper;
@Override
public String getName() {
return "LoadMatchedSamples";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 检查sampleListIds是否为空
List<Long> sampleListIds = context.getSampleListIds();
if (sampleListIds == null || sampleListIds.isEmpty()) {
log.debug("sampleListIds为空,跳过加载匹配样本,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
List<Long> sampleListIds = context.getSampleListIds();
Long faceId = context.getFaceId();
// 防御性检查:如果sampleListIds为空,直接跳过
if (sampleListIds == null || sampleListIds.isEmpty()) {
log.debug("sampleListIds为空,跳过加载匹配样本,faceId={}", faceId);
return StageResult.skipped("sampleListIds为空");
}
try {
// 批量加载样本实体
List<FaceSampleEntity> faceSamples = faceSampleMapper.listByIds(sampleListIds);
if (faceSamples == null || faceSamples.isEmpty()) {
log.warn("未找到任何匹配样本实体,faceId={}, sampleListIds={}", faceId, sampleListIds);
return StageResult.skipped("未找到匹配样本实体");
}
// 存入context缓存,供后续Stage使用
context.setFaceSamples(faceSamples);
log.info("加载匹配样本实体完成: faceId={}, 样本数={}", faceId, faceSamples.size());
return StageResult.success(String.format("已加载%d个样本实体", faceSamples.size()));
} catch (Exception e) {
log.error("加载匹配样本实体失败,faceId={}", faceId, e);
// 加载失败影响后续流程,返回失败
return StageResult.failed("加载匹配样本实体失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,98 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.repository.MemberRelationRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 持久化关联关系Stage
* 负责过滤并保存源文件关联关系到数据库
*
* 职责:
* 1. 从context.memberSourceList读取关联关系
* 2. 过滤已存在的关联关系和无效的source引用
* 3. 保存到数据库
* 4. 清除缓存
*/
@Slf4j
@Component
@StageConfig(
stageId = "persist_relations",
optionalMode = StageOptionalMode.FORCE_ON,
description = "持久化源文件关联关系"
)
public class PersistRelationsStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private SourceMapper sourceMapper;
@Autowired
private MemberRelationRepository memberRelationRepository;
@Override
public String getName() {
return "PersistRelations";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 只有当memberSourceList不为空时才执行
List<MemberSourceEntity> memberSourceList = context.getMemberSourceList();
if (memberSourceList == null || memberSourceList.isEmpty()) {
log.debug("memberSourceList为空,跳过持久化,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
List<MemberSourceEntity> memberSourceEntityList = context.getMemberSourceList();
Long faceId = context.getFaceId();
// 防御性检查:memberSourceList为空
if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) {
log.debug("memberSourceList为空,跳过持久化,faceId={}", faceId);
return StageResult.skipped("memberSourceList为空");
}
try {
// 1. 过滤已存在的关联关系
List<MemberSourceEntity> existingFiltered = sourceMapper.filterExistingRelations(memberSourceEntityList);
// 2. 过滤无效的source引用
List<MemberSourceEntity> validFiltered = sourceMapper.filterValidSourceRelations(existingFiltered);
if (!validFiltered.isEmpty()) {
// 3. 保存到数据库
sourceMapper.addRelations(validFiltered);
log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}",
faceId, memberSourceEntityList.size(), validFiltered.size());
} else {
log.warn("没有有效的关联关系可创建: faceId={}, 原始数量={}",
faceId, memberSourceEntityList.size());
return StageResult.skipped("没有有效的关联关系可创建");
}
// 4. 清除缓存
memberRelationRepository.clearSCacheByFace(faceId);
return StageResult.success(String.format("持久化了%d条关联关系", validFiltered.size()));
} catch (Exception e) {
log.error("持久化关联关系失败,faceId={}", faceId, e);
return StageResult.failed("保存关联关系失败", e);
}
}
}

View File

@@ -0,0 +1,91 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.pc.ScenicService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 准备上下文Stage
* 负责加载人脸实体、景区配置、识别适配器等必要数据
*
* 职责:
* 1. 加载FaceEntity(如不存在则失败)
* 2. 检查是否人工选择(是则跳过,除非isNew=true)
* 3. 加载ScenicConfigManager和IFaceBodyAdapter
* 4. 更新Context
*/
@Slf4j
@Component
@StageConfig(
stageId = "prepare_context",
optionalMode = StageOptionalMode.FORCE_ON,
description = "准备人脸匹配上下文数据"
)
public class PrepareContextStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private FaceRepository faceRepository;
@Autowired
private ScenicRepository scenicRepository;
@Autowired
private ScenicService scenicService;
@Override
public String getName() {
return "PrepareContext";
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
Long faceId = context.getFaceId();
boolean isNew = context.isNew();
// 1. 加载人脸实体
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.warn("人脸不存在,faceId: {}", faceId);
return StageResult.failed("人脸不存在,faceId: " + faceId);
}
context.setFace(face);
log.debug("加载人脸实体成功: faceId={}, memberId={}, scenicId={}",
faceId, face.getMemberId(), face.getScenicId());
// 2. 检查是否人工选择
// 人工选择的无需重新匹配(新用户除外)
if (!isNew && Integer.valueOf(1).equals(face.getIsManual())) {
log.info("人工选择的人脸,无需匹配,faceId: {}", faceId);
return StageResult.skipped("人工选择的人脸,无需重新匹配");
}
// 3. 加载景区配置
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
context.setScenicConfig(scenicConfig);
log.debug("加载景区配置成功: scenicId={}", face.getScenicId());
// 4. 加载人脸识别适配器
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(face.getScenicId());
if (faceBodyAdapter == null) {
log.error("无法获取人脸识别适配器,scenicId: {}", face.getScenicId());
return StageResult.failed("人脸识别服务不可用,请稍后再试");
}
context.setFaceBodyAdapter(faceBodyAdapter);
log.debug("加载人脸识别适配器成功: scenicId={}", face.getScenicId());
return StageResult.success("上下文准备完成");
}
}

View File

@@ -0,0 +1,92 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.service.pc.processor.BuyStatusProcessor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 处理购买状态Stage
* 负责更新源文件的购买状态标记
*
* 职责:
* 1. 从context.memberSourceList读取源文件关联列表
* 2. 从context.freeSourceIds读取免费源文件ID列表
* 3. 调用buyStatusProcessor.processBuyStatus()更新购买状态
*
* 前置条件: context.memberSourceList不为空
* 业务说明: 购买状态影响前端显示和用户下载权限
*/
@Slf4j
@Component
@StageConfig(
stageId = "process_buy_status",
optionalMode = StageOptionalMode.SUPPORT,
description = "处理源文件购买状态",
defaultEnabled = true
)
public class ProcessBuyStatusStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private BuyStatusProcessor buyStatusProcessor;
@Override
public String getName() {
return "ProcessBuyStatus";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 只有当memberSourceList不为空时才执行
List<MemberSourceEntity> memberSourceList = context.getMemberSourceList();
if (memberSourceList == null || memberSourceList.isEmpty()) {
log.debug("memberSourceList为空,跳过购买状态处理,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
List<MemberSourceEntity> memberSourceEntityList = context.getMemberSourceList();
List<Long> freeSourceIds = context.getFreeSourceIds();
Long memberId = context.getFace().getMemberId();
Long scenicId = context.getFace().getScenicId();
Long faceId = context.getFaceId();
// 防御性检查:memberSourceList为空
if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) {
log.debug("memberSourceList为空,跳过购买状态处理,faceId={}", faceId);
return StageResult.skipped("memberSourceList为空");
}
try {
// 处理购买状态
buyStatusProcessor.processBuyStatus(
memberSourceEntityList,
freeSourceIds,
memberId,
scenicId,
faceId);
log.info("购买状态处理完成: faceId={}, 源文件数={}, 免费数={}",
faceId, memberSourceEntityList.size(),
freeSourceIds != null ? freeSourceIds.size() : 0);
return StageResult.success("购买状态处理完成");
} catch (Exception e) {
log.error("处理购买状态失败,faceId={}", faceId, e);
// 购买状态处理失败不影响主流程,返回降级
return StageResult.degraded("购买状态处理失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,89 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.service.pc.processor.SourceRelationProcessor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 处理免费源文件Stage
* 负责根据业务规则确定哪些源文件可以免费访问
*
* 职责:
* 1. 从context.memberSourceList读取源文件关联列表
* 2. 调用sourceRelationProcessor.processFreeSourceLogic()确定免费源文件
* 3. 更新context.freeSourceIds
*
* 前置条件: context.memberSourceList不为空
* 后置条件: context.freeSourceIds已设置
*/
@Slf4j
@Component
@StageConfig(
stageId = "process_free_source",
optionalMode = StageOptionalMode.SUPPORT,
description = "处理免费源文件逻辑",
defaultEnabled = true
)
public class ProcessFreeSourceStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private SourceRelationProcessor sourceRelationProcessor;
@Override
public String getName() {
return "ProcessFreeSource";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 只有当memberSourceList不为空时才执行
List<MemberSourceEntity> memberSourceList = context.getMemberSourceList();
if (memberSourceList == null || memberSourceList.isEmpty()) {
log.debug("memberSourceList为空,跳过免费逻辑,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
List<MemberSourceEntity> memberSourceEntityList = context.getMemberSourceList();
Long scenicId = context.getFace().getScenicId();
boolean isNew = context.isNew();
Long faceId = context.getFaceId();
// 防御性检查:memberSourceList为空
if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) {
log.debug("memberSourceList为空,跳过免费逻辑,faceId={}", faceId);
return StageResult.skipped("memberSourceList为空");
}
try {
// 处理免费逻辑
List<Long> freeSourceIds = sourceRelationProcessor.processFreeSourceLogic(
memberSourceEntityList, scenicId, isNew);
context.setFreeSourceIds(freeSourceIds);
log.info("免费源文件处理完成: faceId={}, 总源文件数={}, 免费数={}",
faceId, memberSourceEntityList.size(), freeSourceIds != null ? freeSourceIds.size() : 0);
return StageResult.success(String.format("确定了%d个免费源文件",
freeSourceIds != null ? freeSourceIds.size() : 0));
} catch (Exception e) {
log.error("处理免费源文件失败,faceId={}", faceId, e);
// 免费逻辑失败不影响主流程,返回降级
return StageResult.degraded("免费源文件处理失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,71 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.face.pipeline.enums.FaceMatchingScene;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.service.pc.helper.FaceMetricsRecorder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 记录自定义匹配次数Stage
* 负责记录自定义人脸匹配调用次数,用于监控
*
* 职责:
* 1. 仅在CUSTOM_MATCHING场景执行
* 2. 调用metricsRecorder.recordCustomMatchCount()记录次数
*/
@Slf4j
@Component
@StageConfig(
stageId = "record_custom_match_metrics",
optionalMode = StageOptionalMode.SUPPORT,
description = "记录自定义匹配指标",
defaultEnabled = true
)
public class RecordCustomMatchMetricsStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private FaceMetricsRecorder metricsRecorder;
@Override
public String getName() {
return "RecordCustomMatchMetrics";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 只有自定义匹配场景才执行
if (context.getScene() != FaceMatchingScene.CUSTOM_MATCHING) {
log.debug("非自定义匹配场景,跳过记录,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
Long faceId = context.getFaceId();
// 防御性检查:只有自定义匹配场景才执行
if (context.getScene() != FaceMatchingScene.CUSTOM_MATCHING) {
log.debug("非自定义匹配场景,跳过记录,faceId={}", faceId);
return StageResult.skipped("非自定义匹配场景");
}
try {
metricsRecorder.recordCustomMatchCount(faceId);
log.debug("记录自定义匹配次数: faceId={}", faceId);
return StageResult.success("自定义匹配指标记录完成");
} catch (Exception e) {
log.error("记录自定义匹配指标失败,faceId={}", faceId, e);
// 指标记录失败不影响主流程,返回降级
return StageResult.degraded("指标记录失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,63 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.service.pc.helper.FaceMetricsRecorder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 记录识别次数Stage
* 负责记录人脸识别调用次数,用于监控和防重复检查
*
* 职责:
* 1. 调用metricsRecorder.recordRecognitionCount()记录识别次数
* 2. 检查searchResult是否触发低阈值检测
* 3. 如果是,调用metricsRecorder.recordLowThreshold()记录
*/
@Slf4j
@Component
@StageConfig(
stageId = "record_metrics",
optionalMode = StageOptionalMode.SUPPORT,
description = "记录人脸识别指标",
defaultEnabled = true
)
public class RecordMetricsStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private FaceMetricsRecorder metricsRecorder;
@Override
public String getName() {
return "RecordMetrics";
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
Long faceId = context.getFaceId();
try {
// 1. 记录识别次数
metricsRecorder.recordRecognitionCount(faceId);
log.debug("记录识别次数: faceId={}", faceId);
// 2. 检查是否触发低阈值检测
if (context.getSearchResult() != null && context.getSearchResult().isLowThreshold()) {
metricsRecorder.recordLowThreshold(faceId);
log.debug("触发低阈值检测,记录faceId: {}", faceId);
}
return StageResult.success("识别指标记录完成");
} catch (Exception e) {
log.error("记录识别指标失败,faceId={}", faceId, e);
// 指标记录失败不影响主流程,返回降级
return StageResult.degraded("指标记录失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,69 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.biz.TaskStatusBiz;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 设置任务状态Stage
* 负责为新用户设置任务状态为待处理
*
* 职责:
* 1. 仅在isNew=true时执行
* 2. 调用taskStatusBiz.setFaceCutStatus(faceId, 0)
*/
@Slf4j
@Component
@StageConfig(
stageId = "set_task_status",
optionalMode = StageOptionalMode.FORCE_ON,
description = "设置新用户任务状态"
)
public class SetTaskStatusStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private TaskStatusBiz taskStatusBiz;
@Override
public String getName() {
return "SetTaskStatus";
}
@Override
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
// 只有新用户才执行
if (!context.isNew()) {
log.debug("非新用户,跳过设置任务状态,faceId={}", context.getFaceId());
return false;
}
return true;
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
Long faceId = context.getFaceId();
// 防御性检查:只有新用户才执行
if (!context.isNew()) {
log.debug("非新用户,跳过设置任务状态,faceId={}", faceId);
return StageResult.skipped("非新用户");
}
try {
taskStatusBiz.setFaceCutStatus(faceId, 0);
log.debug("设置新用户任务状态: faceId={}, status=0", faceId);
return StageResult.success("任务状态已设置");
} catch (Exception e) {
log.error("设置任务状态失败,faceId={}", faceId, e);
// 任务状态设置失败不影响主流程,返回降级
return StageResult.degraded("任务状态设置失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,95 @@
package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.repository.FaceRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Date;
import java.util.stream.Collectors;
/**
* 更新人脸结果Stage
* 负责将人脸识别结果保存到数据库
*
* 职责:
* 1. 从context.searchResult读取识别结果
* 2. 更新FaceEntity(score、matchResult、firstMatchRate、matchSampleIds)
* 3. 清除缓存
*/
@Slf4j
@Component
@StageConfig(
stageId = "update_face_result",
optionalMode = StageOptionalMode.FORCE_ON,
description = "更新人脸识别结果到数据库"
)
public class UpdateFaceResultStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired
private FaceMapper faceMapper;
@Autowired
private FaceRepository faceRepository;
@Override
public String getName() {
return "UpdateFaceResult";
}
@Override
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
SearchFaceRespVo searchResult = context.getSearchResult();
if (searchResult == null) {
log.warn("searchResult为空,跳过更新人脸结果,faceId={}", context.getFaceId());
return StageResult.skipped("searchResult为空");
}
try {
FaceEntity originalFace = context.getFace();
Long faceId = context.getFaceId();
FaceEntity faceEntity = new FaceEntity();
faceEntity.setId(faceId);
faceEntity.setScore(searchResult.getScore());
faceEntity.setMatchResult(searchResult.getSearchResultJson());
if (searchResult.getFirstMatchRate() != null) {
faceEntity.setFirstMatchRate(BigDecimal.valueOf(searchResult.getFirstMatchRate()));
}
if (searchResult.getSampleListIds() != null) {
faceEntity.setMatchSampleIds(searchResult.getSampleListIds().stream()
.map(String::valueOf)
.collect(Collectors.joining(",")));
}
faceEntity.setCreateAt(new Date());
faceEntity.setScenicId(originalFace.getScenicId());
faceEntity.setMemberId(originalFace.getMemberId());
faceEntity.setFaceUrl(originalFace.getFaceUrl());
faceMapper.update(faceEntity);
faceRepository.clearFaceCache(faceId);
log.debug("人脸结果更新成功:faceId={}, score={}, sampleCount={}",
faceId, searchResult.getScore(),
searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0);
return StageResult.success("人脸结果更新成功");
} catch (Exception e) {
log.error("更新人脸结果失败,faceId={}", context.getFaceId(), e);
return StageResult.failed("保存人脸识别结果失败", e);
}
}
}

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);
@@ -420,7 +418,7 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
ByteArrayOutputStream baos = null;
try {
// 下载图片
URL url = new URL(imageUrl.replace("oss-cn-shanghai.aliyuncs.com", "oss-cn-shanghai-internal.aliyuncs.com"));
URL url = new URL(imageUrl);
image = ImageIO.read(url);
if (image == null) {
log.error("无法读取图片,URL: {}", imageUrl);

View File

@@ -0,0 +1,70 @@
package com.ycwl.basic.handler;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
/**
* Map类型的TypeHandler,用于处理JSON字段与Map<String, Integer>的互转
* 主要用于机位快速评价等自由格式的JSON存储
*/
@Slf4j
public class MapTypeHandler extends BaseTypeHandler<Map<String, Integer>> {
private final ObjectMapper objectMapper = new ObjectMapper();
private final TypeReference<Map<String, Integer>> typeReference = new TypeReference<Map<String, Integer>>() {};
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Map<String, Integer> parameter, JdbcType jdbcType) throws SQLException {
try {
String json = objectMapper.writeValueAsString(parameter);
ps.setString(i, json);
} catch (JsonProcessingException e) {
log.error("序列化Map为JSON失败", e);
throw new SQLException("序列化Map为JSON失败", e);
}
}
@Override
public Map<String, Integer> getNullableResult(ResultSet rs, String columnName) throws SQLException {
String json = rs.getString(columnName);
return parseJson(json);
}
@Override
public Map<String, Integer> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String json = rs.getString(columnIndex);
return parseJson(json);
}
@Override
public Map<String, Integer> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String json = cs.getString(columnIndex);
return parseJson(json);
}
/**
* 解析JSON字符串为Map
*/
private Map<String, Integer> parseJson(String json) {
if (json == null || json.trim().isEmpty()) {
return new HashMap<>();
}
try {
return objectMapper.readValue(json, typeReference);
} catch (JsonProcessingException e) {
log.error("解析JSON为Map失败, json={}", json, e);
return new HashMap<>();
}
}
}

View File

@@ -0,0 +1,77 @@
package com.ycwl.basic.handler;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
/**
* 嵌套Map类型的TypeHandler,用于处理JSON字段与Map<String, Map<String, Integer>>的互转
* 主要用于机位评价功能:外层key为机位ID,内层Map为该机位的各维度评分
*
* 数据格式示例:
* {
* "12345": {"清晰度": 5, "构图": 4, "色彩": 5, "整体效果": 4},
* "12346": {"清晰度": 4, "构图": 5, "色彩": 4, "整体效果": 5}
* }
*/
@Slf4j
public class NestedMapTypeHandler extends BaseTypeHandler<Map<String, Map<String, Integer>>> {
private final ObjectMapper objectMapper = new ObjectMapper();
private final TypeReference<Map<String, Map<String, Integer>>> typeReference =
new TypeReference<Map<String, Map<String, Integer>>>() {};
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Map<String, Map<String, Integer>> parameter, JdbcType jdbcType) throws SQLException {
try {
String json = objectMapper.writeValueAsString(parameter);
ps.setString(i, json);
} catch (JsonProcessingException e) {
log.error("序列化嵌套Map为JSON失败", e);
throw new SQLException("序列化嵌套Map为JSON失败", e);
}
}
@Override
public Map<String, Map<String, Integer>> getNullableResult(ResultSet rs, String columnName) throws SQLException {
String json = rs.getString(columnName);
return parseJson(json);
}
@Override
public Map<String, Map<String, Integer>> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String json = rs.getString(columnIndex);
return parseJson(json);
}
@Override
public Map<String, Map<String, Integer>> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String json = cs.getString(columnIndex);
return parseJson(json);
}
/**
* 解析JSON字符串为嵌套Map
*/
private Map<String, Map<String, Integer>> parseJson(String json) {
if (json == null || json.trim().isEmpty()) {
return new HashMap<>();
}
try {
return objectMapper.readValue(json, typeReference);
} catch (JsonProcessingException e) {
log.error("解析JSON为嵌套Map失败, json={}", json, e);
return new HashMap<>();
}
}
}

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,374 @@
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 com.ycwl.basic.pipeline.core.PipelineContext;
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 implements PipelineContext {
// ==================== 核心字段(构造时必填)====================
/**
* 处理过程唯一标识
* 用于 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-禁用
*/
@Override
public boolean isStageEnabled(String stageId, boolean defaultEnabled) {
return stageEnabledMap.getOrDefault(stageId, defaultEnabled);
}
/**
* 判断指定Stage是否启用(默认为false)
*
* @param stageId Stage唯一标识
* @return true-启用, false-禁用
*/
@Override
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);
}
/**
* 清理所有临时文件
*/
@Override
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,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,63 @@
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", "源图片超分辨率增强"),
/**
* AI相机照片增强场景
* AI相机拍摄的照片进行超分辨率和质量增强
*/
AI_CAM_ENHANCE("ai_cam_enhance", "AI相机照片增强");
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,25 @@
package com.ycwl.basic.image.pipeline.exception;
import com.ycwl.basic.pipeline.exception.PipelineException;
/**
* 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.core.PhotoProcessContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.model.Crop;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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,126 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.File;
/**
* 图像缩放Stage
* 支持按比例放大或缩小图片
*/
@Slf4j
@StageConfig(
stageId = "image_resize",
optionalMode = StageOptionalMode.SUPPORT,
description = "图像缩放处理",
defaultEnabled = true
)
public class ImageResizeStage extends AbstractPipelineStage<PhotoProcessContext> {
private final double scaleFactor;
/**
* 构造函数
* @param scaleFactor 缩放比例(例如: 1.5表示放大1.5倍, 0.333表示缩小到1/3)
*/
public ImageResizeStage(double scaleFactor) {
if (scaleFactor <= 0) {
throw new IllegalArgumentException("scaleFactor must be positive");
}
this.scaleFactor = scaleFactor;
}
@Override
public String getName() {
return "ImageResizeStage";
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.skipped("当前文件不存在");
}
BufferedImage originalImage = null;
BufferedImage resizedImage = null;
try {
log.debug("开始图像缩放处理: file={}, scaleFactor={}", currentFile.getName(), scaleFactor);
// 读取原图
originalImage = ImageIO.read(currentFile);
if (originalImage == null) {
return StageResult.failed("无法读取图片文件");
}
int originalWidth = originalImage.getWidth();
int originalHeight = originalImage.getHeight();
// 计算新尺寸
int newWidth = (int) Math.round(originalWidth * scaleFactor);
int newHeight = (int) Math.round(originalHeight * scaleFactor);
// 检查尺寸是否合理
if (newWidth <= 0 || newHeight <= 0) {
return StageResult.failed("缩放后尺寸无效: " + newWidth + "x" + newHeight);
}
// 创建缩放后的图像
resizedImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = resizedImage.createGraphics();
try {
// 设置高质量渲染选项
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 执行缩放
g2d.drawImage(originalImage, 0, 0, newWidth, newHeight, null);
} finally {
g2d.dispose();
}
// 保存缩放后的图片
File resizedFile = context.getTempFileManager().createTempFile("resized", ".jpg");
ImageIO.write(resizedImage, "jpg", resizedFile);
if (!resizedFile.exists() || resizedFile.length() == 0) {
return StageResult.failed("缩放后图片保存失败");
}
// 更新处理后的文件
context.updateProcessedFile(resizedFile);
log.info("图像缩放完成: {}x{} -> {}x{} (比例: {})",
originalWidth, originalHeight,
newWidth, newHeight,
scaleFactor);
return StageResult.success(String.format("缩放完成 (%dx%d -> %dx%d)",
originalWidth, originalHeight, newWidth, newHeight));
} catch (Exception e) {
log.error("图像缩放失败: {}", e.getMessage(), e);
return StageResult.failed("缩放失败: " + e.getMessage(), e);
} finally {
// 释放图像资源
if (originalImage != null) {
originalImage.flush();
}
if (resizedImage != null) {
resizedImage.flush();
}
}
}
}

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.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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.PhotoProcessContext;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.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.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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.core.PhotoProcessContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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,82 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.mapper.PrinterMapper;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
/**
* 更新MemberPrint记录Stage
* 用于更新member_print表中的cropUrl字段
*/
@Slf4j
@StageConfig(
stageId = "update_member_print",
optionalMode = StageOptionalMode.UNSUPPORT,
description = "更新MemberPrint记录",
defaultEnabled = true
)
public class UpdateMemberPrintStage extends AbstractPipelineStage<PhotoProcessContext> {
private final PrinterMapper printerMapper;
private final Integer memberPrintId;
private final Long memberId;
private final Long scenicId;
/**
* 构造函数
* @param printerMapper PrinterMapper实例
* @param memberPrintId MemberPrint记录ID
* @param memberId 用户ID
* @param scenicId 景区ID
*/
public UpdateMemberPrintStage(PrinterMapper printerMapper, Integer memberPrintId, Long memberId, Long scenicId) {
this.printerMapper = printerMapper;
this.memberPrintId = memberPrintId;
this.memberId = memberId;
this.scenicId = scenicId;
}
@Override
public String getName() {
return "UpdateMemberPrintStage";
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
String resultUrl = context.getResultUrl();
if (resultUrl == null || resultUrl.trim().isEmpty()) {
return StageResult.skipped("结果URL为空,跳过更新");
}
if (memberPrintId == null || memberId == null || scenicId == null) {
log.warn("MemberPrint更新参数不完整: memberPrintId={}, memberId={}, scenicId={}",
memberPrintId, memberId, scenicId);
return StageResult.skipped("更新参数不完整");
}
try {
log.debug("开始更新MemberPrint记录: id={}, newCropUrl={}", memberPrintId, resultUrl);
// 更新cropUrl字段
int rows = printerMapper.setPhotoCropped(memberId, scenicId, memberPrintId.longValue(), resultUrl, null);
if (rows > 0) {
log.info("MemberPrint记录更新成功: id={}, cropUrl已更新", memberPrintId);
return StageResult.success("更新成功");
} else {
log.warn("MemberPrint记录更新失败: 可能记录不存在, id={}", memberPrintId);
return StageResult.degraded("更新失败,记录可能不存在");
}
} catch (Exception e) {
log.error("更新MemberPrint记录异常: id={}", memberPrintId, e);
// 更新失败不影响整个流程,使用降级状态
return StageResult.degraded("更新异常: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,103 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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,44 @@
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;
/**
* 缩放倍数,用于将所有定位和大小乘以该倍数
* 默认值为 1.0(不缩放)
*/
@Builder.Default
private final Double scale = 1.0;
}

View File

@@ -0,0 +1,190 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageType;
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.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.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);
}
// 从 config 读取缩放倍数
Double scale = config.getScale();
if (scale != null) {
info.setScale(scale);
}
// 根据旋转状态自己处理 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

@@ -23,6 +23,23 @@ public class WatermarkInfo {
private String dtFormat;
private String datetimeLine;
/**
* 四边偏移(像素值),正数表示向内偏移
* 例如: offsetLeft=40 表示左边界向右收缩40像素,所有左对齐元素随之向右移动
* null 表示使用默认值(通常为0)
*/
private Integer offsetTop;
private Integer offsetBottom;
private Integer offsetLeft;
private Integer offsetRight;
/**
* 缩放倍数,用于将所有定位和大小乘以该倍数
* 例如: scale=2.0 表示所有尺寸和位置都放大2倍
* null 表示使用默认值1.0(不缩放)
*/
private Double scale;
public String getDatetimeLine() {
if (datetimeLine == null) {
datetimeLine = DateUtil.format(datetime, dtFormat);

View File

@@ -47,7 +47,7 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
public static int OFFSET_Y = 15;
public static Color BG_COLOR = Color.WHITE;
public static int QRCODE_SIZE = 150;
public static double QRCODE_LEFT_MARGIN_RATIO = 0.075; // 二维码距离左边缘的图片宽度比例
public static double QRCODE_LEFT_MARGIN_RATIO = 0.05; // 二维码距离左边缘的图片宽度比例
public static int QRCODE_OFFSET_Y = -35;
public static int SCENIC_FONT_SIZE = 42;
@@ -56,8 +56,23 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
public static Color datetimeColor = Color.white;
public static double TEXT_RIGHT_MARGIN_RATIO = 0.05; // 文字距离右边缘的图片宽度比例
// 默认四边偏移值(像素),当 WatermarkInfo 中未提供时使用
public static int DEFAULT_OFFSET_TOP = 0;
public static int DEFAULT_OFFSET_BOTTOM = 0;
public static int DEFAULT_OFFSET_LEFT = 0;
public static int DEFAULT_OFFSET_RIGHT = 0;
@Override
public File process(WatermarkInfo info) throws ImageWatermarkException {
// 获取缩放倍数,默认为1.0(不缩放)
double scale = info.getScale() != null ? info.getScale() : 1.0;
// 获取四边偏移值,优先使用传入的值,否则使用默认值,并应用缩放
int offsetTop = (int) ((info.getOffsetTop() != null ? info.getOffsetTop() : DEFAULT_OFFSET_TOP) * scale);
int offsetBottom = (int) ((info.getOffsetBottom() != null ? info.getOffsetBottom() : DEFAULT_OFFSET_BOTTOM) * scale);
int offsetLeft = (int) ((info.getOffsetLeft() != null ? info.getOffsetLeft() : DEFAULT_OFFSET_LEFT) * scale);
int offsetRight = (int) ((info.getOffsetRight() != null ? info.getOffsetRight() : DEFAULT_OFFSET_RIGHT) * scale);
BufferedImage baseImage;
BufferedImage qrcodeImage;
BufferedImage faceImage = null;
@@ -74,17 +89,26 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
} catch (IOException e) {
throw new ImageWatermarkException("图片打开失败");
}
// 应用缩放到所有常量
int scaledExtraBorder = (int) (EXTRA_BORDER_PX * scale);
int scaledOffsetY = (int) (OFFSET_Y * scale);
int scaledQrcodeSize = (int) (QRCODE_SIZE * scale);
int scaledQrcodeOffsetY = (int) (QRCODE_OFFSET_Y * scale);
int scaledScenicFontSize = (int) (SCENIC_FONT_SIZE * scale);
int scaledDatetimeFontSize = (int) (DATETIME_FONT_SIZE * scale);
// 新图像画布
BufferedImage newImage = new BufferedImage(baseImage.getWidth() + 2 * EXTRA_BORDER_PX, baseImage.getHeight() + 2 * EXTRA_BORDER_PX, BufferedImage.TYPE_INT_RGB);
BufferedImage newImage = new BufferedImage(baseImage.getWidth() + 2 * scaledExtraBorder, baseImage.getHeight() + 2 * scaledExtraBorder, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = newImage.createGraphics();
g2d.setColor(BG_COLOR);
g2d.fillRect(0, 0, newImage.getWidth(), newImage.getHeight());
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.drawImage(baseImage, EXTRA_BORDER_PX, EXTRA_BORDER_PX, null);
int newQrcodeHeight = QRCODE_SIZE;
g2d.drawImage(baseImage, scaledExtraBorder, scaledExtraBorder, null);
int newQrcodeHeight = scaledQrcodeSize;
int newQrcodeWidth = (int) (newQrcodeHeight * 1.0 / qrcodeImage.getHeight() * qrcodeImage.getWidth());
Font scenicFont = new Font(defaultFontName, Font.BOLD, SCENIC_FONT_SIZE);
Font datetimeFont = new Font(defaultFontName, Font.BOLD, DATETIME_FONT_SIZE);
Font scenicFont = new Font(defaultFontName, Font.BOLD, scaledScenicFontSize);
Font datetimeFont = new Font(defaultFontName, Font.BOLD, scaledDatetimeFontSize);
FontMetrics scenicFontMetrics = g2d.getFontMetrics(scenicFont);
FontMetrics datetimeFontMetrics = g2d.getFontMetrics(datetimeFont);
int scenicLineHeight = scenicFontMetrics.getHeight();
@@ -92,15 +116,16 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
int scenicLineWidth = scenicFontMetrics.stringWidth(info.getScenicLine());
int datetimeLineWidth = datetimeFontMetrics.stringWidth(info.getDatetimeLine());
// 二维码放置在左下角,距离左边缘图片宽度的5%
int qrcodeOffsetX = (int) (newImage.getWidth() * QRCODE_LEFT_MARGIN_RATIO);
int qrcodeOffsetY = EXTRA_BORDER_PX + baseImage.getHeight() - OFFSET_Y - newQrcodeHeight;
// 二维码放置在左下角,距离左边缘图片宽度的5%,再加上左侧偏移
int qrcodeOffsetX = (int) (newImage.getWidth() * QRCODE_LEFT_MARGIN_RATIO) + offsetLeft;
int qrcodeOffsetY = scaledExtraBorder + baseImage.getHeight() - scaledOffsetY - newQrcodeHeight - offsetBottom;
Shape originalClip = g2d.getClip();
// 创建比二维码大10像素的白色圆形背景
int whiteCircleSize = Math.max(newQrcodeWidth, newQrcodeHeight) + 10;
// 创建比二维码大10像素的白色圆形背景(10像素也要缩放)
int whiteCirclePadding = (int) (10 * scale);
int whiteCircleSize = Math.max(newQrcodeWidth, newQrcodeHeight) + whiteCirclePadding;
int whiteCircleX = qrcodeOffsetX - (whiteCircleSize - newQrcodeWidth) / 2;
int whiteCircleY = qrcodeOffsetY + QRCODE_OFFSET_Y - (whiteCircleSize - newQrcodeHeight) / 2;
int whiteCircleY = qrcodeOffsetY + scaledQrcodeOffsetY - (whiteCircleSize - newQrcodeHeight) / 2;
// 绘制白色圆形背景
g2d.setColor(Color.WHITE);
@@ -110,7 +135,7 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
// 用白色圆形尺寸裁切二维码(保持二维码原始尺寸,但用大圆裁切)
Ellipse2D qrcodeCircle = new Ellipse2D.Double(whiteCircleX, whiteCircleY, whiteCircleSize, whiteCircleSize);
g2d.setClip(qrcodeCircle);
g2d.drawImage(qrcodeImage, qrcodeOffsetX, qrcodeOffsetY + QRCODE_OFFSET_Y, newQrcodeWidth, newQrcodeHeight, null);
g2d.drawImage(qrcodeImage, qrcodeOffsetX, qrcodeOffsetY + scaledQrcodeOffsetY, newQrcodeWidth, newQrcodeHeight, null);
g2d.setClip(originalClip);
// 在圆形二维码中央绘制圆形头像
@@ -118,7 +143,7 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
// 计算圆形头像的尺寸和位置
int avatarDiameter = (int) (newQrcodeHeight * 0.45);
int avatarX = qrcodeOffsetX + (newQrcodeWidth - avatarDiameter) / 2;
int avatarY = qrcodeOffsetY + QRCODE_OFFSET_Y + (newQrcodeHeight - avatarDiameter) / 2;
int avatarY = qrcodeOffsetY + scaledQrcodeOffsetY + (newQrcodeHeight - avatarDiameter) / 2;
// 保存当前的渲染设置
RenderingHints originalHints = g2d.getRenderingHints();
@@ -137,10 +162,10 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
double faceHeight = faceImage.getHeight();
double scaleX = avatarDiameter / faceWidth;
double scaleY = avatarDiameter / faceHeight;
double scale = Math.max(scaleX, scaleY); // 使用较大的缩放比例以填满圆形
double faceScale = Math.max(scaleX, scaleY); // 使用较大的缩放比例以填满圆形
int scaledWidth = (int) (faceWidth * scale);
int scaledHeight = (int) (faceHeight * scale);
int scaledWidth = (int) (faceWidth * faceScale);
int scaledHeight = (int) (faceHeight * faceScale);
// 计算居中位置
int faceDrawX = avatarX + (avatarDiameter - scaledWidth) / 2;
@@ -155,7 +180,7 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
}
// 计算文字与二维码垂直居中对齐的Y坐标
int qrcodeTop = qrcodeOffsetY + QRCODE_OFFSET_Y;
int qrcodeTop = qrcodeOffsetY + scaledQrcodeOffsetY;
int qrcodeBottom = qrcodeTop + newQrcodeHeight;
int qrcodeCenter = (qrcodeTop + qrcodeBottom) / 2;
@@ -165,8 +190,8 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
// 计算第一行文字的Y坐标(基线位置),使两行文字整体垂直居中于二维码
int textStartY = qrcodeCenter - totalTextHeight / 2 + scenicFontMetrics.getAscent();
// 文字右对齐,放置在右下角,距离右边缘图片宽度的5%
int textRightX = newImage.getWidth() - (int) (newImage.getWidth() * TEXT_RIGHT_MARGIN_RATIO);
// 文字右对齐,放置在右下角,距离右边缘图片宽度的5%,再减去右侧偏移
int textRightX = newImage.getWidth() - (int) (newImage.getWidth() * TEXT_RIGHT_MARGIN_RATIO) - offsetRight;
g2d.setFont(scenicFont);
g2d.setColor(scenicColor);

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,
@@ -51,12 +60,141 @@ public interface DeviceV2Client {
@RequestParam(value = "no", required = false) String no,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "isActive", required = false) Integer isActive,
@RequestParam(value = "scenicId", required = false) Long scenicId);
@RequestParam(value = "scenicId", required = false) Long scenicId,
@RequestParam(value = "scenicIds", required = false) String scenicIds);
/**
* 根据配置条件筛选设备
*/
@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

@@ -67,11 +67,28 @@ public class DeviceIntegrationService {
}
public PageResponse<DeviceV2DTO> listDevices(Integer page, Integer pageSize, String name, String no,
String type, Integer isActive, Long scenicId) {
log.debug("分页查询设备列表, page: {}, pageSize: {}, name: {}, no: {}, type: {}, isActive: {}, scenicId: {}",
page, pageSize, name, no, type, isActive, scenicId);
String type, Integer isActive, Long scenicId, String scenicIds) {
log.debug("分页查询设备列表, page: {}, pageSize: {}, name: {}, no: {}, type: {}, isActive: {}, scenicId: {}, scenicIds: {}",
page, pageSize, name, no, type, isActive, scenicId, scenicIds);
// 参数优先级处理:scenicId 优先于 scenicIds
Long finalScenicId = null;
String finalScenicIds = null;
if (scenicId != null) {
// 优先使用单个 scenicId(向后兼容)
finalScenicId = scenicId;
finalScenicIds = null;
log.debug("使用单个 scenicId 参数: {}", finalScenicId);
} else if (scenicIds != null && !scenicIds.trim().isEmpty()) {
// 使用 scenicIds
finalScenicId = null;
finalScenicIds = scenicIds;
log.debug("使用 scenicIds 参数: {}", finalScenicIds);
}
CommonResponse<PageResponse<DeviceV2DTO>> response = deviceV2Client.listDevices(
page, pageSize, name, no, type, isActive, scenicId);
page, pageSize, name, no, type, isActive, finalScenicId, finalScenicIds);
return handleResponse(response, "分页查询设备列表失败");
}
@@ -163,14 +180,14 @@ public class DeviceIntegrationService {
* 获取景区的IPC设备列表
*/
public PageResponse<DeviceV2DTO> getScenicIpcDevices(Long scenicId, Integer page, Integer pageSize) {
return listDevices(page, pageSize, null, null, "IPC", 1, scenicId);
return listDevices(page, pageSize, null, null, "IPC", 1, scenicId, null);
}
/**
* 获取景区的所有激活设备
*/
public PageResponse<DeviceV2DTO> getScenicActiveDevices(Long scenicId, Integer page, Integer pageSize) {
return listDevices(page, pageSize, null, null, null, 1, scenicId);
return listDevices(page, pageSize, null, null, null, 1, scenicId, null);
}
/**

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