Compare commits

...

69 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
186 changed files with 12694 additions and 770 deletions

View File

@@ -273,6 +273,13 @@
<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>

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

@@ -147,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;
}
@@ -215,11 +230,15 @@ 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:
@@ -295,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

@@ -76,6 +76,14 @@ 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;
}
@@ -118,8 +126,14 @@ public class PriceBiz {
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))
@@ -267,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;
@@ -279,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;
}
@@ -287,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

@@ -4,45 +4,23 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.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() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
// Configure type handling to prevent ClassCastException
PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class)
.build();
objectMapper.activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL);
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig();
configuration = configuration.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer)).entryTtl(Duration.ofMinutes(1));
return configuration;
}
public class CustomRedisCacheManager {
@Autowired
private ObjectMapper objectMapper;
/**
* 处理redis连接工具显示redis key值显示乱码问题,value值没处理

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

@@ -4,14 +4,14 @@ import cn.hutool.http.HttpUtil;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.image.enhancer.adapter.BceImageEnhancer;
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
import com.ycwl.basic.image.pipeline.core.Pipeline;
import com.ycwl.basic.image.pipeline.core.PipelineBuilder;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.stages.DownloadStage;
import com.ycwl.basic.image.pipeline.stages.ImageEnhanceStage;
import com.ycwl.basic.image.pipeline.stages.ImageSRStage;
import com.ycwl.basic.image.pipeline.stages.SourcePhotoUpdateStage;
import com.ycwl.basic.image.pipeline.stages.CleanupStage;
import com.ycwl.basic.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;

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

@@ -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;
@@ -341,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

@@ -205,26 +205,31 @@ public class AppPuzzleController {
// 设置模板ID
vo.setTemplateId(record.getTemplateId());
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), 5, record.getTemplateId());
if (isBuyRespVO.isBuy()) {
IsBuyRespVO isBuyScenic = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), 5, face.getScenicId());
if (isBuyScenic.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);
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), 5, record.getTemplateId());
if (isBuyRespVO.isBuy()) {
vo.setIsBuy(1);
} else {
vo.setFreeCount(1);
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

@@ -88,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

@@ -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

@@ -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

@@ -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,14 +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);
if (faceSample == null) {
response.setStatus(404);
return;
}
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(faceSample.getScenicId());
WxMpUtil.generateUnlimitedWXAQRCode(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), "pages/printer/from_sample", sampleId.toString(), qrcode);
WxMpUtil.generateUnlimitedWXAQRCode(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), targetPath, sampleId.toString(), qrcode);
// 设置响应头
response.setContentType("image/jpeg");
@@ -100,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

@@ -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

@@ -418,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

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

View File

@@ -8,6 +8,7 @@ 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;
@@ -23,7 +24,7 @@ import java.util.function.Consumer;
*/
@Getter
@Setter
public class PhotoProcessContext {
public class PhotoProcessContext implements PipelineContext {
// ==================== 核心字段(构造时必填)====================
@@ -192,6 +193,7 @@ public class PhotoProcessContext {
* @param defaultEnabled 默认值(当配置未指定时使用)
* @return true-启用, false-禁用
*/
@Override
public boolean isStageEnabled(String stageId, boolean defaultEnabled) {
return stageEnabledMap.getOrDefault(stageId, defaultEnabled);
}
@@ -202,6 +204,7 @@ public class PhotoProcessContext {
* @param stageId Stage唯一标识
* @return true-启用, false-禁用
*/
@Override
public boolean isStageEnabled(String stageId) {
return stageEnabledMap.getOrDefault(stageId, false);
}
@@ -292,6 +295,7 @@ public class PhotoProcessContext {
/**
* 清理所有临时文件
*/
@Override
public void cleanup() {
if (cleaned) {
return;

View File

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

View File

@@ -22,7 +22,13 @@ public enum PipelineScene {
* 源图片超分辨率增强场景
* IPC设备拍摄的源图片进行质量提升
*/
SOURCE_PHOTO_SUPER_RESOLUTION("source_photo_sr", "源图片超分辨率增强");
SOURCE_PHOTO_SUPER_RESOLUTION("source_photo_sr", "源图片超分辨率增强"),
/**
* AI相机照片增强场景
* AI相机拍摄的照片进行超分辨率和质量增强
*/
AI_CAM_ENHANCE("ai_cam_enhance", "AI相机照片增强");
private final String code;
private final String description;

View File

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

View File

@@ -1,5 +1,7 @@
package com.ycwl.basic.image.pipeline.exception;
import com.ycwl.basic.pipeline.exception.PipelineException;
/**
* Stage执行异常
*/

View File

@@ -1,10 +1,10 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.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;
/**

View File

@@ -1,11 +1,11 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.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;

View File

@@ -1,12 +1,12 @@
package com.ycwl.basic.image.pipeline.stages;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import 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;

View File

@@ -2,12 +2,12 @@ package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.enhancer.adapter.BceImageEnhancer;
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import 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;

View File

@@ -1,13 +1,13 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.model.Crop;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
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;

View File

@@ -1,11 +1,11 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.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;

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

@@ -2,12 +2,12 @@ package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.enhancer.adapter.BceImageSR;
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import 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;

View File

@@ -1,8 +1,8 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
public class NoOpStage extends AbstractPipelineStage<PhotoProcessContext> {
@Override

View File

@@ -1,11 +1,11 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.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;

View File

@@ -1,11 +1,11 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.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;

View File

@@ -1,10 +1,10 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.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;

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

@@ -1,10 +1,10 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.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;

View File

@@ -34,4 +34,11 @@ public class WatermarkConfig {
* 二维码文件
*/
private final File qrcodeFile;
/**
* 缩放倍数,用于将所有定位和大小乘以该倍数
* 默认值为 1.0(不缩放)
*/
@Builder.Default
private final Double scale = 1.0;
}

View File

@@ -1,15 +1,15 @@
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.image.pipeline.annotation.StageConfig;
import com.ycwl.basic.image.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.core.StageResult;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.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;
@@ -170,6 +170,12 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
info.setQrcodeFile(qrcodeFile);
}
// 从 config 读取缩放倍数
Double scale = config.getScale();
if (scale != null) {
info.setScale(scale);
}
// 根据旋转状态自己处理 offsetLeft
if (context.isRotationApplied()) {
if (context.getImageRotation() == 90) {

View File

@@ -33,6 +33,13 @@ public class WatermarkInfo {
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

@@ -64,11 +64,14 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
@Override
public File process(WatermarkInfo info) throws ImageWatermarkException {
// 获取四边偏移值,优先使用传入的值,否则使用默认值
int offsetTop = info.getOffsetTop() != null ? info.getOffsetTop() : DEFAULT_OFFSET_TOP;
int offsetBottom = info.getOffsetBottom() != null ? info.getOffsetBottom() : DEFAULT_OFFSET_BOTTOM;
int offsetLeft = info.getOffsetLeft() != null ? info.getOffsetLeft() : DEFAULT_OFFSET_LEFT;
int offsetRight = info.getOffsetRight() != null ? info.getOffsetRight() : DEFAULT_OFFSET_RIGHT;
// 获取缩放倍数,默认为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;
@@ -86,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();
@@ -106,13 +118,14 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
// 二维码放置在左下角,距离左边缘图片宽度的5%,再加上左侧偏移
int qrcodeOffsetX = (int) (newImage.getWidth() * QRCODE_LEFT_MARGIN_RATIO) + offsetLeft;
int qrcodeOffsetY = EXTRA_BORDER_PX + baseImage.getHeight() - OFFSET_Y - newQrcodeHeight - offsetBottom;
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);
@@ -122,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);
// 在圆形二维码中央绘制圆形头像
@@ -130,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();
@@ -149,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;
@@ -167,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;

View File

@@ -60,7 +60,8 @@ 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);
/**
* 根据配置条件筛选设备

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);
}
/**

View File

@@ -0,0 +1,17 @@
package com.ycwl.basic.integration.glm;
import java.util.List;
/**
* 智谱 GLM 模型调用抽象。
*/
public interface GlmClient {
/**
* 流式回复,实时回调分片,同时返回完整文本。
*/
String streamReply(Long faceId,
Long memberId,
String traceId,
List<ai.z.openapi.service.model.ChatMessage> messages,
java.util.function.Consumer<String> chunkConsumer);
}

View File

@@ -0,0 +1,118 @@
package com.ycwl.basic.integration.glm;
import ai.z.openapi.ZhipuAiClient;
import ai.z.openapi.service.model.ChatCompletionCreateParams;
import ai.z.openapi.service.model.ChatCompletionResponse;
import ai.z.openapi.service.model.ChatMessage;
import ai.z.openapi.service.model.ChatMessageRole;
import ai.z.openapi.service.model.ChatThinking;
import ai.z.openapi.service.model.Delta;
import ai.z.openapi.service.model.ModelData;
import io.reactivex.rxjava3.core.Flowable;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* 智谱 GLM 官方 SDK 调用实现,流式拆分文本。
*/
@Slf4j
@Component
public class GlmClientImpl implements GlmClient {
private static final String DEFAULT_MODEL = "glm-4.5-airx";
private final ZhipuAiClient client;
public GlmClientImpl(@Value("${zhipu.api-key:}") String apiKeyFromCfg) {
String apiKey = StringUtils.isNotBlank(apiKeyFromCfg) ? apiKeyFromCfg : System.getenv("ZHIPU_API_KEY");
if (StringUtils.isBlank(apiKey)) {
log.warn("未配置 ZHIPU_API_KEY,GLM 调用将返回占位结果");
client = null;
} else {
client = ZhipuAiClient.builder().apiKey(apiKey).build();
}
}
@Override
public String streamReply(Long faceId,
Long memberId,
String traceId,
List<ChatMessage> messages,
Consumer<String> chunkConsumer) {
if (client == null) {
String fallback = "GLM 未配置 API KEY,无法调用。";
if (chunkConsumer != null) {
chunkConsumer.accept(fallback);
}
return fallback;
}
List<ChatMessage> finalMessages = messages == null ? new ArrayList<>() : new ArrayList<>(messages);
if (finalMessages.isEmpty()) {
finalMessages.add(ChatMessage.builder()
.role(ChatMessageRole.USER.value())
.content("你好")
.build());
}
finalMessages.addFirst(ChatMessage.builder()
.role(ChatMessageRole.SYSTEM.value())
.content("你是一个智能销售,然后用户是实际想购买的人。你需要尽可能推广景区生成的Vlog、视频、照片等内容给用户,鼓励购买。注意,返回内容需要为普通文本格式,禁止出现Markdown标记。")
.build());
finalMessages.addFirst(ChatMessage.builder()
.role(ChatMessageRole.SYSTEM.value())
.content("注意,返回内容需要为普通文本格式,禁止使用Markdown格式进行返回。")
.build());
ChatCompletionCreateParams request = ChatCompletionCreateParams.builder()
.model(DEFAULT_MODEL)
.messages(finalMessages)
.thinking(ChatThinking.builder().type("enabled").build())
.stream(true)
.maxTokens(4096)
.temperature(0.8f)
.build();
ChatCompletionResponse response = client.chat().createChatCompletion(request);
if (!response.isSuccess()) {
String msg = "GLM 调用失败: " + response.getMsg();
log.warn(msg);
if (chunkConsumer != null) {
chunkConsumer.accept(msg);
}
return msg;
}
StringBuilder sb = new StringBuilder();
Flowable<ModelData> flowable = response.getFlowable();
flowable.blockingSubscribe(
data -> {
if (data.getChoices() == null || data.getChoices().isEmpty()) {
return;
}
Delta delta = data.getChoices().getFirst().getDelta();
if (delta == null) {
return;
}
String piece = delta.getContent();
if (StringUtils.isNotBlank(piece)) {
sb.append(piece);
if (chunkConsumer != null) {
chunkConsumer.accept(piece);
}
}
},
error -> {
log.error("GLM 流式调用异常", error);
String err = "GLM 调用异常:" + error.getMessage();
sb.append(err);
if (chunkConsumer != null) {
chunkConsumer.accept(err);
}
}
);
return sb.toString();
}
}

View File

@@ -10,6 +10,7 @@ import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
import com.ycwl.basic.integration.kafka.dto.FaceProcessingMessage;
import com.ycwl.basic.integration.kafka.scheduler.AccountFaceSchedulerManager;
import com.ycwl.basic.integration.kafka.scheduler.AccountSchedulerContext;
import com.ycwl.basic.mapper.FaceSampleAiCamMapper;
import com.ycwl.basic.mapper.FaceSampleMapper;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.repository.DeviceRepository;
@@ -42,8 +43,10 @@ import java.util.Date;
public class FaceProcessingKafkaService {
private static final String ZT_FACE_TOPIC = "zt-face";
private static final String ZT_AI_CAM_FACE_TOPIC = "zt-ai-cam-face";
private final FaceSampleMapper faceSampleMapper;
private final FaceSampleAiCamMapper faceSampleAiCamMapper;
private final TaskFaceService taskFaceService;
private final ScenicService scenicService;
private final DeviceRepository deviceRepository;
@@ -115,6 +118,67 @@ public class FaceProcessingKafkaService {
}
}
/**
* 消费AI相机发送的人脸处理消息 (zt-ai-cam-face)
* 逻辑与 zt-face 类似,但写入不同的表,且人脸库分组依据不同
*/
@KafkaListener(topics = ZT_AI_CAM_FACE_TOPIC, containerFactory = "manualCommitKafkaListenerContainerFactory")
public void processAiCamFaceMessage(String message, Acknowledgment ack) {
Long faceSampleId = null;
try {
FaceProcessingMessage faceMessage = JacksonUtil.parseObject(message, FaceProcessingMessage.class);
faceSampleId = faceMessage.getFaceSampleId();
log.debug("接收AI相机人脸消息: scenicId={}, deviceId={}, faceSampleId={}",
faceMessage.getScenicId(), faceMessage.getDeviceId(), faceSampleId);
// ========== 第一步: 同步写入数据库 (FaceSampleAiCam) ==========
boolean saved = saveAiCamFaceSample(faceMessage, faceSampleId);
if (!saved) {
log.error("AI相机数据库写入失败, 不提交识别任务, faceSampleId={}", faceSampleId);
ack.acknowledge();
return;
}
log.debug("AI相机数据库写入成功, faceSampleId={}, status=0", faceSampleId);
// ========== 第二步: 获取账号调度器上下文 ==========
AccountSchedulerContext schedulerCtx = getSchedulerContextForScenic(faceMessage.getScenicId());
if (schedulerCtx == null) {
log.error("无法获取调度器上下文, faceSampleId={}", faceSampleId);
updateAiCamFaceSampleStatusSafely(faceSampleId, -1);
ack.acknowledge();
return;
}
// ========== 第三步: 提交到账号专属调度器 ==========
boolean submitted = schedulerCtx.getScheduler().submit(() -> {
processAiCamFaceRecognitionAsync(faceMessage);
});
if (submitted) {
log.debug("AI相机任务已提交到调度器, account={}, cloudType={}, faceSampleId={}, schedulerQueue={}",
schedulerCtx.getAccountKey(),
schedulerCtx.getCloudType(),
faceSampleId,
schedulerCtx.getScheduler().getQueueSize());
} else {
log.error("调度器队列已满, account={}, faceSampleId={}",
schedulerCtx.getAccountKey(), faceSampleId);
updateAiCamFaceSampleStatusSafely(faceSampleId, -1);
}
ack.acknowledge();
} catch (Exception e) {
log.error("处理AI相机人脸消息异常, faceSampleId={}", faceSampleId, e);
if (faceSampleId != null) {
updateAiCamFaceSampleStatusSafely(faceSampleId, -1);
}
ack.acknowledge();
}
}
/**
* 根据景区获取对应的账号调度器上下文
* 关键: 按 accessKeyId/appId 隔离,而非按云类型
@@ -343,4 +407,105 @@ public class FaceProcessingKafkaService {
return false;
}
}
/**
* 安全地更新AI相机人脸样本状态
*/
private void updateAiCamFaceSampleStatusSafely(Long faceSampleId, Integer status) {
if (faceSampleId == null) {
return;
}
try {
faceSampleAiCamMapper.updateStatus(faceSampleId, status);
log.debug("AI相机样本状态更新成功: faceSampleId={}, status={}", faceSampleId, status);
} catch (Exception e) {
log.error("AI相机样本状态更新失败(非致命): faceSampleId={}, status={}", faceSampleId, status, e);
}
}
/**
* 保存AI相机人脸样本数据到数据库
*/
private boolean saveAiCamFaceSample(FaceProcessingMessage faceMessage, Long externalFaceId) {
try {
FaceSampleEntity faceSample = new FaceSampleEntity();
faceSample.setId(externalFaceId); // 使用外部传入的ID
faceSample.setScenicId(faceMessage.getScenicId());
faceSample.setDeviceId(faceMessage.getDeviceId());
faceSample.setStatus(0); // 初始状态
faceSample.setFaceUrl(faceMessage.getFaceUrl());
if (faceMessage.getShotTime() != null) {
faceSample.setCreateAt(faceMessage.getShotTime());
} else {
faceSample.setCreateAt(new Date());
}
faceSampleAiCamMapper.add(faceSample);
return true;
} catch (Exception e) {
log.error("保存AI相机人脸样本数据失败, 外部faceId: {}, scenicId: {}, deviceId: {}",
externalFaceId, faceMessage.getScenicId(), faceMessage.getDeviceId(), e);
return false;
}
}
/**
* 异步执行AI相机人脸识别处理逻辑
* 区别: 使用 deviceId 作为人脸库分组
*/
private void processAiCamFaceRecognitionAsync(FaceProcessingMessage message) {
Long faceSampleId = message.getFaceSampleId();
Long scenicId = message.getScenicId();
Long deviceId = message.getDeviceId();
try {
updateAiCamFaceSampleStatusSafely(faceSampleId, 1);
log.debug("开始AI相机人脸识别, faceSampleId={}, status=1", faceSampleId);
IFaceBodyAdapter adapter = scenicService.getScenicFaceBodyAdapter(scenicId);
if (adapter == null) {
log.error("adapter 不存在, scenicId={}, faceSampleId={}", scenicId, faceSampleId);
updateAiCamFaceSampleStatusSafely(faceSampleId, -1);
return;
}
// 使用 "AiCam" + deviceId 作为人脸库分组
String dbName = "AiCam" + deviceId;
taskFaceService.assureFaceDb(adapter, dbName);
String faceUniqueId = faceSampleId.toString();
// groupName 使用 deviceId
AddFaceResp addFaceResp = adapter.addFace(
dbName,
faceSampleId.toString(),
message.getFaceUrl(),
faceUniqueId
);
if (addFaceResp != null) {
faceSampleAiCamMapper.updateScore(faceSampleId, addFaceResp.getScore());
updateAiCamFaceSampleStatusSafely(faceSampleId, 2);
log.info("AI相机人脸识别成功, faceSampleId={}, score={}, status=2",
faceSampleId, addFaceResp.getScore());
// 预订任务逻辑与原逻辑保持一致 (如果需要)
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId);
if (deviceConfig != null &&
Integer.valueOf(1).equals(deviceConfig.getInteger("enable_pre_book"))) {
DynamicTaskGenerator.addTask(faceSampleId);
}
} else {
log.warn("addFace 返回 null, faceSampleId={}", faceSampleId);
updateAiCamFaceSampleStatusSafely(faceSampleId, -1);
}
} catch (Exception e) {
log.error("AI相机人脸识别异常, faceSampleId={}", faceSampleId, e);
updateAiCamFaceSampleStatusSafely(faceSampleId, -1);
}
}
}

View File

@@ -39,7 +39,8 @@ public interface ScenicV2Client {
CommonResponse<PageResponse<ScenicV2DTO>> listScenics(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String name);
@RequestParam(required = false) String name,
@RequestParam(required = false) String scenicIds);
// ==================== Scenic Config V2 Operations ====================

View File

@@ -63,9 +63,10 @@ public class ScenicIntegrationService {
return handleResponse(response, "筛选景区失败");
}
public PageResponse<ScenicV2DTO> listScenics(Integer page, Integer pageSize, Integer status, String name) {
log.debug("分页查询景区列表, page: {}, pageSize: {}, status: {}, name: {}", page, pageSize, status, name);
CommonResponse<PageResponse<ScenicV2DTO>> response = scenicV2Client.listScenics(page, pageSize, status, name);
public PageResponse<ScenicV2DTO> listScenics(Integer page, Integer pageSize, Integer status, String name, String scenicIds) {
log.debug("分页查询景区列表, page: {}, pageSize: {}, status: {}, name: {}, scenicIds: {}",
page, pageSize, status, name, scenicIds);
CommonResponse<PageResponse<ScenicV2DTO>> response = scenicV2Client.listScenics(page, pageSize, status, name, scenicIds);
return handleResponse(response, "分页查询景区列表失败");
}

View File

@@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
@@ -41,10 +42,13 @@ import static com.ycwl.basic.constant.JwtRoleConstant.MERCHANT;
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Lazy
@Autowired
RedisTemplate redisTemplate;
@Lazy
@Autowired
private ScenicAccountMapper scenicAccountMapper;
@Lazy
@Autowired
private AdminUserMapper adminUserMapper;

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.mapper;
import com.ycwl.basic.model.mobile.chat.entity.FaceChatConversationEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface FaceChatConversationMapper {
FaceChatConversationEntity findByFaceId(@Param("faceId") Long faceId);
FaceChatConversationEntity getById(@Param("id") Long id);
int insert(FaceChatConversationEntity entity);
int updateStatus(@Param("id") Long id, @Param("status") String status);
}

View File

@@ -0,0 +1,24 @@
package com.ycwl.basic.mapper;
import com.ycwl.basic.model.mobile.chat.entity.FaceChatMessageEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface FaceChatMessageMapper {
Integer maxSeqForUpdate(@Param("conversationId") Long conversationId);
int insert(FaceChatMessageEntity entity);
List<FaceChatMessageEntity> listByConversation(@Param("conversationId") Long conversationId,
@Param("cursor") Integer cursor,
@Param("limit") Integer limit);
/**
* 按 seq 倒序获取最近若干条消息,用于拼接上下文。
*/
List<FaceChatMessageEntity> listRecentByConversation(@Param("conversationId") Long conversationId,
@Param("limit") Integer limit);
}

View File

@@ -0,0 +1,21 @@
package com.ycwl.basic.mapper;
import com.ycwl.basic.model.pc.faceDetectLog.entity.FaceDetectLogAiCamEntity;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* AI相机人脸识别日志Mapper
*/
@Mapper
public interface FaceDetectLogAiCamMapper {
int add(FaceDetectLogAiCamEntity entity);
/**
* 根据faceId查询所有识别记录
* @param faceId 人脸ID
* @return 识别记录列表
*/
List<FaceDetectLogAiCamEntity> listByFaceId(Long faceId);
}

View File

@@ -0,0 +1,32 @@
package com.ycwl.basic.mapper;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.faceSample.req.FaceSampleReqQuery;
import com.ycwl.basic.model.pc.faceSample.resp.FaceSampleRespVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
import java.util.List;
/**
* AiCam人脸样本Mapper
*/
@Mapper
public interface FaceSampleAiCamMapper {
List<FaceSampleRespVO> list(FaceSampleReqQuery faceSampleReqQuery);
FaceSampleRespVO getById(Long id);
int add(FaceSampleEntity faceSample);
int deleteById(Long id);
int deleteByIds(@Param("list") List<Long> ids);
int update(FaceSampleEntity faceSample);
List<FaceSampleEntity> listByIds(List<Long> list);
FaceSampleEntity getEntity(Long faceSampleId);
List<FaceSampleEntity> listEntityBeforeDate(Long scenicId, Date endDate);
void updateScore(Long id, Float score);
void updateStatus(Long id, Integer status);
}

View File

@@ -82,6 +82,7 @@ public interface SourceMapper {
List<SourceEntity> listVideoByFaceRelation(Long faceId);
List<SourceEntity> listImageByFaceRelation(Long faceId);
List<SourceEntity> listAiCamImageByFaceRelation(Long faceId);
List<MemberSourceEntity> listByFaceRelation(Long faceId, Integer type);
SourceEntity getEntity(Long id);
@@ -148,4 +149,20 @@ public interface SourceMapper {
* @return source实体
*/
SourceEntity getSourceByFaceAndDeviceId(Long faceId, Long deviceId, Integer type, String sortStrategy);
/**
* 根据faceSampleId列表和type查询source列表
* @param faceSampleIds faceSampleId列表
* @param type 素材类型
* @return source实体列表
*/
List<SourceEntity> listByFaceSampleIdsAndType(List<Long> faceSampleIds, Integer type);
/**
* 删除指定faceId和type的member_source关联记录
* @param faceId 人脸ID
* @param type 素材类型
* @return 删除的记录数
*/
int deleteRelationsByFaceIdAndType(Long faceId, Integer type);
}

View File

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

View File

@@ -0,0 +1,14 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
/**
* 会话信息返回对象。
*/
@Data
public class ChatConversationVO {
private Long conversationId;
private Long faceId;
private String status;
private String model;
}

View File

@@ -0,0 +1,17 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
import java.util.List;
/**
* 消息列表响应。
*/
@Data
public class ChatMessagePageResp {
private List<ChatMessageVO> messages;
/**
* 下一条游标(返回最后一条 seq)。
*/
private Integer nextCursor;
}

View File

@@ -0,0 +1,18 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
import java.util.Date;
/**
* 聊天消息视图对象。
*/
@Data
public class ChatMessageVO {
private Long id;
private Integer seq;
private String role;
private String content;
private String traceId;
private Date createdAt;
}

View File

@@ -0,0 +1,22 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
/**
* 发送消息请求体。
*/
@Data
public class ChatSendMessageReq {
/**
* 用户输入的文本内容。
*/
private String content;
/**
* 链路追踪ID,前端可透传,没有则服务端生成。
*/
private String traceId;
/**
* 是否期望流式返回。
*/
private Boolean stream;
}

View File

@@ -0,0 +1,13 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
/**
* 发送消息同步响应。
*/
@Data
public class ChatSendMessageResp {
private ChatMessageVO userMessage;
private ChatMessageVO assistantMessage;
private String traceId;
}

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
import java.util.List;
/**
* 流式发送消息的服务结果。
*/
@Data
public class ChatSendMessageStreamResp {
private ChatMessageVO userMessage;
private ChatMessageVO assistantMessage;
private String traceId;
private List<String> chunks;
}

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.model.mobile.chat.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 小程序人脸聊天会话,一脸一会话。
*/
@Data
@TableName("face_chat_conversation")
public class FaceChatConversationEntity {
@TableId
private Long id;
/**
* 对应的人脸ID。
*/
private Long faceId;
/**
* 归属用户ID,冗余校验越权。
*/
private Long memberId;
/**
* 会话状态 active/closed。
*/
private String status;
/**
* 使用的模型名称,例如 glm-v。
*/
private String model;
private Date createdAt;
private Date updatedAt;
}

View File

@@ -0,0 +1,28 @@
package com.ycwl.basic.model.mobile.chat.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 小程序人脸聊天消息,只保存文本。
*/
@Data
@TableName("face_chat_message")
public class FaceChatMessageEntity {
@TableId
private Long id;
private Long conversationId;
private Long faceId;
private Integer seq;
/**
* user / assistant / system。
*/
private String role;
private String content;
private String traceId;
private Integer latencyMs;
private Date createdAt;
}

View File

@@ -0,0 +1,31 @@
package com.ycwl.basic.model.pc.faceDetectLog.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
@Data
@TableName("face_detect_log_ai_cam")
public class FaceDetectLogAiCamEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long scenicId;
private Long deviceId;
private Long faceId;
private String dbName;
private String faceUrl;
private Float score;
private String matchRawResult;
private Date createTime;
}

View File

@@ -24,4 +24,5 @@ public class MemberPrintEntity {
private Integer status;
private Date createTime;
private Date updateTime;
private String crop;
}

View File

@@ -60,6 +60,16 @@ public class ScenicConfigResp {
*/
private Boolean printEnableManual;
/**
* 我的页面显示已付款订单开关
*/
private Boolean showMyPagePaid;
/**
* 我的页面显示未付款订单开关
*/
private Boolean showMyPageUnpaid;
// ========== 提示文案 ==========
/**

View File

@@ -0,0 +1,39 @@
package com.ycwl.basic.model.printer;
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import lombok.Data;
import java.util.List;
/**
* @Author:longbinbin
* @Date:2024/12/3
* 打印机大屏人脸识别响应(包含匹配的source列表)
*/
@Data
public class FaceRecognizeWithSourcesResp {
/**
* 人脸照片URL
*/
private String url;
/**
* 人脸ID
*/
private Long faceId;
/**
* 景区ID
*/
private Long scenicId;
/**
* 匹配到的图像素材列表(type=2)
*/
private List<SourceEntity> sources;
/**
* 二维码URL
*/
private String qrcodeUrl;
}

View File

@@ -47,6 +47,7 @@ import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.service.pc.ScenicService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -70,13 +71,20 @@ public class OrderServiceImpl implements IOrderService {
private final OrderItemMapper orderItemMapper;
private final OrderDiscountMapper orderDiscountMapper;
private final OrderRefundMapper orderRefundMapper;
@Lazy
private final OrderEventManager orderEventManager;
@Lazy
private final ScenicService scenicService;
private final MemberMapper memberMapper;
@Lazy
private final ICouponService couponService;
@Lazy
private final IVoucherService voucherService;
@Lazy
private final IProductConfigService productConfigService;
@Lazy
private final IProductTypeCapabilityService productTypeCapabilityService;
@Lazy
private final DuplicatePurchaseCheckerFactory duplicatePurchaseCheckerFactory;
@Override

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
package com.ycwl.basic.pipeline.annotation;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Stage 配置注解,统一声明 Stage 的元信息与可选性控制。
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface StageConfig {
/**
* Stage 唯一标识,用于外部配置或监控引用。
*/
String stageId();
/**
* 可选性模式,默认不受外部控制。
*/
StageOptionalMode optionalMode() default StageOptionalMode.UNSUPPORT;
/**
* 描述信息,便于文档与日志。
*/
String description() default "";
/**
* 默认是否启用,仅在 optionalMode=SUPPORT 时生效。
*/
boolean defaultEnabled() default true;
}

View File

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

View File

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

View File

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

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