Compare commits

...

62 Commits

Author SHA1 Message Date
4360ef1313 feat(device): 实现设备视频连续性检查功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 新增设备视频连续性检查控制器 DeviceVideoContinuityController
- 提供查询、手动触发和删除检查结果的 REST 接口
- 实现视频连续性检查核心逻辑,支持检测视频间隙
- 添加定时任务 DeviceVideoContinuityCheckTask 自动检查设备视频连续性
- 仅在生产环境(prod)启用,每天9点到18点间每5分钟执行一次
- 支持阿里云OSS和本地存储的视频连续性检查
- 检查结果缓存至 Redis,默认保留24小时
- 新增相关实体类: DeviceVideoContinuityCache、VideoContinuityGap、VideoContinuityResult
- 在存储操作接口中增加 checkVideoContinuity 和 checkRecentVideoContinuity 方法
- 为不支持的存储类型提供默认不支持连续性检查的实现
2025-11-24 14:02:53 +08:00
9278d4479f feat(printer): 优化拼图打印偏移处理逻辑
- 添加白边框并向上偏移内容以避免打印机偏移
- 替换原有的单纯向上偏移方法
- 弃用 shiftImageUp 方法,新增 addBorderAndShiftUp 方法
- 更新临时文件命名及清理逻辑
- 修改日志记录内容以反映新的处理方式
2025-11-22 00:07:18 +08:00
18bf51487d feat(printer): 优化拼图打印逻辑并调整日期格式
- 调整AppPuzzleController中recordId参数为固定值0L
- 修改FaceMatchingOrchestrator中的日期格式为"yyyy.MM.dd"
- 完善PrinterServiceImpl水印处理条件判断
- 新增针对sourceId为0时的拼图照片偏移处理逻辑
- 修复重复打印检查逻辑,使用resultImageUrl代替puzzleRecordId比较
- 增强异常处理和日志记录,提升系统稳定性
2025-11-21 23:25:34 +08:00
447e8799e8 refactor(repository): 移除冗余的用户购买状态查询逻辑
- 删除 SourceRepository 中的 getUserIsBuy 方法
- 删除 VideoRepository 中的 getUserIsBuy 方法
- 简化业务逻辑,减少重复代码
- 提高代码可维护性和清晰度
2025-11-21 22:24:32 +08:00
fd130c471f feat(order): 添加未来模板一口价购买逻辑
- 引入 IsBuyBatchRespVO 类以支持批量购买响应
- 实现视频模板买断逻辑,优先检查模板是否已购买
- 在商品类型为0时,查询视频模板并判断用户是否已购买
- 若已购买,直接返回订单ID及购买状态,跳过价格查询
- 保留原有价格查询逻辑作为兜底方案
2025-11-21 22:15:17 +08:00
c47c24a39a refactor(goods): 移除商品列表查询接口及关联逻辑
- 删除 GoodsService 中的 goodsList 接口定义
- 移除 GoodsServiceImpl 中 goodsList 方法的实现
- 清理相关导入语句和无用代码引用
- 简化商品服务模块,聚焦于源素材商品列表功能
2025-11-21 21:52:14 +08:00
97e3ab19a0 refactor(order): 重构订单购买逻辑并优化接口参数
- 调整 isBuy 方法参数顺序,增加 faceId 参数支持
- 删除冗余的购买检查方法和旧版 isBuy 重载方法
- 简化购买状态判断逻辑,移除重复代码
- 更新视频查看权限服务中的购买检查调用
- 修改人脸服务中景区 ID 类型为 Long
- 调整打印机服务中人脸查询方法参数类型
- 统一订单业务类中方法签名和调用方式
- 移除订单请求模型中无用字段注释
- 增加人脸 ID 列表字段支持批量查询
- 优化任务服务中购买状态检查逻辑
2025-11-21 21:45:26 +08:00
5b27cac6b0 feat(service): 优化商品查询逻辑并新增分组查询接口
- 在 SourceMapper 中新增 queryGroupedByFaceAndType 方法,支持按 faceId 和 type 分组查询
- 调整 orderBiz.isBuy 方法的参数顺序,统一调用格式
- 修改 GoodsServiceImpl 中源素材查询逻辑,使用新分组方法减少循环嵌套
- 简化源素材去重及过滤禁用类型的处理流程
- 提前获取景区配置信息,避免重复查询
- 优化代码结构,提升可读性和维护性
2025-11-21 21:43:37 +08:00
91f3632e2b fix(printer): 修复人脸照片统计逻辑
- 在统计人脸照片数量时增加状态过滤条件
- 仅统计状态为0的有效记录
- 避免已删除或无效数据影响统计结果
2025-11-21 21:06:28 +08:00
cd8ae491e2 feat(mobile): 实现基于人脸ID的商品列表查询功能
- 修改AppFaceController中list方法,将scenicId转换为Long类型传递
- 在AppGoodsController中注入FaceService,并在goodsList接口中调用faceService获取人脸列表
- 更新FaceMapper中的listByScenicAndUserId方法签名,统一scenicId参数类型为Long
- GoodsServiceImpl中新增listGoodsByFaceIdList方法,实现根据人脸ID列表查询相关商品逻辑
- 商品查询支持按成片vlog和源素材分类展示,并去重处理
- 优化GoodsService接口,增加listGoodsByFaceIdList方法定义
- OrderMapper.xml
2025-11-21 20:49:05 +08:00
d0d238d31d feat(order): 添加景区全免逻辑处理
- 引入ScenicConfigManager依赖
- 实现景区全免配置判断
- 设置全免订单价格为零
- 更新订单响应状态逻辑
2025-11-21 19:52:13 +08:00
2be30c6eb4 refactor(price): 重构价格购买方法命名以明确用途
- 将 isBuy 方法重命名为 isOnePriceBuy 以准确反映其功能
- 更新所有调用点以使用新的方法名
- 清理未使用的导入包和变量声明
- 移除与一口价购买无关的冗余代码引用
2025-11-21 19:48:54 +08:00
fb82329a88 fix(order): 修复订单购买状态判断逻辑
- 调整isBuy方法参数顺序,确保 memberId 和 scenicId 正确传递
- 在OrderBiz中设置默认buy状态为false,避免空指针异常
- 修改OrderMapper查询条件,增加refund_status=0过滤已退款订单
- 优化face服务中调用isBuy方法时的参数传递逻辑
2025-11-21 19:45:21 +08:00
4f0d6dc44f feat(order): 新增根据人脸ID查询购买记录功能
- 在OrderBiz中增加isBuy方法重载,支持通过人脸ID查询购买记录
- 修改AppPuzzleController中调用参数顺序,适配新方法签名
- 在OrderMapper接口中新增getUserBuyFaceItem方法定义
- 在OrderMapper.xml中实现getUserBuyFaceItem的SQL查询逻辑
- 调整FaceServiceImpl中相关调用逻辑,移除冗余配置获取代码
2025-11-21 19:27:53 +08:00
302b6811c4 feat(puzzle): 优化二维码生成与去重逻辑
- 避免重复上传已存在的微信小程序二维码
- 在去重检测中跳过 dateStr 字段以提高准确性
- 添加文件存在性检查,减少不必要的上传操作
- 记录并返回已存在文件的访问 URL
- 提升 puzzle 服务的性能与资源利用率
2025-11-21 18:11:41 +08:00
c0daa4d3b2 refactor(face): 优化拼图模板内容生成逻辑
- 修改拼图模板内容生成方式,支持多个模板内容生成
- 使用forEach循环处理每个模板,确保内容正确插入
- 保留原有价格计算和购买状态判断逻辑
- 确保contentId在记录存在时正确设置
- 维持原有的商品类型、分组和排序设置
2025-11-21 17:46:28 +08:00
83cfbc67e1 fix(repository): 修复人脸缓存获取逻辑
- 添加空值检查避免解析空字符串
- 提取缓存键避免重复格式化
- 优化缓存命中时的对象转换逻辑
2025-11-21 17:07:29 +08:00
8f918570d9 feat(puzzle): 动态设置拼图名称
- 注入PuzzleTemplateMapper依赖
- 根据模板ID获取拼图模板名称
- 使用模板名称替换硬编码的"三拼图"名称
2025-11-21 17:07:24 +08:00
f4a3dc9cae fix(order): 修复订单支付后商品创建时间获取逻辑
- 删除冗余的商品创建时间计算代码
- 优化订单支付后的统计记录逻辑
- 清理无用的日期比较操作
- 提升代码可读性和维护性
2025-11-21 17:04:12 +08:00
cd5ba23d59 feat(puzzle): 添加模板封面图片字段并更新相关逻辑
- 在PuzzleTemplateDTO和TemplateCreateRequest中新增coverImage字段
- 在PuzzleTemplateEntity中新增coverImage字段并映射到数据库
- 更新FaceServiceImpl以支持获取模板封面图片URL
- 修改Mapper XML文件以支持coverImage字段的读写操作
- 调整SQL查询和插入语句以包含新的coverImage字段
- 更新三拼图内容页面逻辑以使用模板封面图片URL
2025-11-21 16:04:59 +08:00
038b2e6f08 fix(order): 修复订单查询逻辑
- 在查询条件中添加了 goodsId 过滤
- 为避免多余数据返回,增加了 limit 1 限制
- 确保订单项查询的准确性与性能优化
2025-11-21 15:02:26 +08:00
caad0c2cf0 feat(order): 添加plog图商品类型支持
- 在OrderServiceImpl中增加对商品类型5的处理逻辑
- 设置商品名称和订单类型为"plog图"
- 在OrderMapper.xml中新增member_plog_data查询块
- 添加对goods_type为5时coverUrl和imgUrl的映射
- 增加对goods_type为5时商品名称的显示处理
- 新增member_plog_data表的左连接查询条件
2025-11-21 14:40:08 +08:00
259d99bde7 feat(face): 添加购买状态判断逻辑
- 在生成内容时增加对用户是否已购买的判断
- 根据购买状态设置内容的 isBuy 字段
- 调用 orderBiz.isBuy 方法检查购买状态
- 使用 scenicId 和 templateId 作为购买查询条件
2025-11-21 14:03:54 +08:00
0e2122910f feat(face): 新增人脸匹配编排流程中的任务状态管理
- 引入 TaskStatusBiz
2025-11-21 13:56:20 +08:00
e1a77a1614 feat(printer): 移除拼图照片自动裁剪功能
- 删除了从打印机配置获取打印尺寸的逻辑
- 移除了调用ImageUtils.smartCropAndFill进行图片裁剪的代码
- 去掉了裁剪后图片上传和临时文件清理的相关实现
- 简化了打印服务流程,直接使用原始图片URL
- 保留了cropUrl字段但不再进行实际裁剪操作
2025-11-21 11:47:52 +08:00
8791cf5910 fix(printer): 修复上传裁剪图片时的文件扩展名获取逻辑
- 将文件扩展名从resultImageUrl改为croppedFile.getName()中获取
- 确保上传裁剪后图片时能正确识别文件类型
- 避免因URL解析错误导致的文件扩展名丢失问题
2025-11-21 11:47:05 +08:00
a860319ea1 refactor(puzzle): 移除拼图生成记录中的复用逻辑
- 删除 PuzzleGenerationRecordEntity 中的 isDuplicate 和 originalRecordId 字段
- 移除插入记录时设置 isDuplicate 的逻辑
- 删除 FaceMatchingOrchestrator 中查询历史记录的逻辑
- 更新 Mapper XML 文件,移除相关字段和条件判断
- 简化生成流程,不再检查模板是否已生成
2025-11-21 11:41:11 +08:00
d5fc5c2565 feat(scenic): 添加新的景区ID到白名单
- 在白名单中新增景区ID: 4049850382325780480
- 完善景区列表分页查询功能的ID过滤逻辑
2025-11-21 11:02:56 +08:00
0db713b4a8 feat(puzzle): 实现拼图生成去重机制
- 新增内容哈希计算逻辑,基于元素内容生成SHA256哈希用于去重判断
- 添加重复图片检测功能,当所有IMAGE元素使用相同URL时抛出异常
- 实现历史记录查询接口,根据模板ID、内容哈希和景区ID查找重复记录
- 扩展生成响应对象,增加isDuplicate和originalRecordId字段标识复用情况
- 更新数据库实体和Mapper,新增content_hash、is_duplicate等字段支持去重
- 添加完整的单元测试和集成测试,覆盖去重检测、哈希计算等核心逻辑
- 引入DuplicateImageException和PuzzleBizException异常类完善错误处理
2025-11-21 11:02:43 +08:00
6ef710201c fix(order): 修正订单商品名称逻辑并更新购买检查参数
- 修改AppPuzzleController中isBuy方法的参数传递逻辑
- 在OrderServiceImpl中为未知类型添加默认商品名称
- 统一景区相关商品的命名规则
2025-11-21 10:03:10 +08:00
9123a1f6db feat(puzzle): 新增拼图功能模块
- 新增AppPuzzleController控制器,提供拼图相关接口
- 实现根据faceId查询拼图数量和记录列表功能
- 实现根据recordId查询拼图详情和下载拼图资源功能
- 实现拼图价格计算和导入打印列表功能
- 在FaceServiceImpl中集成拼图记录展示逻辑
- 在OrderServiceImpl中新增PHOTO_LOG产品类型处理
- 在PrinterService中实现从拼图添加到打印列表的功能
- 完善拼图记录转换为内容页面VO的逻辑处理
2025-11-20 23:11:04 +08:00
d458f918ed feat(text): 实现文本垂直居中对齐功能
- 修改TextElement类中的Y坐标计算逻辑
- 新增总文本高度计算和垂直偏移量
- 调整起始Y坐标以支持垂直居中对齐
- 保持原有逐行绘制逻辑不变
2025-11-20 23:10:50 +08:00
27e58d36d0 test(puzzle): 更新测试用例以适配新的执行结果结构
- 移除已弃用的 DeviceCountRangeConditionStrategy 策略注册
- 修改 PuzzleElementFillEngine 执行方法调用方式,使用 getDynamicData 获取动态数据
- 在 PuzzleGenerateServiceImplTest 中引入 FillResult 类型并更新 mock 返回值结构
- 统一调整所有相关测试断言逻辑以匹配新返回的数据格式
2025-11-20 23:10:27 +08:00
8c76c85ae2 feat(puzzle): 添加拼图生成记录检查逻辑
- 引入 PuzzleGenerationRecordEntity 和 PuzzleGenerationRecordMapper
- 在人脸匹配编排流程中查询已有拼图生成记录
- 增加模板重复生成判断逻辑,避免重复处理
- 跳过已生成模板并记录日志提示
2025-11-20 17:33:51 +08:00
8991d68673 docs(claude): 更新设备数量匹配策略的描述
- 修改模式1的匹配逻辑为实际机位数大于等于deviceCount
- 修改模式2的匹配逻辑为从指定列表过滤后数量大于等于deviceCount
- 保持配置顺序并只取前N个设备进行匹配
2025-11-20 17:27:31 +08:00
3b93e07a66 feat(fill): 更新机位数量匹配策略为大于等于匹配
- 修改策略注释说明匹配方式由精确匹配改为大于等于匹配
- 更新全局数量匹配逻辑,从 == 改为 >= 判断
- 更新列表数量匹配逻辑,从 == 改为 >= 判断
- 在列表匹配成功时,只取前 N 个机位存入 context.extra
- 调整日志描述,明确显示最小数量与实际数量的比较
- 更新单元测试用例以验证大于等于匹配逻辑
- 增加测试用例验证匹配成功时只取前 N 个机位的行为
- 调整测试用例名称和断言逻辑以适应新的匹配规则
2025-11-20 16:38:08 +08:00
c8054c60ab feat(puzzle): 启用规则匹配以增强拼图生成
- 在拼图生成请求中添加规则匹配选项
- 设置 requireRuleMatch 参数为 true 以启用高级验证
- 确保动态数据映射保持不变
- 保留现有质量与格式设置配置
2025-11-20 15:20:36 +08:00
2fd852c5c6 feat(puzzle): 增强拼图填充引擎功能
- 新增 requireRuleMatch 参数控制是否必须匹配规则
- 重构 DeviceCountConditionStrategy 支持两种匹配模式
- 移除已废弃的 DeviceCountRangeConditionStrategy
- 引入 FillResult 类封装填充结果信息
- 优化条件上下文和数据源上下文的 extra 字段类型
- 更新相关测试用例和文档说明
2025-11-20 15:11:13 +08:00
aaa8d8310a feat(source): 新增根据人脸和设备ID获取素材的功能
- 在SourceMapper接口中新增getSourceByFaceAndDeviceId方法
- 支持通过faceId、deviceId、type和排序策略查询特定素材
- 在XML映射文件中实现对应的SQL查询逻辑
- 支持多种排序策略:最新、最早、评分降序、评分升序、随机和已购买优先
- 查询结果限制为一条记录
2025-11-20 14:55:28 +08:00
8d2d0901fd feat(face): 添加景区名称和日期到动态数据
- 引入日期工具类以支持日期格式化
- 在基础动态数据中增加景区名称字段
- 添加当前日期字符串到基础动态数据
- 为后续模板生成提供更丰富的上下文信息
2025-11-20 13:51:04 +08:00
d1381c93b0 feat(puzzle): 更新拼图元素填充引擎执行方法参数
- 在engine.execute方法调用中增加scenicId参数
- 修改测试用例以适应新的方法签名
- 确保所有相关测试验证逻辑正确性
- 更新PuzzleGenerateServiceImplTest中的fillEngine调用参数
- 调整verify语句匹配新参数列表
- 保持原有功能逻辑不变仅扩展参数传递
2025-11-20 11:41:22 +08:00
536f2866f6 feat(puzzle): 添加统计人脸ID生成记录数量功能
- 在PuzzleGenerationRecordMapper接口中新增countByFaceId方法
- 在PuzzleGenerationRecordMapper.xml中实现对应的SQL查询
- 支持根据faceId统计生成记录的数量
2025-11-20 11:41:09 +08:00
4cbd0dc255 feat(puzzle): 新增微信小程序二维码生成功能
- 在DataSourceContext中新增scenicId字段用于景区关联
- 实现WechatQrcodeDataSourceStrategy策略类,支持生成并上传微信小程序码
- 扩展DataSourceType枚举,增加WECHAT_QRCODE类型
- 修改PuzzleElementFillEngine执行方法,支持传入scenicId参数
- 在PuzzleGenerateServiceImpl中集成二维码自动生成逻辑
- 新增generateWechatQrcode方法用于生成并上传小程序码到OSS
- 完善日志记录和异常处理机制
- 添加必要的工具类和存储服务依赖注入
2025-11-20 11:00:53 +08:00
90cf0d44c9 feat(video-review): 优化视频评价导出功能,支持机位名称动态表头
- 引入DeviceRepository用于批量查询机位名称
- 在导出逻辑中收集并排序机位ID,确保表头顺序一致
- 动态生成Excel表头,使用实际机位名称替代原始JSON字段
- 调整单元格样式以支持自动换行,提升可读性
- 更新mapper配置,关联template表获取模板名称
- 优化列宽自适应逻辑,为机位列设置最小宽度保障显示效果
- 日志记录中增加导出机位数量统计信息
2025-11-20 11:00:29 +08:00
d387f11173 feat(video): 添加视频模板ID和名称字段
- 在VideoReviewRespDTO中新增templateId字段
- 在VideoReviewRespDTO中新增templateName字段
- 添加相应字段的注释说明
- 支持关联查询video表获取模板信息
2025-11-20 10:48:08 +08:00
f6d6a63977 feat(puzzle): 修改生成记录查询逻辑以支持人脸ID
- 将查询条件从 orderId 更改为 faceId
- 更新 Mapper 接口方法名和参数
- 修改 XML 映射文件中的字段和查询条件
- 调整插入记录时使用的字段名称
- 更新基础列定义以反映新的字段结构
2025-11-20 10:48:08 +08:00
67aebd5770 refactor(puzzle): 移除填充规则中的景区ID依赖
- 删除 PuzzleFillRuleDTO、PuzzleFillRuleSaveRequest 和 PuzzleFillRuleEntity 中的 scenicId 字段
- 从 ConditionContext 和 DataSourceContext 中移除 scenicId 属性
- 更新 PuzzleElementFillEngine 的 execute 方法,不再接收和传递 scenicId 参数
- 修改 PuzzleGenerateServiceImpl 中调用填充引擎的逻辑,去除 scenicId 判断和传参
- 调整 PuzzleFillRuleMapper.xml 配置文件,移除 scenic_id 映射关系
- 更新所有相关单元测试用例,删除对 scenicId 的引用和验证
- 简化规则查询方法,由 listByTemplateAndScenic 改为 listByTemplateId
- 移除因缺少 scenicId 而产生的警告日志和特殊处理分支
2025-11-19 23:23:08 +08:00
6d18a770b8 feat(puzzle): 实现人脸匹配后异步生成拼图模板功能
- 移除查询规则时的景区ID参数,简化规则加载逻辑
- 为人脸匹配编排器添加拼图模板服务依赖
- 新增异步生成拼图模板方法,在人脸识别成功后触发
- 优化Mapper接口,添加@Mapper注解并移除冗余查询方法
- 更新文档说明,同步修改规则查询方式描述
- 清理SourceMapper中重复的deleted条件过滤逻辑
2025-11-19 22:48:01 +08:00
b6cbb18a7f docs(puzzle): 更新Claude模块文档结构
- 移除了联系方式和维护者信息
- 简化了文档结尾的元数据部分
- 优化了设备ID匹配策略文档的引用格式
2025-11-19 18:29:45 +08:00
cfb3625ac0 feat(puzzle): 实现智能自动填充引擎和安全增强
- 新增拼图元素自动填充引擎 PuzzleElementFillEngine
- 支持基于规则的条件匹配和数据源解析
- 实现机位数量、机位ID等多维度条件策略
- 添加 DEVICE_IMAGE、USER_AVATAR 等数据源类型支持
- 增加景区隔离校验确保模板使用安全性
- 强化图片下载安全校验,防范 SSRF 攻击
- 支持本地文件路径解析和公网 URL 安全检查
- 完善静态值数据源策略支持 localPath 配置
- 优化生成流程中 faceId 和 scenicId 的校验逻辑
- 补充相关单元测试覆盖核心功能点
2025-11-19 17:28:41 +08:00
cb17ea527b refactor(SourceMapper): 优化查询条件动态拼接逻辑
- 使用<where>标签替换原有静态where条件
- 添加对scenicId、isBuy、type、faceId参数的动态判断
- 确保只有非空参数参与SQL查询条件构建
- 提高SQL语句可读性和维护性
- 避免因缺少条件导致的语法错误风险
2025-11-19 15:08:59 +08:00
625ad910c9 feat(printer): 添加素材打印状态查询功能
- 在PrinterMapper中新增countFacePhoto方法用于统计用户打印素材数量
- 创建GoodsDetailPrintSceneVO类继承GoodsDetailVO并添加inList字段
- 修改GoodsReqQuery类添加scene字段用于标识打印场景
- 在GoodsServiceImpl中注入PrinterMapper并实现打印状态判断逻辑
- 在PrinterMapper.xml中添加对应的SQL查询语句
- 移除BaseContextHandler引入,优化代码依赖关系
2025-11-19 15:08:39 +08:00
778afaaa83 feat(puzzle): 实现拼图自动填充规则引擎及相关功能
- 新增拼图填充规则管理Controller、DTO、Entity等核心类
- 实现条件评估策略模式,支持多种匹配规则
- 实现数据源解析策略模式,支持多种数据来源
- 新增拼图元素自动填充引擎,支持优先级匹配和动态填充
- 在SourceMapper中增加设备统计和查询相关方法
- 在PuzzleGenerateRequest中新增faceId字段用于触发自动填充
- 完善相关枚举类和工具类,提升系统可维护性和扩展性
2025-11-19 11:10:23 +08:00
de421cf0d5 chore(build): 移除跳过测试编译的 Maven 插件配置
- 删除了 maven-compiler-plugin 中跳过测试编译的配置
- 移除了对测试文件排除的设置
- 清理了插件中不必要的 Java 21 预览功能启用参数
2025-11-19 10:16:24 +08:00
3ddf7bd0e9 feat(image): 添加图片180度旋转功能
- 新增rotateImage180方法实现图片180度旋转
- 支持源文件读取和目标文件写入
- 使用AffineTransform实现图像旋转变换
- 保持图片原始尺寸不变
- 添加详细的异常处理和资源释放
- 移除对270度旋转的限制检查
2025-11-18 17:32:04 +08:00
208202ba41 feat(image): 添加水印四边偏移支持
- 在 WatermarkInfo 中新增 offsetTop、offsetBottom、offsetLeft 和 offsetRight 字段
- 在 PrinterDefaultWatermarkOperator 中实现四边偏移逻辑,默认值为 0
- 根据图片方向设置不同的偏移值,横图左偏移 40 像素,竖图下偏移 30 像素
- 调整二维码和文字位置计算方式以应用偏移量
- 优化水印处理流程,确保偏移参数正确传递和使用
2025-11-18 16:27:19 +08:00
6e84a5fd43 fix(printer): 调整二维码边距和图片旋转逻辑
- 修改二维码距离左边缘的图片宽度比例从 0.075 为 0.05
- 修正图片旋转角度判断逻辑,确保横向处理正确
- 移除下载 URL 中的域名替换操作,使用原始地址直接下载
2025-11-18 16:06:19 +08:00
8e48bd92cc feat(pricing): 添加新的产品类型枚举值
- 新增 PHOTO_LOG 类型,表示 pLog 图
- 新增 PHOTO_VLOG 类型,表示 pLog 视频
2025-11-18 15:49:41 +08:00
23181e9f08 fix(video): 完善视频删除逻辑以排除被评价的视频
- 修改删除条件,增加对视频评价关联表的检查
- 确保已被评价的视频不会被误删
- 防止因外键约束导致的删除失败问题
2025-11-18 14:42:20 +08:00
42e806df76 feat(puzzle): 添加批量替换模板元素功能
- 在 PuzzleTemplateController 中新增 replaceElements 接口
- 在 PuzzleElementMapper 中新增 getByTemplateIdAndKey 查询方法
- 在 PuzzleTemplateServiceImpl 中实现 replaceElements 业务逻辑
- 在 IPuzzleTemplateService 接口中定义 replaceElements 方法
- 在 PuzzleElementMapper.xml 中添加对应 SQL 查询语句
2025-11-18 12:47:24 +08:00
a49e581915 fix(printer): 修复打印照片方向检测逻辑
- 修改图片方向判断方式,从文件检测改为读取crop配置中的rotation值
- 添加异常处理机制,确保旋转角度解析失败时能正确抛出异常
- 保持竖图自动旋转为横图的处理逻辑不变
2025-11-18 12:27:18 +08:00
af60e95529 feat(puzzle): 添加模板分页查询功能并优化DTO序列化
- 在PuzzleTemplateController中新增pageTemplates接口支持分页查询
- 为ElementCreateRequest和PuzzleElementDTO添加@JsonProperty注解优化JSON序列化
- 实现PuzzleTemplateServiceImpl中的pageTemplates分页逻辑
- 使用PageHelper实现分页查询并限制最大页面大小为100
- 在IPuzzleTemplateService接口中定义pageTemplates方法签名及文档说明
- 添加参数校验确保page和pageSize的有效性
- 返回PageResponse对象封装分页结果供前端使用
2025-11-18 12:05:21 +08:00
125 changed files with 8603 additions and 575 deletions

13
pom.xml
View File

@@ -311,19 +311,6 @@
<skip>${skipTests}</skip>
</configuration>
</plugin>
<!-- 跳过测试编译 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<testExcludes>
<testExclude>**/*Test.java</testExclude>
</testExcludes>
<source>21</source>
<target>21</target>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>

View File

@@ -1,10 +1,12 @@
package com.ycwl.basic.biz;
import com.ycwl.basic.enums.StatisticEnum;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.OrderMapper;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.mapper.StatisticsMapper;
import com.ycwl.basic.mapper.VideoMapper;
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.order.PriceObj;
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
@@ -160,90 +162,46 @@ public class OrderBiz {
return null;
}
}
public IsBuyRespVO isBuy(Long userId, Long scenicId, int goodsType, Long goodsId) {
public IsBuyRespVO isBuy(Long scenicId, Long memberId, Long faceId, int goodsType, Long goodsId) {
IsBuyRespVO respVO = new IsBuyRespVO();
boolean isBuy = orderRepository.checkUserBuyItem(userId, goodsType, goodsId);
// 模板购买逻辑
if (!isBuy) {
if (goodsType == 0) {
VideoEntity video = videoRepository.getVideo(goodsId);
if (video == null) {
respVO.setGoodsType(goodsType);
respVO.setGoodsId(goodsId);
OrderEntity orderEntity = orderMapper.getUserBuyFaceItem(memberId, faceId, goodsType, goodsId);
if (orderEntity != null) {
respVO.setOrderId(orderEntity.getId());
respVO.setBuy(true);
respVO.setFree(false);
return respVO;
}
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
if (Boolean.TRUE.equals(scenicConfig.getBoolean("all_free"))) {
// 景区全免
respVO.setFree(true);
respVO.setOrigPrice(BigDecimal.ZERO);
respVO.setSlashPrice(BigDecimal.ZERO);
return respVO;
}
// 未来模板一口价
if (goodsType == 0) {
// 视频,可以买断模板
VideoEntity video = videoRepository.getVideo(goodsId);
if (video != null && video.getTemplateId() != null) {
OrderEntity templateBuy = orderMapper.getUserBuyFaceItem(memberId, faceId, -1, video.getTemplateId());
if (templateBuy != null) {
respVO.setOrderId(templateBuy.getId());
respVO.setBuy(true);
respVO.setFree(false);
return respVO;
}
TaskEntity task = videoTaskRepository.getTaskById(video.getTaskId());
Long templateId = video.getTemplateId();
// -1为整个模板购买
OrderEntity orderEntity = orderRepository.getUserBuyItem(userId, -1, templateId);
if (orderEntity != null && task != null) {
respVO.setOrderId(orderEntity.getId());
if (orderEntity.getFaceId() != null && task.getFaceId() != null) {
isBuy = orderEntity.getFaceId().equals(task.getFaceId());
}
}
}
}
// 免费送逻辑,之前已经赠送了的
if (!isBuy) {
isBuy = switch (goodsType) {
case 0 -> videoRepository.getUserIsBuy(userId, goodsId);
case 1, 2 -> sourceRepository.getUserIsBuy(userId, goodsType, goodsId);
default -> false;
};
} else {
OrderEntity orderEntity = orderRepository.getUserBuyItem(userId, goodsType, goodsId);
if (orderEntity != null) {
respVO.setOrderId(orderEntity.getId());
}
}
respVO.setBuy(isBuy);
// 还是没买
if (!isBuy) {
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId);
if (priceObj == null) {
return respVO;
}
FaceEntity face = faceRepository.getFace(priceObj.getFaceId());
respVO.setShare(true);
if (face != null && face.getMemberId().equals(userId)) {
respVO.setShare(false);
}
respVO.setFree(priceObj.isFree());
respVO.setGoodsType(goodsType);
respVO.setGoodsId(goodsId);
respVO.setOrigPrice(priceObj.getPrice());
respVO.setSlashPrice(priceObj.getSlashPrice());
switch (goodsType) {
case 0: // vlog
VideoEntity video = videoRepository.getVideo(goodsId);
TaskEntity taskById = videoTaskRepository.getTaskById(video.getTaskId());
if (taskById != null) {
CouponRecordQueryResp recordQueryResp = couponBiz.queryUserCouponRecord(scenicId, userId, taskById.getFaceId(), taskById.getTemplateId().toString());
if (recordQueryResp.isUsable()) {
respVO.setCouponId(recordQueryResp.getCouponId());
respVO.setCouponRecordId(recordQueryResp.getId());
CouponEntity coupon = recordQueryResp.getCoupon();
if (coupon != null) {
respVO.setCouponPrice(coupon.calculateDiscountPrice(priceObj.getPrice()));
}
}
}
break;
case 1:
case 2:
CouponRecordQueryResp recordQueryResp = couponBiz.queryUserCouponRecord(scenicId, userId, goodsId, String.valueOf(goodsType));
if (recordQueryResp.isUsable()) {
respVO.setCouponId(recordQueryResp.getCouponId());
respVO.setCouponRecordId(recordQueryResp.getId());
CouponEntity coupon = recordQueryResp.getCoupon();
if (coupon != null) {
respVO.setCouponPrice(coupon.calculateDiscountPrice(priceObj.getPrice()));
}
}
break;
}
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId);
if (priceObj == null) {
return respVO;
}
respVO.setBuy(false);
respVO.setOrigPrice(priceObj.getPrice());
respVO.setSlashPrice(priceObj.getSlashPrice());
return respVO;
}
@@ -259,11 +217,14 @@ public class OrderBiz {
switch (item.getGoodsType()) {
case 0: // vlog视频
videoRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
break;
case 1: // 视频原素材
case 2: // 照片原素材
sourceRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId(), order.getId());
break;
case 3:
printerService.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
break;
}
});
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
@@ -271,38 +232,6 @@ public class OrderBiz {
if (couponRecordId != null) {
couponBiz.userUseCoupon(order.getMemberId(), order.getFaceId(), couponRecordId, orderId);
}
//支付时间
OrderAppRespVO orderDetail = orderMapper.appDetail(orderId);
Date payAt = orderDetail.getPayAt();
//商品创建时间
Date goodsCreateTime = new Date();
if (!orderDetail.getOrderItemList().isEmpty()) {
OrderItemVO orderItemVO = orderDetail.getOrderItemList().getFirst();
switch (orderItemVO.getGoodsType()) {
case 0:
VideoEntity video = videoRepository.getVideo(orderItemVO.getGoodsId());
if (video != null) {
goodsCreateTime = video.getCreateTime();
}
break;
case 1:
List<SourceEntity> imageSource = sourceMapper.listImageByFaceRelation(orderItemVO.getGoodsId());
Optional<SourceEntity> min = imageSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
if (min.isPresent()) {
goodsCreateTime = min.get().getCreateTime();
}
break;
case 2:
List<SourceEntity> videoSource = sourceMapper.listImageByFaceRelation(orderItemVO.getGoodsId());
Optional<SourceEntity> minTime = videoSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
if (minTime.isPresent()) {
goodsCreateTime = minTime.get().getCreateTime();
}
break;
}
}
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
statisticsRecordAddReq.setMemberId(order.getMemberId());
Long enterType = statisticsMapper.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());

View File

@@ -1,8 +1,6 @@
package com.ycwl.basic.biz;
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
@@ -12,8 +10,6 @@ import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity;
import com.ycwl.basic.pricing.entity.PriceOnePriceConfig;
import com.ycwl.basic.pricing.service.IOnePricePurchaseService;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.PriceRepository;
@@ -29,7 +25,6 @@ import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Component
@@ -95,7 +90,7 @@ public class PriceBiz {
}).collect(Collectors.toList());
}
public IsBuyBatchRespVO isBuy(Long userId, Long faceId, Long scenicId, Integer type, String goodsIds) {
public IsBuyBatchRespVO isOnePriceBuy(Long userId, Long faceId, Long scenicId, Integer type, String goodsIds) {
IsBuyBatchRespVO respVO = new IsBuyBatchRespVO();
PriceConfigEntity priceConfig = priceRepository.getPriceConfigByScenicTypeGoods(scenicId, type, goodsIds);
if (priceConfig == null) {

View File

@@ -59,7 +59,7 @@ public class AppFaceController {
public ApiResponse<List<FaceRespVO>> list(@PathVariable("scenicId") String scenicId) {
JwtInfo worker = JwtTokenUtil.getWorker();
Long userId = worker.getUserId();
List<FaceRespVO> list = faceService.listByUser(userId, scenicId);
List<FaceRespVO> list = faceService.listByUser(userId, Long.parseLong(scenicId));
return ApiResponse.success(list);
}

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,7 @@ public class AppScenicController {
add("3932535453961555968");
add("3936121342868459520");
add("3936940597855784960");
add("4049850382325780480");
}};
// 分页查询景区列表

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,16 @@ public class WatermarkInfo {
private String dtFormat;
private String datetimeLine;
/**
* 四边偏移(像素值),正数表示向内偏移
* 例如: offsetLeft=40 表示左边界向右收缩40像素,所有左对齐元素随之向右移动
* null 表示使用默认值(通常为0)
*/
private Integer offsetTop;
private Integer offsetBottom;
private Integer offsetLeft;
private Integer offsetRight;
public String getDatetimeLine() {
if (datetimeLine == null) {
datetimeLine = DateUtil.format(datetime, dtFormat);

View File

@@ -47,7 +47,7 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
public static int OFFSET_Y = 15;
public static Color BG_COLOR = Color.WHITE;
public static int QRCODE_SIZE = 150;
public static double QRCODE_LEFT_MARGIN_RATIO = 0.075; // 二维码距离左边缘的图片宽度比例
public static double QRCODE_LEFT_MARGIN_RATIO = 0.05; // 二维码距离左边缘的图片宽度比例
public static int QRCODE_OFFSET_Y = -35;
public static int SCENIC_FONT_SIZE = 42;
@@ -56,8 +56,20 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
public static Color datetimeColor = Color.white;
public static double TEXT_RIGHT_MARGIN_RATIO = 0.05; // 文字距离右边缘的图片宽度比例
// 默认四边偏移值(像素),当 WatermarkInfo 中未提供时使用
public static int DEFAULT_OFFSET_TOP = 0;
public static int DEFAULT_OFFSET_BOTTOM = 0;
public static int DEFAULT_OFFSET_LEFT = 0;
public static int DEFAULT_OFFSET_RIGHT = 0;
@Override
public File process(WatermarkInfo info) throws ImageWatermarkException {
// 获取四边偏移值,优先使用传入的值,否则使用默认值
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;
BufferedImage baseImage;
BufferedImage qrcodeImage;
BufferedImage faceImage = null;
@@ -92,9 +104,9 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
int scenicLineWidth = scenicFontMetrics.stringWidth(info.getScenicLine());
int datetimeLineWidth = datetimeFontMetrics.stringWidth(info.getDatetimeLine());
// 二维码放置在左下角,距离左边缘图片宽度的5%
int qrcodeOffsetX = (int) (newImage.getWidth() * QRCODE_LEFT_MARGIN_RATIO);
int qrcodeOffsetY = EXTRA_BORDER_PX + baseImage.getHeight() - OFFSET_Y - newQrcodeHeight;
// 二维码放置在左下角,距离左边缘图片宽度的5%,再加上左侧偏移
int qrcodeOffsetX = (int) (newImage.getWidth() * QRCODE_LEFT_MARGIN_RATIO) + offsetLeft;
int qrcodeOffsetY = EXTRA_BORDER_PX + baseImage.getHeight() - OFFSET_Y - newQrcodeHeight - offsetBottom;
Shape originalClip = g2d.getClip();
// 创建比二维码大10像素的白色圆形背景
@@ -165,8 +177,8 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
// 计算第一行文字的Y坐标(基线位置),使两行文字整体垂直居中于二维码
int textStartY = qrcodeCenter - totalTextHeight / 2 + scenicFontMetrics.getAscent();
// 文字右对齐,放置在右下角,距离右边缘图片宽度的5%
int textRightX = newImage.getWidth() - (int) (newImage.getWidth() * TEXT_RIGHT_MARGIN_RATIO);
// 文字右对齐,放置在右下角,距离右边缘图片宽度的5%,再减去右侧偏移
int textRightX = newImage.getWidth() - (int) (newImage.getWidth() * TEXT_RIGHT_MARGIN_RATIO) - offsetRight;
g2d.setFont(scenicFont);
g2d.setColor(scenicColor);

View File

@@ -35,7 +35,7 @@ public interface FaceMapper {
FaceRespVO findLastFaceByUserId(String userId);
FaceRespVO findLastFaceByScenicAndUserId(Long scenicId, Long userId);
List<FaceRespVO> listByScenicAndUserId(String scenicId, Long userId);
List<FaceRespVO> listByScenicAndUserId(Long scenicId, Long userId);
List<FaceEntity> listUnpaidEntityBeforeDate(Long scenicId, Date endDate);
}

View File

@@ -59,4 +59,6 @@ public interface OrderMapper {
int updateMemberIdByFaceId(OrderEntity orderEntity);
List<OrderItemEntity> getOrderItems(Long orderId);
OrderEntity getUserBuyFaceItem(Long memberId, Long faceId, int goodsType, Long goodsId);
}

View File

@@ -35,7 +35,7 @@ public interface PrinterMapper {
PrintTaskEntity getTaskById(Integer id);
List<PrinterResp> listByScenicId(@Param("scenicId") Long scenicId);
int countFacePhoto(Long scenicId, Long faceId, Long sourceId);
List<MemberPrintResp> listRelation(@Param("memberId") Long memberId, @Param("scenicId") Long scenicId);
List<MemberPrintResp> listRelationByFaceId(Long memberId, Long scenicId, Long faceId);

View File

@@ -67,6 +67,13 @@ public interface SourceMapper {
List<SourceRespVO> queryByRelation(SourceReqQuery sourceReqQuery);
/**
* 按 faceId 和 type 分组查询源素材,每组返回最新的一条记录
* @param sourceReqQuery 查询参数
* @return 分组后的素材列表
*/
List<SourceRespVO> queryGroupedByFaceAndType(SourceReqQuery sourceReqQuery);
SourceEntity querySameVideo(Long faceSampleId, Long deviceId);
int hasRelationTo(Long memberId, Long sourceId, int type);
@@ -107,4 +114,38 @@ public interface SourceMapper {
int addFromZTSource(SourceEntity source);
SourceEntity getBySampleIdAndType(Long faceSampleId, Integer type);
/**
* 统计faceId关联的不同设备数量
* @param faceId 人脸ID
* @return 设备数量
*/
Integer countDistinctDevicesByFaceId(Long faceId);
/**
* 根据faceId和设备索引获取source
* @param faceId 人脸ID
* @param deviceIndex 设备索引(从0开始)
* @param type 素材类型(1-视频,2-图片)
* @param sortStrategy 排序策略
* @return source实体
*/
SourceEntity getSourceByFaceAndDeviceIndex(Long faceId, Integer deviceIndex, Integer type, String sortStrategy);
/**
* 获取faceId关联的所有设备ID列表
* @param faceId 人脸ID
* @return 设备ID列表
*/
List<Long> getDeviceIdsByFaceId(Long faceId);
/**
* 根据faceId和设备ID获取source
* @param faceId 人脸ID
* @param deviceId 设备ID
* @param type 素材类型(1-视频,2-图片)
* @param sortStrategy 排序策略
* @return source实体
*/
SourceEntity getSourceByFaceAndDeviceId(Long faceId, Long deviceId, Integer type, String sortStrategy);
}

View File

@@ -0,0 +1,10 @@
package com.ycwl.basic.model.mobile.goods;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class GoodsDetailPrintSceneVO extends GoodsDetailVO{
private Boolean inList;
}

View File

@@ -17,4 +17,5 @@ public class GoodsReqQuery {
private Long scenicId;
// 源素材商品类型 1视频 2图像
private Integer sourceType;
private String scene;
}

View File

@@ -17,71 +17,6 @@ import java.util.Date;
public class OrderAppPageReq extends BaseQueryParameterReq {
// 用户id
private Long memberId;
// /**
// * 微信openId
// */
// @ApiModelProperty("微信openId")
// private Long openId;
// /**
// * 价格
// */
// @ApiModelProperty("价格")
// private BigDecimal price;
// /**
// * 实际支付价格
// */
// @ApiModelProperty("实际支付价格")
// private BigDecimal payPrice;
// /**
// * 推客id
// */
// @ApiModelProperty("推客id")
// private Long brokerId;
// /**
// * 推客优惠码
// */
// @ApiModelProperty("推客优惠码")
// private String promoCode;
// /**
// * 退款原因
// */
// @ApiModelProperty("退款原因")
// private String refundReason;
// /**
// * 退款状态,0未提出,1已通过,2待审核
// */
// @ApiModelProperty("退款状态,0未提出,1已通过,2待审核")
// private Integer refundStatus;
// /**
// * 状态,0未支付,1已支付,2已退款,9已取消
// */
// @ApiModelProperty("状态,0未支付,1已支付,2已退款,9已取消")
// private Integer status;
// /**
// * 订单创建时间
// */
// @ApiModelProperty("订单创建时间")
// private Date startCreateTime;
// private Date endCreateTime;
// /**
// * 订单支付时间
// */
// @ApiModelProperty("订单支付时间")
// private Date startPayTime;
// private Date endPayTime;
// /**
// * 订单取消时间
// */
// @ApiModelProperty("订单取消时间")
// private Date startCancelTime;
// private Date endCancelTime;
// /**
// * 订单退款时间
// */
// @ApiModelProperty("订单退款时间")
// private Date startRefundTime;
// private Date endRefundTime;
// 订单类型 0成片(vlog) 1原片 2照片
private Integer type;
}

View File

@@ -8,6 +8,7 @@ import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
/**
* @Author:longbinbin
@@ -45,6 +46,8 @@ public class SourceReqQuery extends BaseQueryParameterReq {
// 是否被购买:0未购买,1已购买
private Integer isBuy;
private Long faceId;
// 人脸ID列表(批量查询用)
private List<Long> faceIds;
private Date startTime;
private Date endTime;
}

View File

@@ -27,6 +27,16 @@ public class VideoReviewRespDTO {
*/
private String videoUrl;
/**
* 模板ID(关联查询video表)
*/
private Long templateId;
/**
* 模板名称(关联查询)
*/
private String templateName;
/**
* 景区ID
*/

View File

@@ -13,6 +13,8 @@ public enum ProductType {
VLOG_VIDEO("VLOG_VIDEO", "Vlog视频"),
RECORDING_SET("RECORDING_SET", "录像集"),
PHOTO_SET("PHOTO_SET", "照相集"),
PHOTO_LOG("PHOTO_LOG", "pLog图"),
PHOTO_VLOG("PHOTO_VLOG", "pLog视频"),
PHOTO_PRINT("PHOTO_PRINT", "照片打印"),
PHOTO_PRINT_MU("PHOTO_PRINT_MU", "手机照片打印"),
PHOTO_PRINT_FX("PHOTO_PRINT_FX", "特效照片打印"),

View File

@@ -9,6 +9,7 @@ Puzzle拼图模块是一个基于模板和元素的动态图片生成系统,
- 多层次元素渲染:支持图片和文字元素的分层叠加
- 灵活的样式配置:支持位置、大小、透明度、旋转、圆角等属性
- 动态数据注入:通过elementKey进行动态数据替换
- 智能自动填充:基于规则引擎自动选择和填充素材数据
- 生成记录追踪:完整记录每次生成的参数和结果
**典型应用场景:**
@@ -27,28 +28,58 @@ Puzzle拼图模块是一个基于模板和元素的动态图片生成系统,
puzzle/
├── controller/ # API接口层
│ ├── PuzzleGenerateController.java # 拼图生成接口
── PuzzleTemplateController.java # 模板管理接口
── PuzzleTemplateController.java # 模板管理接口
│ └── PuzzleFillRuleController.java # 填充规则管理接口
├── service/ # 业务逻辑层
│ ├── IPuzzleGenerateService.java
│ ├── IPuzzleTemplateService.java
│ ├── IPuzzleFillRuleService.java
│ └── impl/
│ ├── PuzzleGenerateServiceImpl.java
── PuzzleTemplateServiceImpl.java
── PuzzleTemplateServiceImpl.java
│ └── PuzzleFillRuleServiceImpl.java
├── mapper/ # 数据访问层
│ ├── PuzzleTemplateMapper.java
│ ├── PuzzleElementMapper.java
── PuzzleGenerationRecordMapper.java
── PuzzleGenerationRecordMapper.java
│ ├── PuzzleFillRuleMapper.java
│ └── PuzzleFillRuleItemMapper.java
│ # 拼图引擎会复用基础域的 com.ycwl.basic.mapper.SourceMapper(不在 puzzle 包内)
├── entity/ # 实体类
│ ├── PuzzleTemplateEntity.java # 模板实体
│ ├── PuzzleElementEntity.java # 元素实体
── PuzzleGenerationRecordEntity.java # 生成记录实体
── PuzzleGenerationRecordEntity.java # 生成记录实体
│ ├── PuzzleFillRuleEntity.java # 填充规则实体
│ └── PuzzleFillRuleItemEntity.java # 填充规则明细实体
├── dto/ # 数据传输对象
│ ├── PuzzleGenerateRequest.java # 生成请求
│ ├── PuzzleGenerateResponse.java # 生成响应
│ ├── PuzzleTemplateDTO.java # 模板DTO
│ ├── PuzzleElementDTO.java # 元素DTO
│ ├── TemplateCreateRequest.java # 模板创建请求
── ElementCreateRequest.java # 元素创建请求
── ElementCreateRequest.java # 元素创建请求
│ ├── PuzzleFillRuleSaveRequest.java # 填充规则保存请求
│ ├── PuzzleFillRuleDTO.java # 填充规则DTO
│ └── PuzzleFillRuleItemDTO.java # 填充规则明细DTO
├── fill/ # 自动填充引擎
│ ├── PuzzleElementFillEngine.java # 填充引擎(核心)
│ ├── condition/ # 条件策略
│ │ ├── ConditionStrategy.java
│ │ ├── ConditionEvaluator.java
│ │ ├── ConditionContext.java
│ │ ├── AlwaysConditionStrategy.java
│ │ ├── DeviceCountConditionStrategy.java
│ │ ├── DeviceCountRangeConditionStrategy.java
│ │ └── DeviceIdMatchConditionStrategy.java
│ ├── datasource/ # 数据源解析
│ │ ├── DataSourceResolver.java
│ │ ├── DeviceImageDataSourceStrategy.java
│ │ ├── FaceUrlDataSourceStrategy.java
│ │ └── StaticValueDataSourceStrategy.java
│ └── enums/ # 枚举定义
│ ├── ConditionType.java
│ ├── DataSourceType.java
│ └── SortStrategy.java
└── util/ # 工具类
└── PuzzleImageRenderer.java # 图片渲染引擎(核心)
```
@@ -57,8 +88,16 @@ puzzle/
1. **服务层模式(Service Layer)**:业务逻辑封装在service层,controller只负责接口适配
2. **DTO模式**:使用独立的DTO对象处理API输入输出,与Entity分离
3. **策略模式**:图片适配模式(CONTAIN、COVER、FILL等)
3. **策略模式**:图片适配模式(CONTAIN、COVER、FILL等)、条件匹配策略、数据源解析策略、排序策略
4. **建造者模式**:通过模板+元素配置构建最终图片
5. **责任链模式**:自动填充规则按优先级顺序匹配执行
## 🔐 运行时安全与一致性保证
- **景区隔离强校验**:PuzzleGenerateServiceImpl 会根据模板的 `scenicId` 判断是否允许当前请求生成图片;模板绑定了景区时,请求必须传入相同的 `scenicId`,否则直接拒绝,避免跨租户串用模板。
- **自动填充参数兜底**:自动填充引擎 `PuzzleElementFillEngine` 仅在 `faceId + scenicId` 同时存在时触发,且规则没有明细项时会继续匹配下一条,防止空规则截断后续逻辑。
- **元素类型白名单**:`ElementConfigHelper` 仅允许 `ElementType` 枚举中已经落地的 TEXT、IMAGE 类型入库,杜绝未实现类型绕过验证。
- **图片下载防护**:`ImageElement` 只接受公网 http/https 地址,自动阻断内网、环回以及 file:// 资源,并设置请求超时,缓解 SSRF 与资源阻塞风险。
---
@@ -179,7 +218,93 @@ PuzzleGenerateResponse generate(PuzzleGenerateRequest request)
---
### 4. Controller接口层
### 4. PuzzleElementFillEngine - 自动填充引擎(新功能)
**职责**:基于规则引擎自动选择和填充拼图元素的数据源
**核心概念**
- 通过配置化的规则(而非硬编码)决定每个元素使用哪些素材
- 支持基于机位数量、机位ID、人脸特征等多维度条件匹配
- 支持多种数据源(机位图片、用户头像、二维码等)
- 支持灵活的排序策略(最新、评分、随机等)
- 支持优先级和降级策略
**核心方法**
```java
Map<String, String> execute(Long templateId, Long faceId, Long scenicId)
```
**执行流程**
1. **加载规则列表**
- 查询指定模板的所有启用规则(`PuzzleFillRuleMapper.listByTemplateId`
-`priority`降序排序(优先级高的先执行)
2. **构建上下文**
- 查询faceId关联的机位数量(`SourceMapper.countDistinctDevicesByFaceId`
- 查询faceId关联的机位ID列表(`SourceMapper.getDeviceIdsByFaceId`
- 构建`ConditionContext`对象
3. **规则匹配**
- 遍历规则列表,调用`ConditionEvaluator.evaluate()`评估每条规则
- 匹配到第一条符合条件的规则后停止(责任链模式)
4. **执行填充**
- 查询匹配规则的所有明细项(`PuzzleFillRuleItemMapper.listByRuleId`
-`itemOrder`排序
- 对每条明细调用`DataSourceResolver.resolve()`解析数据源
- 返回`Map<elementKey, dataValue>`
**条件策略(Strategy Pattern)**
| 策略类型 | 类名 | 匹配逻辑 | 配置示例 |
|---------|------|---------|---------|
| 总是匹配 | AlwaysConditionStrategy | 总是返回true,用作兜底规则 | `{}` |
| 机位数量匹配(模式1) | DeviceCountConditionStrategy | 实际机位数 ≥ deviceCount | `{"deviceCount": 4}` |
| 机位数量匹配(模式2) | DeviceCountConditionStrategy | 从指定列表中过滤并匹配数量 ≥ deviceCount,只取前N个,保持配置顺序 | `{"deviceCount": 2, "deviceIds": [200, 300, 400]}` |
| 机位ID匹配 | DeviceIdMatchConditionStrategy | 匹配指定的机位ID(支持ANY/ALL模式) | `{"deviceIds": [200, 300], "matchMode": "ALL"}` |
**数据源类型**
| 类型 | 说明 | sourceFilter 配置 |
|------|------|------------------|
| DEVICE_IMAGE | 机位图片 | `{"deviceIndex": 0, "type": 2}` - deviceIndex指定使用第几个机位,type指定图片类型 |
| USER_AVATAR | 用户头像 | `{}` |
| QR_CODE | 二维码 | `{"content": "{orderId}"}` - 支持变量替换 |
**排序策略**
| 策略 | 说明 |
|------|------|
| LATEST | 最新优先(按创建时间降序) |
| EARLIEST | 最早优先(按创建时间升序) |
| SCORE_DESC | 评分降序(适用于有评分的素材) |
| SCORE_ASC | 评分升序 |
| RANDOM | 随机选择 |
**降级策略**
- 每条明细可配置`fallbackValue`
- 当数据源无法获取到值时,使用降级默认值
- 如果降级值也为空,则跳过该元素的填充
**技术要点**
- 使用Spring的`@Component`自动注册策略
- 使用Jackson解析JSON配置
- 缓存机位数量和机位列表,单次执行仅查询一次
- 详细日志记录规则匹配和填充过程
**使用场景**
- 根据机位数量选择不同布局(4机位用4宫格,6机位用六宫格)
- 优先使用高质量机位的图片(指定机位200、300)
- 多机位组合场景(只有机位A和B同时存在时使用特定布局)
**性能优化**
- 规则数量建议不超过10条/模板
- 优先级高的规则应配置更精确的条件
- 使用`ALWAYS`策略作为兜底,确保总有规则匹配
---
### 5. Controller接口层
#### PuzzleGenerateController
```java
@@ -337,9 +462,66 @@ POST /puzzle/generate
---
### 4. puzzle_fill_rule - 拼图填充规则表
| 字段 | 类型 | 说明 |
|-----|------|-----|
| id | BIGINT | 主键ID |
| template_id | BIGINT | 关联的模板ID(外键) |
| rule_name | VARCHAR(100) | 规则名称 |
| condition_type | VARCHAR(50) | 条件类型:DEVICE_COUNT/DEVICE_COUNT_RANGE/DEVICE_ID_MATCH/ALWAYS |
| condition_value | TEXT | 条件配置(JSON格式) |
| priority | INT | 优先级(数值越大越优先) |
| enabled | TINYINT | 是否启用:0-禁用 1-启用 |
| scenic_id | BIGINT | 景区ID(多租户隔离) |
| description | TEXT | 规则描述 |
| create_time | DATETIME | 创建时间 |
| update_time | DATETIME | 更新时间 |
| deleted | TINYINT | 删除标记:0-未删除 1-已删除 |
| deleted_at | DATETIME | 删除时间 |
**索引**
- KEY `idx_template_scenic` (template_id, scenic_id, deleted)
- KEY `idx_priority` (priority)
**业务逻辑**
- 规则按`priority`降序排列执行
- 匹配到第一条符合条件的规则后停止
- 建议使用`ALWAYS`类型作为兜底规则(最低优先级)
---
### 5. puzzle_fill_rule_item - 拼图填充规则明细表
| 字段 | 类型 | 说明 |
|-----|------|-----|
| id | BIGINT | 主键ID |
| rule_id | BIGINT | 关联的规则ID(外键) |
| element_key | VARCHAR(50) | 目标元素标识(对应puzzle_element的element_key) |
| data_source | VARCHAR(50) | 数据源类型:DEVICE_IMAGE/USER_AVATAR/QR_CODE等 |
| source_filter | TEXT | 数据源过滤条件(JSON格式) |
| sort_strategy | VARCHAR(50) | 排序策略:LATEST/EARLIEST/SCORE_DESC/SCORE_ASC/RANDOM |
| fallback_value | VARCHAR(500) | 降级默认值(数据源无法获取时使用) |
| item_order | INT | 明细排序(决定执行顺序) |
| create_time | DATETIME | 创建时间 |
| update_time | DATETIME | 更新时间 |
| deleted | TINYINT | 删除标记:0-未删除 1-已删除 |
| deleted_at | DATETIME | 删除时间 |
**索引**
- KEY `idx_rule_id` (rule_id, deleted)
- KEY `idx_element_key` (element_key)
**业务逻辑**
- 明细项按`item_order`升序执行
- 每条明细对应一个元素的填充逻辑
- 支持降级策略(fallbackValue)
---
## 🔄 关键业务流程
### 拼图生成完整流程
### 拼图生成完整流程(含自动填充)
```
用户请求 → Controller接收
@@ -350,6 +532,16 @@ POST /puzzle/generate
根据templateId查询所有元素(按z-index排序)
【新增】调用PuzzleElementFillEngine.execute()(自动填充)
├─ 查询该模板的所有填充规则(按优先级排序)
├─ 构建ConditionContext(机位数量、机位列表等)
├─ 遍历规则进行条件匹配
├─ 找到匹配规则后,加载其明细列表
├─ 对每条明细调用DataSourceResolver解析数据源
└─ 返回Map<elementKey, dataValue>
合并自动填充数据和用户手动数据(用户数据优先级更高)
调用PuzzleImageRenderer.render()
├─ 创建画布
├─ 绘制背景
@@ -390,22 +582,6 @@ POST /puzzle/generate
---
## 🛠️ 技术栈
### 核心依赖
- **Spring Boot**:框架基础
- **MyBatis Plus**:数据访问
- **Lombok**:减少样板代码
- **Hutool**:工具类库(图片处理、HTTP下载)
- **Java AWT/ImageIO**:图形绘制和图片处理
- **SLF4J/Logback**:日志
### 外部依赖
- **OSS对象存储**:图片上传和存储
- **MySQL**:关系型数据库
---
## 📦 对外依赖
puzzle模块与其他模块的依赖关系:
@@ -590,22 +766,3 @@ request.setQuality(90);
PuzzleGenerateResponse response = generateService.generate(request);
System.out.println("生成成功,图片URL: " + response.getImageUrl());
```
---
## 🔗 相关文档
- [MyBatis-Plus官方文档](https://baomidou.com/)
- [Hutool工具类文档](https://hutool.cn/)
- [Java AWT图形绘制教程](https://docs.oracle.com/javase/tutorial/2d/)
- [阿里云OSS Java SDK](https://help.aliyun.com/document_detail/32008.html)
---
## 📞 联系方式
如有问题或建议请联系模块负责人或提交Issue
**维护者**Claude
**创建时间**2025-01-17
**最后更新**2025-01-17

View File

@@ -0,0 +1,117 @@
package com.ycwl.basic.puzzle.controller;
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleDTO;
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleSaveRequest;
import com.ycwl.basic.puzzle.service.IPuzzleFillRuleService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 拼图填充规则管理Controller
*
* @author Claude
* @since 2025-01-19
*/
@Slf4j
@RestController
@RequestMapping("/api/puzzle/admin/fill-rule")
@RequiredArgsConstructor
public class PuzzleFillRuleController {
private final IPuzzleFillRuleService fillRuleService;
/**
* 创建填充规则
*
* @param request 保存请求(包含主规则+明细列表)
* @return 规则ID
*/
@PostMapping
public ApiResponse<Long> create(@RequestBody PuzzleFillRuleSaveRequest request) {
log.info("创建填充规则, ruleName={}, itemCount={}",
request.getRuleName(),
request.getItems() != null ? request.getItems().size() : 0);
Long ruleId = fillRuleService.create(request);
return ApiResponse.success(ruleId);
}
/**
* 更新填充规则
*
* @param id 规则ID
* @param request 保存请求
* @return 是否成功
*/
@PutMapping("/{id}")
public ApiResponse<Boolean> update(@PathVariable Long id,
@RequestBody PuzzleFillRuleSaveRequest request) {
log.info("更新填充规则, ruleId={}, ruleName={}", id, request.getRuleName());
request.setId(id);
Boolean success = fillRuleService.update(request);
return ApiResponse.success(success);
}
/**
* 删除填充规则
*
* @param id 规则ID
* @return 是否成功
*/
@DeleteMapping("/{id}")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
log.info("删除填充规则, ruleId={}", id);
Boolean success = fillRuleService.delete(id);
return ApiResponse.success(success);
}
/**
* 查询单条规则(含明细)
*
* @param id 规则ID
* @return 规则DTO
*/
@GetMapping("/{id}")
public ApiResponse<PuzzleFillRuleDTO> getById(@PathVariable Long id) {
log.info("查询填充规则, ruleId={}", id);
PuzzleFillRuleDTO rule = fillRuleService.getById(id);
return ApiResponse.success(rule);
}
/**
* 查询模板的所有规则(含明细)
*
* @param templateId 模板ID
* @return 规则列表
*/
@GetMapping("/template/{templateId}")
public ApiResponse<List<PuzzleFillRuleDTO>> listByTemplateId(@PathVariable Long templateId) {
log.info("查询模板的所有填充规则, templateId={}", templateId);
List<PuzzleFillRuleDTO> rules = fillRuleService.listByTemplateId(templateId);
return ApiResponse.success(rules);
}
/**
* 启用/禁用规则
*
* @param id 规则ID
* @param enabled 是否启用(0-禁用 1-启用)
* @return 是否成功
*/
@PostMapping("/{id}/toggle")
public ApiResponse<Boolean> toggleEnabled(@PathVariable Long id,
@RequestParam Integer enabled) {
log.info("切换规则启用状态, ruleId={}, enabled={}", id, enabled);
Boolean success = fillRuleService.toggleEnabled(id, enabled);
return ApiResponse.success(success);
}
}

View File

@@ -27,8 +27,8 @@ public class PuzzleGenerateController {
*/
@PostMapping("/generate")
public ApiResponse<PuzzleGenerateResponse> generatePuzzle(@RequestBody PuzzleGenerateRequest request) {
log.info("拼图生成请求: templateCode={}, userId={}, orderId={}",
request.getTemplateCode(), request.getUserId(), request.getOrderId());
log.info("拼图生成请求: templateCode={}, userId={}, faceId={}",
request.getTemplateCode(), request.getUserId(), request.getFaceId());
// 参数校验
if (request.getTemplateCode() == null || request.getTemplateCode().trim().isEmpty()) {

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.puzzle.controller;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
@@ -80,6 +81,22 @@ public class PuzzleTemplateController {
return ApiResponse.success(templates);
}
/**
* 分页获取模板列表
*/
@GetMapping("/templates/page")
public ApiResponse<PageResponse<PuzzleTemplateDTO>> pageTemplates(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Long scenicId,
@RequestParam(required = false) String category,
@RequestParam(required = false) Integer status) {
log.debug("分页查询模板列表: page={}, pageSize={}, scenicId={}, category={}, status={}",
page, pageSize, scenicId, category, status);
PageResponse<PuzzleTemplateDTO> templates = templateService.pageTemplates(page, pageSize, scenicId, category, status);
return ApiResponse.success(templates);
}
/**
* 为模板添加单个元素
*/
@@ -101,6 +118,17 @@ public class PuzzleTemplateController {
return ApiResponse.success(null);
}
/**
* 批量替换模板元素(删除旧元素,添加新元素)
*/
@PutMapping("/templates/{templateId}/elements")
public ApiResponse<Void> replaceElements(@PathVariable Long templateId,
@RequestBody List<ElementCreateRequest> elements) {
log.info("批量替换元素请求: templateId={}, count={}", templateId, elements.size());
templateService.replaceElements(templateId, elements);
return ApiResponse.success(null);
}
/**
* 更新元素
*/

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.puzzle.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Map;
@@ -22,21 +23,25 @@ public class ElementCreateRequest {
/**
* 模板ID
*/
@JsonProperty("templateId")
private Long templateId;
/**
* 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
*/
@JsonProperty("elementType")
private String elementType;
/**
* 元素标识(用于动态数据映射)
*/
@JsonProperty("elementKey")
private String elementKey;
/**
* 元素名称(便于管理识别)
*/
@JsonProperty("elementName")
private String elementName;
// ===== 位置和布局属性(所有元素通用) =====
@@ -44,36 +49,43 @@ public class ElementCreateRequest {
/**
* X坐标(相对于画布左上角,像素)
*/
@JsonProperty("xPosition")
private Integer xPosition;
/**
* Y坐标(相对于画布左上角,像素)
*/
@JsonProperty("yPosition")
private Integer yPosition;
/**
* 宽度(像素)
*/
@JsonProperty("width")
private Integer width;
/**
* 高度(像素)
*/
@JsonProperty("height")
private Integer height;
/**
* 层级(数值越大越靠上)
*/
@JsonProperty("zIndex")
private Integer zIndex;
/**
* 旋转角度(0-360度,顺时针)
*/
@JsonProperty("rotation")
private Integer rotation;
/**
* 不透明度(0-100,100为完全不透明)
*/
@JsonProperty("opacity")
private Integer opacity;
// ===== JSON配置(二选一) =====
@@ -85,6 +97,7 @@ public class ElementCreateRequest {
* - 文字元素:"{\"defaultText\":\"用户名\", \"fontFamily\":\"微软雅黑\", \"fontSize\":14}"
* - 图片元素:"{\"defaultImageUrl\":\"https://...\", \"imageFitMode\":\"COVER\", \"borderRadius\":10}"
*/
@JsonProperty("config")
private String config;
/**
@@ -96,5 +109,6 @@ public class ElementCreateRequest {
* configMap.put("fontSize", 14);
* request.setConfigMap(configMap);
*/
@JsonProperty("configMap")
private Map<String, Object> configMap;
}

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.puzzle.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Map;
@@ -21,26 +22,31 @@ public class PuzzleElementDTO {
/**
* 元素ID
*/
@JsonProperty("id")
private Long id;
/**
* 模板ID
*/
@JsonProperty("templateId")
private Long templateId;
/**
* 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
*/
@JsonProperty("elementType")
private String elementType;
/**
* 元素标识(用于动态数据映射)
*/
@JsonProperty("elementKey")
private String elementKey;
/**
* 元素名称(便于管理识别)
*/
@JsonProperty("elementName")
private String elementName;
// ===== 位置和布局属性(所有元素通用) =====
@@ -48,36 +54,43 @@ public class PuzzleElementDTO {
/**
* X坐标(相对于画布左上角,像素)
*/
@JsonProperty("xPosition")
private Integer xPosition;
/**
* Y坐标(相对于画布左上角,像素)
*/
@JsonProperty("yPosition")
private Integer yPosition;
/**
* 宽度(像素)
*/
@JsonProperty("width")
private Integer width;
/**
* 高度(像素)
*/
@JsonProperty("height")
private Integer height;
/**
* 层级(数值越大越靠上)
*/
@JsonProperty("zIndex")
private Integer zIndex;
/**
* 旋转角度(0-360度,顺时针)
*/
@JsonProperty("rotation")
private Integer rotation;
/**
* 不透明度(0-100,100为完全不透明)
*/
@JsonProperty("opacity")
private Integer opacity;
// ===== JSON配置 =====
@@ -85,10 +98,12 @@ public class PuzzleElementDTO {
/**
* JSON配置字符串
*/
@JsonProperty("config")
private String config;
/**
* JSON配置Map(方便前端使用)
*/
@JsonProperty("configMap")
private Map<String, Object> configMap;
}

View File

@@ -0,0 +1,68 @@
package com.ycwl.basic.puzzle.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 拼图填充规则DTO
*/
@Data
public class PuzzleFillRuleDTO {
/**
* 规则ID
*/
private Long id;
/**
* 关联的模板ID
*/
private Long templateId;
/**
* 规则名称
*/
private String ruleName;
/**
* 条件类型
*/
private String conditionType;
/**
* 条件值(JSON字符串)
*/
private String conditionValue;
/**
* 优先级
*/
private Integer priority;
/**
* 是否启用
*/
private Integer enabled;
/**
* 规则描述
*/
private String description;
/**
* 明细列表
*/
private List<PuzzleFillRuleItemDTO> items;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,45 @@
package com.ycwl.basic.puzzle.dto;
import lombok.Data;
/**
* 拼图填充规则明细DTO
*/
@Data
public class PuzzleFillRuleItemDTO {
/**
* 明细ID
*/
private Long id;
/**
* 目标元素标识
*/
private String elementKey;
/**
* 数据源类型
*/
private String dataSource;
/**
* 数据过滤条件(JSON字符串)
*/
private String sourceFilter;
/**
* 排序策略
*/
private String sortStrategy;
/**
* 降级默认值
*/
private String fallbackValue;
/**
* 明细排序
*/
private Integer itemOrder;
}

View File

@@ -0,0 +1,58 @@
package com.ycwl.basic.puzzle.dto;
import lombok.Data;
import java.util.List;
/**
* 拼图填充规则保存请求
* 包含主规则+明细列表
*/
@Data
public class PuzzleFillRuleSaveRequest {
/**
* 规则ID(更新时传入)
*/
private Long id;
/**
* 关联的模板ID
*/
private Long templateId;
/**
* 规则名称
*/
private String ruleName;
/**
* 条件类型
*/
private String conditionType;
/**
* 条件值(JSON字符串)
*/
private String conditionValue;
/**
* 优先级
*/
private Integer priority;
/**
* 是否启用
*/
private Integer enabled;
/**
* 规则描述
*/
private String description;
/**
* 明细列表(主从一起保存)
*/
private List<PuzzleFillRuleItemDTO> items;
}

View File

@@ -23,11 +23,6 @@ public class PuzzleGenerateRequest {
*/
private Long userId;
/**
* 订单ID(可选)
*/
private String orderId;
/**
* 业务类型(可选)
*/
@@ -38,9 +33,15 @@ public class PuzzleGenerateRequest {
*/
private Long scenicId;
/**
* 人脸ID(可选,用于触发自动填充规则)
*/
private Long faceId;
/**
* 动态数据(key为元素的elementKey,value为实际值)
* 例如:{"userAvatar": "https://...", "userName": "张三", "orderNumber": "ORDER123"}
* 注意:手动传入的dynamicData优先级高于自动填充的数据
*/
private Map<String, String> dynamicData;
@@ -55,4 +56,11 @@ public class PuzzleGenerateRequest {
* 仅对JPEG格式有效
*/
private Integer quality;
/**
* 是否必须匹配填充规则才能生成(可选,默认false)
* true: 如果没有规则匹配,抛出异常,不生成图片
* false: 无论是否匹配规则,都继续生成(默认行为)
*/
private Boolean requireRuleMatch = false;
}

View File

@@ -46,9 +46,28 @@ public class PuzzleGenerateResponse {
private Long recordId;
/**
* 创建成功响应
* 是否为复用历史记录
* true-复用历史图片(未重新渲染), false-新生成
*/
private Boolean isDuplicate;
/**
* 原始记录ID
* 当isDuplicate=true时,记录被复用的原始记录ID
*/
private Long originalRecordId;
/**
* 创建成功响应(新生成)
*/
public static PuzzleGenerateResponse success(String imageUrl, Long fileSize, Integer width, Integer height, Integer duration, Long recordId) {
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId);
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId, false, null);
}
/**
* 创建成功响应(支持去重标记)
*/
public static PuzzleGenerateResponse success(String imageUrl, Long fileSize, Integer width, Integer height, Integer duration, Long recordId, Boolean isDuplicate, Long originalRecordId) {
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId, isDuplicate, originalRecordId);
}
}

View File

@@ -53,6 +53,11 @@ public class PuzzleTemplateDTO {
*/
private String backgroundImage;
/**
* 模板封面图片URL
*/
private String coverImage;
/**
* 模板描述
*/

View File

@@ -46,6 +46,11 @@ public class TemplateCreateRequest {
*/
private String backgroundImage;
/**
* 模板封面图片URL
*/
private String coverImage;
/**
* 模板描述
*/

View File

@@ -50,11 +50,12 @@ public class ImageConfig implements ElementConfig {
}
}
// 校验图片URL格式(可选)
if (StrUtil.isNotBlank(defaultImageUrl)) {
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
}
// 校验图片URL
if (StrUtil.isBlank(defaultImageUrl)) {
throw new IllegalArgumentException("默认图片URL不能为空");
}
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
}
}

View File

@@ -1,7 +1,7 @@
package com.ycwl.basic.puzzle.element.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import cn.hutool.http.HttpRequest;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.config.ImageConfig;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
@@ -14,7 +14,12 @@ import java.awt.geom.Ellipse2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.net.InetAddress;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 图片元素实现
@@ -25,6 +30,8 @@ import java.io.File;
@Slf4j
public class ImageElement extends BaseElement {
private static final int DOWNLOAD_TIMEOUT_MS = 5000;
private ImageConfig imageConfig;
@Override
@@ -105,29 +112,32 @@ public class ImageElement extends BaseElement {
* @param imageUrl 图片URL或本地文件路径
* @return BufferedImage对象
*/
private BufferedImage downloadImage(String imageUrl) {
try {
log.debug("下载图片: url={}", imageUrl);
// 判断是否为本地文件路径
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
// 网络图片
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
return ImageIO.read(new ByteArrayInputStream(imageBytes));
} else {
// 本地文件
File file = new File(imageUrl);
if (file.exists()) {
return ImageIO.read(file);
} else {
log.error("本地图片文件不存在: path={}", imageUrl);
return null;
}
}
} catch (Exception e) {
log.error("图片下载失败: url={}", imageUrl, e);
protected BufferedImage downloadImage(String imageUrl) {
if (StrUtil.isBlank(imageUrl)) {
return null;
}
if (isRemoteUrl(imageUrl)) {
if (!isSafeRemoteUrl(imageUrl)) {
log.warn("图片URL未通过安全校验, 已拒绝下载: {}", imageUrl);
return null;
}
try {
log.debug("下载图片: url={}", imageUrl);
byte[] imageBytes = HttpRequest.get(imageUrl)
.timeout(DOWNLOAD_TIMEOUT_MS)
.setFollowRedirects(false)
.execute()
.bodyBytes();
return ImageIO.read(new ByteArrayInputStream(imageBytes));
} catch (Exception e) {
log.error("图片下载失败: url={}", imageUrl, e);
return null;
}
}
return loadLocalImage(imageUrl);
}
/**
@@ -251,4 +261,63 @@ public class ImageElement extends BaseElement {
// 绘制到主画布
g2d.drawImage(rounded, position.getX(), position.getY(), null);
}
private boolean isRemoteUrl(String imageUrl) {
return StrUtil.startWithIgnoreCase(imageUrl, "http://") ||
StrUtil.startWithIgnoreCase(imageUrl, "https://");
}
/**
* 判断URL是否为安全的公网HTTP地址,避免SSRF
*/
protected boolean isSafeRemoteUrl(String imageUrl) {
if (StrUtil.isBlank(imageUrl)) {
return false;
}
try {
URL url = new URL(imageUrl);
String protocol = url.getProtocol();
if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) {
return false;
}
InetAddress address = InetAddress.getByName(url.getHost());
if (address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()) {
return false;
}
return true;
} catch (Exception e) {
log.warn("图片URL解析失败: {}", imageUrl, e);
return false;
}
}
private BufferedImage loadLocalImage(String imageUrl) {
try {
Path path;
if (StrUtil.startWithIgnoreCase(imageUrl, "file:")) {
path = Paths.get(new URI(imageUrl));
} else {
path = Paths.get(imageUrl);
}
if (!Files.exists(path) || !Files.isRegularFile(path)) {
log.error("本地图片文件不存在: {}", imageUrl);
return null;
}
log.debug("加载本地图片: {}", path);
try (var inputStream = Files.newInputStream(path)) {
return ImageIO.read(inputStream);
}
} catch (Exception e) {
log.error("本地图片加载失败: {}", imageUrl, e);
return null;
}
}
}

View File

@@ -141,8 +141,12 @@ public class TextElement extends BaseElement {
? textConfig.getTextAlign().toUpperCase()
: "LEFT";
// 起始Y坐标
int y = position.getY() + fm.getAscent();
// 计算总文本高度并实现垂直居中
int totalTextHeight = lineHeight * actualLines;
int verticalOffset = (position.getHeight() - totalTextHeight) / 2;
// 起始Y坐标(垂直居中)
int y = position.getY() + verticalOffset + fm.getAscent();
// 逐行绘制
for (int i = 0; i < actualLines; i++) {

View File

@@ -0,0 +1,74 @@
package com.ycwl.basic.puzzle.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 拼图自动填充规则实体
*/
@Data
@TableName("puzzle_fill_rule")
public class PuzzleFillRuleEntity {
/**
* 规则ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 关联的模板ID
*/
private Long templateId;
/**
* 规则名称
*/
private String ruleName;
/**
* 条件类型: DEVICE_COUNT-机位数量
*/
private String conditionType;
/**
* 条件值(JSON格式)
*/
private String conditionValue;
/**
* 优先级(数值越大越优先)
*/
private Integer priority;
/**
* 是否启用(0-否 1-是)
*/
private Integer enabled;
/**
* 规则描述
*/
private String description;
/**
* 删除标记(0-未删除 1-已删除)
*/
@TableLogic
private Integer deleted;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,67 @@
package com.ycwl.basic.puzzle.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 拼图自动填充规则明细实体
*/
@Data
@TableName("puzzle_fill_rule_item")
public class PuzzleFillRuleItemEntity {
/**
* 明细ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 关联的规则ID
*/
private Long ruleId;
/**
* 目标元素标识
*/
private String elementKey;
/**
* 数据源类型: FACE_URL, SOURCE_IMAGE, DEVICE_IMAGE等
*/
private String dataSource;
/**
* 数据过滤条件(JSON格式)
*/
private String sourceFilter;
/**
* 排序策略: LATEST-最新, SCORE_DESC-分数降序
*/
private String sortStrategy;
/**
* 降级默认值
*/
private String fallbackValue;
/**
* 明细排序
*/
private Integer itemOrder;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}

View File

@@ -44,10 +44,10 @@ public class PuzzleGenerationRecordEntity {
private Long userId;
/**
* 关联订单号
* 人脸ID(用于关联素材和追溯)
*/
@TableField("order_id")
private String orderId;
@TableField("face_id")
private Long faceId;
/**
* 业务类型(如:order-订单 ticket-门票 certificate-证书)
@@ -63,6 +63,13 @@ public class PuzzleGenerationRecordEntity {
@TableField("generation_params")
private String generationParams;
/**
* 内容哈希(SHA256)
* 用于去重检测,基于所有元素的实际内容计算
*/
@TableField("content_hash")
private String contentHash;
/**
* 生成的图片URL
*/

View File

@@ -67,6 +67,12 @@ public class PuzzleTemplateEntity {
@TableField("background_image")
private String backgroundImage;
/**
* 模板封面图片URL(用于前端管理界面展示)
*/
@TableField("cover_image")
private String coverImage;
/**
* 模板描述
*/

View File

@@ -0,0 +1,49 @@
package com.ycwl.basic.puzzle.exception;
/**
* 重复图片异常
* 当所有图片元素使用相同URL时抛出此异常
*
* @author Claude
* @since 2025-01-21
*/
public class DuplicateImageException extends PuzzleBizException {
private static final String DEFAULT_MESSAGE_TEMPLATE = "检测到所有图片元素使用相同URL,拒绝生成: %s (元素数量: %d)";
private final String duplicateImageUrl;
private final int elementCount;
/**
* 构造函数
*
* @param duplicateImageUrl 重复的图片URL
* @param elementCount 使用相同URL的元素数量
*/
public DuplicateImageException(String duplicateImageUrl, int elementCount) {
super(String.format(DEFAULT_MESSAGE_TEMPLATE, duplicateImageUrl, elementCount));
this.duplicateImageUrl = duplicateImageUrl;
this.elementCount = elementCount;
}
/**
* 构造函数(带自定义消息)
*
* @param message 自定义错误消息
* @param duplicateImageUrl 重复的图片URL
* @param elementCount 元素数量
*/
public DuplicateImageException(String message, String duplicateImageUrl, int elementCount) {
super(message);
this.duplicateImageUrl = duplicateImageUrl;
this.elementCount = elementCount;
}
public String getDuplicateImageUrl() {
return duplicateImageUrl;
}
public int getElementCount() {
return elementCount;
}
}

View File

@@ -0,0 +1,7 @@
package com.ycwl.basic.puzzle.exception;
public class PuzzleBizException extends RuntimeException {
public PuzzleBizException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,66 @@
package com.ycwl.basic.puzzle.fill;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashMap;
import java.util.Map;
/**
* 拼图元素填充结果
*
* @author Claude
* @since 2025-01-20
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FillResult {
/**
* 是否匹配到规则
*/
private boolean ruleMatched;
/**
* 匹配的规则名称(如果有)
*/
private String matchedRuleName;
/**
* 填充的数据
*/
@Builder.Default
private Map<String, String> dynamicData = new HashMap<>();
/**
* 成功填充的元素数量
*/
private int filledCount;
/**
* 创建空结果(未匹配)
*/
public static FillResult noMatch() {
return FillResult.builder()
.ruleMatched(false)
.dynamicData(new HashMap<>())
.filledCount(0)
.build();
}
/**
* 创建匹配成功的结果
*/
public static FillResult matched(String ruleName, Map<String, String> data, int count) {
return FillResult.builder()
.ruleMatched(true)
.matchedRuleName(ruleName)
.dynamicData(data)
.filledCount(count)
.build();
}
}

View File

@@ -0,0 +1,144 @@
package com.ycwl.basic.puzzle.fill;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity;
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity;
import com.ycwl.basic.puzzle.fill.condition.ConditionContext;
import com.ycwl.basic.puzzle.fill.condition.ConditionEvaluator;
import com.ycwl.basic.puzzle.fill.datasource.DataSourceContext;
import com.ycwl.basic.puzzle.fill.datasource.DataSourceResolver;
import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleItemMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 拼图元素自动填充引擎
* 核心业务逻辑:
* 1. 查询模板的所有规则(按priority DESC)
* 2. 遍历规则,评估条件是否匹配
* 3. 匹配成功后,批量填充该规则的所有明细项
* 4. 匹配第一条后停止(优先级逻辑)
*/
@Slf4j
@Component
public class PuzzleElementFillEngine {
@Autowired
private PuzzleFillRuleMapper ruleMapper;
@Autowired
private PuzzleFillRuleItemMapper itemMapper;
@Autowired
private SourceMapper sourceMapper;
@Autowired
private ConditionEvaluator conditionEvaluator;
@Autowired
private DataSourceResolver dataSourceResolver;
/**
* 执行填充规则
*
* @param templateId 模板ID
* @param faceId 人脸ID
* @param scenicId 景区ID
* @return 填充结果(包含是否匹配规则的信息)
*/
public FillResult execute(Long templateId, Long faceId, Long scenicId) {
if (faceId == null) {
log.debug("自动填充被跳过, templateId={}, faceId={}", templateId, faceId);
return FillResult.noMatch();
}
try {
// 1. 查询模板的所有启用规则(按priority DESC排序)
List<PuzzleFillRuleEntity> rules = ruleMapper.listByTemplateId(templateId);
if (rules == null || rules.isEmpty()) {
log.debug("模板[{}]没有配置自动填充规则", templateId);
return FillResult.noMatch();
}
log.info("模板[{}]共有{}条填充规则,开始执行...", templateId, rules.size());
// 2. 统计机位数量和获取机位列表(缓存,避免重复查询)
Integer deviceCount = sourceMapper.countDistinctDevicesByFaceId(faceId);
List<Long> deviceIds = sourceMapper.getDeviceIdsByFaceId(faceId);
log.debug("faceId[{}]关联机位数量: {}, 机位列表: {}", faceId, deviceCount, deviceIds);
// 3. 构建条件评估上下文
ConditionContext conditionContext = ConditionContext.builder()
.faceId(faceId)
.deviceCount(deviceCount)
.deviceIds(deviceIds)
.build();
// 4. 遍历规则,匹配第一条后停止
for (PuzzleFillRuleEntity rule : rules) {
// 评估条件是否匹配
boolean matched = conditionEvaluator.evaluate(rule, conditionContext);
if (!matched) {
log.debug("规则[{}]条件不匹配,跳过", rule.getRuleName());
continue;
}
// 条件匹配!查询该规则的所有明细
log.info("规则[{}]匹配成功,开始执行填充...", rule.getRuleName());
List<PuzzleFillRuleItemEntity> items = itemMapper.listByRuleId(rule.getId());
if (items == null || items.isEmpty()) {
log.warn("规则[{}]没有配置明细项", rule.getRuleName());
continue;
}
// 5. 批量填充dynamicData
DataSourceContext dataSourceContext = DataSourceContext.builder()
.faceId(faceId)
.scenicId(scenicId)
.extra(conditionContext.getExtra())
.build();
Map<String, String> dynamicData = new HashMap<>();
int successCount = 0;
for (PuzzleFillRuleItemEntity item : items) {
String value = dataSourceResolver.resolve(
item.getDataSource(),
item.getSourceFilter(),
item.getSortStrategy(),
item.getFallbackValue(),
dataSourceContext
);
if (value != null && !value.isEmpty()) {
dynamicData.put(item.getElementKey(), value);
successCount++;
log.debug("填充成功: {} -> {}", item.getElementKey(), value);
} else {
log.debug("填充失败(值为空): {}", item.getElementKey());
}
}
log.info("规则[{}]执行完成,成功填充{}/{}个元素", rule.getRuleName(), successCount, items.size());
// 6. 返回匹配成功的结果
return FillResult.matched(rule.getRuleName(), dynamicData, successCount);
}
// 所有规则都不匹配
log.info("所有规则都不匹配, templateId={}, faceId={}", templateId, faceId);
return FillResult.noMatch();
} catch (Exception e) {
log.error("自动填充引擎执行异常, templateId={}, faceId={}", templateId, faceId, e);
return FillResult.noMatch();
}
}
}

View File

@@ -0,0 +1,22 @@
package com.ycwl.basic.puzzle.fill.condition;
import com.fasterxml.jackson.databind.JsonNode;
import com.ycwl.basic.puzzle.fill.enums.ConditionType;
import org.springframework.stereotype.Component;
/**
* 总是匹配策略(兜底规则)
*/
@Component
public class AlwaysConditionStrategy implements ConditionStrategy {
@Override
public boolean evaluate(JsonNode conditionValue, ConditionContext context) {
return true;
}
@Override
public String getSupportedType() {
return ConditionType.ALWAYS.getCode();
}
}

View File

@@ -0,0 +1,36 @@
package com.ycwl.basic.puzzle.fill.condition;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 条件评估上下文
* 包含评估所需的各种运行时数据
*/
@Data
@Builder
public class ConditionContext {
/**
* 人脸ID
*/
private Long faceId;
/**
* 机位数量(缓存值,避免重复查询)
*/
private Integer deviceCount;
/**
* 机位ID列表(用于精确匹配指定的机位)
*/
private List<Long> deviceIds;
/**
* 可扩展的其他上下文数据
*/
private Map<String, Object> extra;
}

View File

@@ -0,0 +1,64 @@
package com.ycwl.basic.puzzle.fill.condition;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 条件评估器
* 使用策略模式,根据conditionType动态选择评估策略
*/
@Slf4j
@Component
public class ConditionEvaluator {
private final Map<String, ConditionStrategy> strategyMap;
private final ObjectMapper objectMapper;
@Autowired
public ConditionEvaluator(List<ConditionStrategy> strategies, ObjectMapper objectMapper) {
this.strategyMap = strategies.stream()
.collect(Collectors.toMap(
ConditionStrategy::getSupportedType,
Function.identity()
));
this.objectMapper = objectMapper;
log.info("初始化条件评估器,已注册{}个策略: {}", strategyMap.size(), strategyMap.keySet());
}
/**
* 评估规则条件是否匹配
*
* @param rule 规则实体
* @param context 评估上下文
* @return true-匹配, false-不匹配
*/
public boolean evaluate(PuzzleFillRuleEntity rule, ConditionContext context) {
String conditionType = rule.getConditionType();
ConditionStrategy strategy = strategyMap.get(conditionType);
if (strategy == null) {
log.warn("未找到条件类型[{}]对应的评估策略,规则ID:{}", conditionType, rule.getId());
return false;
}
try {
JsonNode conditionValue = objectMapper.readTree(rule.getConditionValue());
boolean result = strategy.evaluate(conditionValue, context);
log.debug("规则[{}]条件评估结果: {}, 条件类型: {}, 条件值: {}",
rule.getRuleName(), result, conditionType, rule.getConditionValue());
return result;
} catch (Exception e) {
log.error("规则[{}]条件评估异常,规则ID:{}", rule.getRuleName(), rule.getId(), e);
return false;
}
}
}

View File

@@ -0,0 +1,24 @@
package com.ycwl.basic.puzzle.fill.condition;
import com.fasterxml.jackson.databind.JsonNode;
/**
* 条件评估策略接口
* 使用策略模式,每种条件类型实现独立的评估逻辑,方便测试和扩展
*/
public interface ConditionStrategy {
/**
* 评估条件是否匹配
*
* @param conditionValue 条件值(JSON)
* @param context 评估上下文
* @return true-匹配, false-不匹配
*/
boolean evaluate(JsonNode conditionValue, ConditionContext context);
/**
* 获取支持的条件类型
*/
String getSupportedType();
}

View File

@@ -0,0 +1,153 @@
package com.ycwl.basic.puzzle.fill.condition;
import com.fasterxml.jackson.databind.JsonNode;
import com.ycwl.basic.puzzle.fill.enums.ConditionType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 机位数量匹配策略(大于等于匹配)
*
* <p>支持两种匹配模式:</p>
* <ul>
* <li>模式1:全局数量匹配 - 只指定 deviceCount,匹配所有机位的数量 ≥ deviceCount</li>
* <li>模式2:指定列表数量匹配 - 同时指定 deviceCount + deviceIds,从指定列表中过滤并匹配数量 ≥ deviceCount</li>
* </ul>
*
* <p>配置示例:</p>
* <pre>
* // 模式1:全局数量匹配(大于等于)
* {
* "deviceCount": 4
* }
* // 匹配:用户有4个或更多机位时匹配成功
*
* // 模式2:指定列表数量匹配(大于等于)
* {
* "deviceCount": 2,
* "deviceIds": [200, 300, 400]
* }
* // 匹配:从列表中过滤出≥2个机位时匹配成功,只取前2个供数据源使用
* </pre>
*
* <p>模式2匹配逻辑:</p>
* <ul>
* <li>从 deviceIds 列表中过滤出实际存在的机位</li>
* <li>保持配置顺序(不按 deviceId 排序)</li>
* <li>判断过滤后的数量是否 ≥ deviceCount</li>
* <li>匹配成功后,只取前 deviceCount 个机位存入 context.extra,供数据源解析使用</li>
* </ul>
*
* @author Claude
* @since 2025-01-20
*/
@Slf4j
@Component
public class DeviceCountConditionStrategy implements ConditionStrategy {
@Override
public boolean evaluate(JsonNode conditionValue, ConditionContext context) {
if (conditionValue == null || !conditionValue.has("deviceCount")) {
log.warn("DEVICE_COUNT条件缺少deviceCount字段");
return false;
}
int expectedCount = conditionValue.get("deviceCount").asInt();
if (expectedCount <= 0) {
log.warn("deviceCount必须大于0, 当前值: {}", expectedCount);
return false;
}
// 检查是否指定了 deviceIds(模式2)
if (conditionValue.has("deviceIds")) {
return evaluateWithDeviceIdList(conditionValue, context, expectedCount);
} else {
// 模式1:全局数量匹配
return evaluateGlobalCount(context, expectedCount);
}
}
/**
* 模式1:全局数量匹配(大于等于)
*/
private boolean evaluateGlobalCount(ConditionContext context, int expectedCount) {
Integer actualCount = context.getDeviceCount();
if (actualCount == null) {
log.debug("上下文中没有机位数量信息");
return false;
}
boolean matched = actualCount >= expectedCount;
if (matched) {
log.info("DEVICE_COUNT全局匹配成功: 最小数量={}, 实际数量={}", expectedCount, actualCount);
} else {
log.debug("DEVICE_COUNT全局匹配失败: 最小数量={}, 实际数量={}", expectedCount, actualCount);
}
return matched;
}
/**
* 模式2:指定列表数量匹配(大于等于,只取前N个)
*/
private boolean evaluateWithDeviceIdList(JsonNode conditionValue, ConditionContext context, int expectedCount) {
// 1. 读取配置的 deviceIds 列表
JsonNode deviceIdsNode = conditionValue.get("deviceIds");
if (!deviceIdsNode.isArray()) {
log.warn("deviceIds字段必须是数组");
return false;
}
List<Long> requiredDeviceIds = new ArrayList<>();
deviceIdsNode.forEach(node -> requiredDeviceIds.add(node.asLong()));
if (requiredDeviceIds.isEmpty()) {
log.warn("deviceIds数组为空");
return false;
}
// 2. 获取上下文中的机位列表
List<Long> contextDeviceIds = context.getDeviceIds();
if (contextDeviceIds == null || contextDeviceIds.isEmpty()) {
log.debug("上下文中没有机位ID列表");
return false;
}
// 3. 按配置顺序过滤出实际存在的机位
List<Long> matchedDeviceIds = requiredDeviceIds.stream()
.filter(contextDeviceIds::contains)
.collect(Collectors.toList()); // 保持配置顺序,不排序
// 4. 判断是否满足最小数量要求(大于等于)
boolean matched = matchedDeviceIds.size() >= expectedCount;
if (matched) {
// 5. 只取前 expectedCount 个机位存入 context.extra
List<Long> limitedDeviceIds = matchedDeviceIds.stream()
.limit(expectedCount)
.collect(Collectors.toList());
Map<String, Object> extra = new HashMap<>();
extra.put("filteredDeviceIds", limitedDeviceIds);
context.setExtra(extra);
log.info("DEVICE_COUNT列表匹配成功: 配置列表={}, 过滤后={}, 最小数量={}, 实际数量={}, 取前{}个={}",
requiredDeviceIds, matchedDeviceIds, expectedCount, matchedDeviceIds.size(), expectedCount, limitedDeviceIds);
} else {
log.debug("DEVICE_COUNT列表匹配失败: 配置列表={}, 过滤后={}, 最小数量={}, 实际数量={}",
requiredDeviceIds, matchedDeviceIds, expectedCount, matchedDeviceIds.size());
}
return matched;
}
@Override
public String getSupportedType() {
return ConditionType.DEVICE_COUNT.getCode();
}
}

View File

@@ -0,0 +1,88 @@
package com.ycwl.basic.puzzle.fill.condition;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 机位ID匹配条件策略
* 判断指定的机位ID是否在当前上下文的机位列表中
*
* 支持两种匹配模式:
* 1. 单个机位匹配: {"deviceId": 123}
* 2. 多个机位匹配: {"deviceIds": [123, 456], "matchMode": "ALL"/"ANY"}
* - ALL: 所有指定的机位都必须存在
* - ANY: 至少存在一个指定的机位(默认)
*/
@Slf4j
@Component
public class DeviceIdMatchConditionStrategy implements ConditionStrategy {
@Override
public boolean evaluate(JsonNode conditionValue, ConditionContext context) {
if (conditionValue == null) {
log.warn("DEVICE_ID_MATCH条件值为null");
return false;
}
List<Long> contextDeviceIds = context.getDeviceIds();
if (contextDeviceIds == null || contextDeviceIds.isEmpty()) {
log.debug("上下文中没有机位ID列表");
return false;
}
// 单个机位匹配模式
if (conditionValue.has("deviceId")) {
Long requiredDeviceId = conditionValue.get("deviceId").asLong();
boolean matched = contextDeviceIds.contains(requiredDeviceId);
log.debug("单机位匹配: deviceId={}, matched={}", requiredDeviceId, matched);
return matched;
}
// 多个机位匹配模式
if (conditionValue.has("deviceIds")) {
JsonNode deviceIdsNode = conditionValue.get("deviceIds");
if (!deviceIdsNode.isArray()) {
log.warn("deviceIds字段必须是数组");
return false;
}
List<Long> requiredDeviceIds = new ArrayList<>();
deviceIdsNode.forEach(node -> requiredDeviceIds.add(node.asLong()));
if (requiredDeviceIds.isEmpty()) {
log.warn("deviceIds数组为空");
return false;
}
// 获取匹配模式,默认为ANY
String matchMode = conditionValue.has("matchMode")
? conditionValue.get("matchMode").asText()
: "ANY";
boolean matched;
if ("ALL".equalsIgnoreCase(matchMode)) {
// ALL模式: 所有指定的机位都必须存在
matched = contextDeviceIds.containsAll(requiredDeviceIds);
log.debug("多机位ALL匹配: requiredDeviceIds={}, matched={}", requiredDeviceIds, matched);
} else {
// ANY模式: 至少存在一个指定的机位
matched = requiredDeviceIds.stream().anyMatch(contextDeviceIds::contains);
log.debug("多机位ANY匹配: requiredDeviceIds={}, matched={}", requiredDeviceIds, matched);
}
return matched;
}
log.warn("DEVICE_ID_MATCH条件缺少deviceId或deviceIds字段");
return false;
}
@Override
public String getSupportedType() {
return "DEVICE_ID_MATCH";
}
}

View File

@@ -0,0 +1,29 @@
package com.ycwl.basic.puzzle.fill.datasource;
import lombok.Builder;
import lombok.Data;
import java.util.Map;
/**
* 数据源解析上下文
*/
@Data
@Builder
public class DataSourceContext {
/**
* 人脸ID
*/
private Long faceId;
/**
* 景区ID
*/
private Long scenicId;
/**
* 可扩展的其他上下文数据
*/
private Map<String, Object> extra;
}

View File

@@ -0,0 +1,77 @@
package com.ycwl.basic.puzzle.fill.datasource;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 数据源解析器
* 使用策略模式,根据dataSource类型动态选择解析策略
*/
@Slf4j
@Component
public class DataSourceResolver {
private final Map<String, DataSourceStrategy> strategyMap;
private final ObjectMapper objectMapper;
@Autowired
public DataSourceResolver(List<DataSourceStrategy> strategies, ObjectMapper objectMapper) {
this.strategyMap = strategies.stream()
.collect(Collectors.toMap(
DataSourceStrategy::getSupportedType,
Function.identity()
));
this.objectMapper = objectMapper;
log.info("初始化数据源解析器,已注册{}个策略: {}", strategyMap.size(), strategyMap.keySet());
}
/**
* 解析数据源,返回填充值
*
* @param dataSource 数据源类型
* @param sourceFilterJson 过滤条件(JSON字符串)
* @param sortStrategy 排序策略
* @param fallbackValue 降级默认值
* @param context 解析上下文
* @return 填充值
*/
public String resolve(String dataSource,
String sourceFilterJson,
String sortStrategy,
String fallbackValue,
DataSourceContext context) {
DataSourceStrategy strategy = strategyMap.get(dataSource);
if (strategy == null) {
log.warn("未找到数据源类型[{}]对应的解析策略", dataSource);
return fallbackValue;
}
try {
JsonNode sourceFilter = null;
if (sourceFilterJson != null && !sourceFilterJson.isEmpty()) {
sourceFilter = objectMapper.readTree(sourceFilterJson);
}
String value = strategy.resolve(sourceFilter, sortStrategy, context);
if (value == null || value.isEmpty()) {
log.debug("数据源[{}]解析结果为空,使用降级值: {}", dataSource, fallbackValue);
return fallbackValue;
}
return value;
} catch (Exception e) {
log.error("数据源[{}]解析异常,使用降级值: {}", dataSource, fallbackValue, e);
return fallbackValue;
}
}
}

View File

@@ -0,0 +1,25 @@
package com.ycwl.basic.puzzle.fill.datasource;
import com.fasterxml.jackson.databind.JsonNode;
/**
* 数据源解析策略接口
* 使用策略模式,每种数据源类型实现独立的解析逻辑
*/
public interface DataSourceStrategy {
/**
* 解析数据源,返回填充值
*
* @param sourceFilter 数据源过滤条件(JSON)
* @param sortStrategy 排序策略
* @param context 解析上下文
* @return 填充值(通常是URL)
*/
String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context);
/**
* 获取支持的数据源类型
*/
String getSupportedType();
}

View File

@@ -0,0 +1,104 @@
package com.ycwl.basic.puzzle.fill.datasource;
import com.fasterxml.jackson.databind.JsonNode;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.puzzle.fill.enums.DataSourceType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* 设备图片数据源策略
* 根据deviceIndex指定第N个设备的图片
*/
@Slf4j
@Component
public class DeviceImageDataSourceStrategy implements DataSourceStrategy {
@Autowired
private SourceMapper sourceMapper;
@Override
public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) {
try {
// 默认type=2(图片)
Integer type = 2;
if (sourceFilter != null && sourceFilter.has("type")) {
type = sourceFilter.get("type").asInt();
}
// 获取deviceIndex
Integer deviceIndex = 0;
if (sourceFilter != null && sourceFilter.has("deviceIndex")) {
deviceIndex = sourceFilter.get("deviceIndex").asInt();
}
// 使用默认策略
if (sortStrategy == null || sortStrategy.isEmpty()) {
sortStrategy = "LATEST";
}
// 1. 检查是否有过滤后的机位列表
Map<String, Object> extra = context.getExtra();
if (extra != null && extra.containsKey("filteredDeviceIds")) {
@SuppressWarnings("unchecked")
List<Long> filteredDeviceIds = (List<Long>) extra.get("filteredDeviceIds");
if (filteredDeviceIds != null && !filteredDeviceIds.isEmpty()) {
// 使用过滤后的机位列表
if (deviceIndex >= filteredDeviceIds.size()) {
log.warn("deviceIndex[{}]超出过滤后的机位列表范围, 最大索引={}",
deviceIndex, filteredDeviceIds.size() - 1);
return null;
}
Long targetDeviceId = filteredDeviceIds.get(deviceIndex);
log.debug("使用过滤后的机位列表, deviceIndex={}, targetDeviceId={}",
deviceIndex, targetDeviceId);
SourceEntity source = sourceMapper.getSourceByFaceAndDeviceId(
context.getFaceId(),
targetDeviceId,
type,
sortStrategy
);
if (source != null) {
String url = type == 1 ? source.getVideoUrl() : source.getUrl();
log.debug("解析DEVICE_IMAGE成功(过滤模式), faceId={}, deviceId={}, type={}, url={}",
context.getFaceId(), targetDeviceId, type, url);
return url;
}
return null;
}
}
// 2. 降级到原有逻辑(使用deviceIndex直接查询)
SourceEntity source = sourceMapper.getSourceByFaceAndDeviceIndex(
context.getFaceId(),
deviceIndex,
type,
sortStrategy
);
if (source != null) {
String url = type == 1 ? source.getVideoUrl() : source.getUrl();
log.debug("解析DEVICE_IMAGE成功(索引模式), faceId={}, deviceIndex={}, type={}, url={}",
context.getFaceId(), deviceIndex, type, url);
return url;
}
} catch (Exception e) {
log.error("解析DEVICE_IMAGE异常, faceId={}", context.getFaceId(), e);
}
return null;
}
@Override
public String getSupportedType() {
return DataSourceType.DEVICE_IMAGE.getCode();
}
}

View File

@@ -0,0 +1,39 @@
package com.ycwl.basic.puzzle.fill.datasource;
import com.fasterxml.jackson.databind.JsonNode;
import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.puzzle.fill.enums.DataSourceType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 人脸URL数据源策略
*/
@Slf4j
@Component
public class FaceUrlDataSourceStrategy implements DataSourceStrategy {
@Autowired
private FaceMapper faceMapper;
@Override
public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) {
try {
FaceEntity face = faceMapper.get(context.getFaceId());
if (face != null && face.getFaceUrl() != null) {
log.debug("解析FACE_URL成功, faceId={}, url={}", context.getFaceId(), face.getFaceUrl());
return face.getFaceUrl();
}
} catch (Exception e) {
log.error("解析FACE_URL异常, faceId={}", context.getFaceId(), e);
}
return null;
}
@Override
public String getSupportedType() {
return DataSourceType.FACE_URL.getCode();
}
}

View File

@@ -0,0 +1,77 @@
package com.ycwl.basic.puzzle.fill.datasource;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.ycwl.basic.puzzle.fill.enums.DataSourceType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 静态值数据源策略
* 支持直接返回配置值或指向本地文件的路径
*/
@Slf4j
@Component
public class StaticValueDataSourceStrategy implements DataSourceStrategy {
@Override
public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) {
if (sourceFilter == null) {
return null;
}
if (sourceFilter.hasNonNull("localPath")) {
String localPath = sourceFilter.get("localPath").asText();
return resolveLocalPath(localPath);
}
if (sourceFilter.hasNonNull("value")) {
String value = sourceFilter.get("value").asText();
log.debug("解析STATIC_VALUE成功, value={}", value);
return value;
}
return null;
}
private String resolveLocalPath(String rawPath) {
if (StrUtil.isBlank(rawPath)) {
log.warn("localPath为空, 无法解析静态值数据源");
return null;
}
try {
Path path;
if (StrUtil.startWithIgnoreCase(rawPath, "file:")) {
path = Paths.get(new URI(rawPath));
} else {
path = Paths.get(rawPath);
}
if (!path.isAbsolute()) {
path = path.toAbsolutePath();
}
if (!Files.exists(path) || !Files.isRegularFile(path)) {
log.warn("localPath不存在或不是文件: {}", path);
return null;
}
log.debug("解析STATIC_VALUE本地路径成功: {}", path);
return path.toString();
} catch (Exception e) {
log.error("解析本地路径失败: {}", rawPath, e);
return null;
}
}
@Override
public String getSupportedType() {
return DataSourceType.STATIC_VALUE.getCode();
}
}

View File

@@ -0,0 +1,109 @@
package com.ycwl.basic.puzzle.fill.datasource;
import com.fasterxml.jackson.databind.JsonNode;
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.puzzle.fill.enums.DataSourceType;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.utils.WxMpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.FileInputStream;
import java.nio.file.Files;
import java.util.UUID;
/**
* 微信小程序二维码数据源策略
* 用于生成微信小程序二维码并上传至OSS
*/
@Slf4j
@Component
public class WechatQrcodeDataSourceStrategy implements DataSourceStrategy {
@Autowired
private ScenicRepository scenicRepository;
@Override
public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) {
if (context.getFaceId() == null) {
log.warn("生成微信小程序二维码失败: faceId为空");
return null;
}
if (context.getScenicId() == null) {
log.warn("生成微信小程序二维码失败: scenicId为空");
return null;
}
try {
// 获取景区的小程序配置
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(context.getScenicId());
if (scenicMpConfig == null) {
log.error("生成微信小程序二维码失败: 未找到景区[{}]的小程序配置", context.getScenicId());
return null;
}
// 从sourceFilter中获取page路径,默认使用 pages/videoSynthesis/from_face
String page = "pages/videoSynthesis/from_face";
if (sourceFilter != null && sourceFilter.has("page")) {
page = sourceFilter.get("page").asText();
}
// 生成临时文件
File qrcode = new File("qrcode_" + context.getFaceId() + "_" + UUID.randomUUID().toString().substring(0, 8) + ".jpg");
try {
// 调用微信API生成小程序码
WxMpUtil.generateUnlimitedWXAQRCode(
scenicMpConfig.getAppId(),
scenicMpConfig.getAppSecret(),
page,
context.getFaceId().toString(),
qrcode
);
// 上传到OSS
try (FileInputStream fis = new FileInputStream(qrcode)) {
String fileName = String.format("qrcode_%d_%s.jpg",
context.getFaceId(),
UUID.randomUUID().toString().replace("-", "").substring(0, 16));
String qrcodeUrl = StorageFactory.use().uploadFile(
"image/jpeg",
fis,
"puzzle",
"wechat_qrcode",
fileName
);
log.info("生成微信小程序二维码成功: faceId={}, page={}, url={}",
context.getFaceId(), page, qrcodeUrl);
return qrcodeUrl;
}
} finally {
// 清理临时文件
if (qrcode.exists()) {
try {
Files.delete(qrcode.toPath());
} catch (Exception e) {
log.warn("删除临时二维码文件失败: {}", qrcode.getAbsolutePath(), e);
}
}
}
} catch (Exception e) {
log.error("生成微信小程序二维码异常: faceId={}, scenicId={}",
context.getFaceId(), context.getScenicId(), e);
return null;
}
}
@Override
public String getSupportedType() {
return DataSourceType.WECHAT_QRCODE.getCode();
}
}

View File

@@ -0,0 +1,45 @@
package com.ycwl.basic.puzzle.fill.enums;
import lombok.Getter;
/**
* 填充规则条件类型枚举
*/
@Getter
public enum ConditionType {
/**
* 机位数量匹配
*/
DEVICE_COUNT("DEVICE_COUNT", "机位数量匹配"),
/**
* 机位ID精确匹配
*/
DEVICE_ID_MATCH("DEVICE_ID_MATCH", "机位ID精确匹配"),
/**
* 总是匹配(兜底规则)
*/
ALWAYS("ALWAYS", "总是匹配");
private final String code;
private final String description;
ConditionType(String code, String description) {
this.code = code;
this.description = description;
}
/**
* 根据code获取枚举
*/
public static ConditionType fromCode(String code) {
for (ConditionType type : values()) {
if (type.code.equals(code)) {
return type;
}
}
throw new IllegalArgumentException("未知的条件类型: " + code);
}
}

View File

@@ -0,0 +1,60 @@
package com.ycwl.basic.puzzle.fill.enums;
import lombok.Getter;
/**
* 数据源类型枚举
*/
@Getter
public enum DataSourceType {
/**
* 人脸URL(来自face表的face_url)
*/
FACE_URL("FACE_URL", "人脸URL"),
/**
* 素材图片(来自source表,type=2)
*/
SOURCE_IMAGE("SOURCE_IMAGE", "素材图片"),
/**
* 素材视频(来自source表,type=1)
*/
SOURCE_VIDEO("SOURCE_VIDEO", "素材视频"),
/**
* 设备图片(根据deviceIndex指定第N个设备的图片)
*/
DEVICE_IMAGE("DEVICE_IMAGE", "设备图片"),
/**
* 静态值(直接使用fallbackValue)
*/
STATIC_VALUE("STATIC_VALUE", "静态值"),
/**
* 微信小程序二维码(生成小程序二维码)
*/
WECHAT_QRCODE("WECHAT_QRCODE", "微信小程序二维码");
private final String code;
private final String description;
DataSourceType(String code, String description) {
this.code = code;
this.description = description;
}
/**
* 根据code获取枚举
*/
public static DataSourceType fromCode(String code) {
for (DataSourceType type : values()) {
if (type.code.equals(code)) {
return type;
}
}
throw new IllegalArgumentException("未知的数据源类型: " + code);
}
}

View File

@@ -0,0 +1,50 @@
package com.ycwl.basic.puzzle.fill.enums;
import lombok.Getter;
/**
* 素材排序策略枚举
*/
@Getter
public enum SortStrategy {
/**
* 最新创建(按create_time DESC)
*/
LATEST("LATEST", "最新创建"),
/**
* 最早创建(按create_time ASC)
*/
EARLIEST("EARLIEST", "最早创建"),
/**
* 分数降序(按score DESC,需要source表有score字段)
*/
SCORE_DESC("SCORE_DESC", "分数降序"),
/**
* 优先已购买(按is_buy DESC, create_time DESC)
*/
PURCHASED_FIRST("PURCHASED_FIRST", "优先已购买");
private final String code;
private final String description;
SortStrategy(String code, String description) {
this.code = code;
this.description = description;
}
/**
* 根据code获取枚举
*/
public static SortStrategy fromCode(String code) {
for (SortStrategy strategy : values()) {
if (strategy.code.equals(code)) {
return strategy;
}
}
return LATEST; // 默认返回最新
}
}

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.puzzle.mapper;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -11,6 +12,7 @@ import java.util.List;
* @author Claude
* @since 2025-01-17
*/
@Mapper
public interface PuzzleElementMapper {
/**
@@ -23,6 +25,12 @@ public interface PuzzleElementMapper {
*/
List<PuzzleElementEntity> getByTemplateId(@Param("templateId") Long templateId);
/**
* 根据模板ID和元素Key查询元素
*/
PuzzleElementEntity getByTemplateIdAndKey(@Param("templateId") Long templateId,
@Param("elementKey") String elementKey);
/**
* 插入元素
*/

View File

@@ -0,0 +1,39 @@
package com.ycwl.basic.puzzle.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 拼图自动填充规则明细 Mapper
*/
@Mapper
public interface PuzzleFillRuleItemMapper extends BaseMapper<PuzzleFillRuleItemEntity> {
/**
* 根据规则ID查询所有明细(按item_order升序)
*
* @param ruleId 规则ID
* @return 明细列表
*/
List<PuzzleFillRuleItemEntity> listByRuleId(@Param("ruleId") Long ruleId);
/**
* 批量插入明细
*
* @param items 明细列表
* @return 插入数量
*/
int batchInsert(@Param("items") List<PuzzleFillRuleItemEntity> items);
/**
* 根据规则ID删除所有明细
*
* @param ruleId 规则ID
* @return 删除数量
*/
int deleteByRuleId(@Param("ruleId") Long ruleId);
}

View File

@@ -0,0 +1,23 @@
package com.ycwl.basic.puzzle.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 拼图自动填充规则 Mapper
*/
@Mapper
public interface PuzzleFillRuleMapper extends BaseMapper<PuzzleFillRuleEntity> {
/**
* 根据模板ID查询所有启用的规则(按优先级降序)
*
* @param templateId 模板ID
* @return 规则列表
*/
List<PuzzleFillRuleEntity> listByTemplateId(@Param("templateId") Long templateId);
}

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.puzzle.mapper;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -11,6 +12,7 @@ import java.util.List;
* @author Claude
* @since 2025-01-17
*/
@Mapper
public interface PuzzleGenerationRecordMapper {
/**
@@ -25,9 +27,15 @@ public interface PuzzleGenerationRecordMapper {
@Param("limit") Integer limit);
/**
* 查询订单的生成记录列表
* 查询人脸ID的生成记录列表
*/
List<PuzzleGenerationRecordEntity> listByOrderId(@Param("orderId") String orderId);
List<PuzzleGenerationRecordEntity> listByFaceId(@Param("faceId") Long faceId);
/**
* 统计人脸ID的生成记录数量
*/
int countByFaceId(@Param("faceId") Long faceId);
/**
* 插入记录
@@ -54,4 +62,18 @@ public interface PuzzleGenerationRecordMapper {
*/
int updateFail(@Param("id") Long id,
@Param("errorMessage") String errorMessage);
/**
* 根据内容哈希查询历史记录(用于去重)
* 查询条件: template_id + content_hash + scenic_id + status=1
* 返回最新的成功记录
*
* @param templateId 模板ID
* @param contentHash 内容哈希
* @param scenicId 景区ID
* @return 历史记录,如果不存在返回null
*/
PuzzleGenerationRecordEntity findByContentHash(@Param("templateId") Long templateId,
@Param("contentHash") String contentHash,
@Param("scenicId") Long scenicId);
}

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.puzzle.mapper;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -11,6 +12,7 @@ import java.util.List;
* @author Claude
* @since 2025-01-17
*/
@Mapper
public interface PuzzleTemplateMapper {
/**

View File

@@ -0,0 +1,61 @@
package com.ycwl.basic.puzzle.service;
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleDTO;
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleSaveRequest;
import java.util.List;
/**
* 拼图填充规则服务接口
*/
public interface IPuzzleFillRuleService {
/**
* 创建规则(主+明细)
*
* @param request 保存请求
* @return 规则ID
*/
Long create(PuzzleFillRuleSaveRequest request);
/**
* 更新规则(主+明细)
*
* @param request 保存请求
* @return 是否成功
*/
Boolean update(PuzzleFillRuleSaveRequest request);
/**
* 删除规则(级联删除明细)
*
* @param id 规则ID
* @return 是否成功
*/
Boolean delete(Long id);
/**
* 查询单条规则(含明细)
*
* @param id 规则ID
* @return 规则DTO
*/
PuzzleFillRuleDTO getById(Long id);
/**
* 查询模板的所有规则(含明细)
*
* @param templateId 模板ID
* @return 规则列表
*/
List<PuzzleFillRuleDTO> listByTemplateId(Long templateId);
/**
* 启用/禁用规则
*
* @param id 规则ID
* @param enabled 是否启用
* @return 是否成功
*/
Boolean toggleEnabled(Long id, Integer enabled);
}

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.puzzle.service;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
@@ -45,6 +46,19 @@ public interface IPuzzleTemplateService {
*/
List<PuzzleTemplateDTO> listTemplates(Long scenicId, String category, Integer status);
/**
* 分页获取模板列表
*
* @param page 页码(从1开始)
* @param pageSize 每页大小
* @param scenicId 景区ID(可选)
* @param category 模板分类(可选)
* @param status 状态(可选): 0-禁用 1-启用
* @return 分页结果
*/
PageResponse<PuzzleTemplateDTO> pageTemplates(Integer page, Integer pageSize,
Long scenicId, String category, Integer status);
/**
* 为模板添加元素
*/
@@ -55,6 +69,14 @@ public interface IPuzzleTemplateService {
*/
void batchAddElements(Long templateId, List<ElementCreateRequest> elements);
/**
* 批量替换元素(删除旧元素,添加新元素)
*
* @param templateId 模板ID
* @param elements 新的元素列表
*/
void replaceElements(Long templateId, List<ElementCreateRequest> elements);
/**
* 更新元素
*/

View File

@@ -0,0 +1,174 @@
package com.ycwl.basic.puzzle.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleDTO;
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleItemDTO;
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleSaveRequest;
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity;
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleItemMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleMapper;
import com.ycwl.basic.puzzle.service.IPuzzleFillRuleService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 拼图填充规则服务实现
*/
@Slf4j
@Service
public class PuzzleFillRuleServiceImpl implements IPuzzleFillRuleService {
@Autowired
private PuzzleFillRuleMapper ruleMapper;
@Autowired
private PuzzleFillRuleItemMapper itemMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(PuzzleFillRuleSaveRequest request) {
// 1. 保存主规则
PuzzleFillRuleEntity ruleEntity = new PuzzleFillRuleEntity();
BeanUtils.copyProperties(request, ruleEntity);
ruleMapper.insert(ruleEntity);
Long ruleId = ruleEntity.getId();
log.info("创建填充规则成功, ruleId={}, ruleName={}", ruleId, request.getRuleName());
// 2. 批量保存明细
if (request.getItems() != null && !request.getItems().isEmpty()) {
List<PuzzleFillRuleItemEntity> itemEntities = request.getItems().stream()
.map(dto -> {
PuzzleFillRuleItemEntity entity = new PuzzleFillRuleItemEntity();
BeanUtils.copyProperties(dto, entity);
entity.setRuleId(ruleId);
return entity;
})
.collect(Collectors.toList());
itemMapper.batchInsert(itemEntities);
log.info("批量保存规则明细成功, count={}", itemEntities.size());
}
return ruleId;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean update(PuzzleFillRuleSaveRequest request) {
if (request.getId() == null) {
throw new IllegalArgumentException("更新规则时ID不能为空");
}
// 1. 更新主规则
PuzzleFillRuleEntity ruleEntity = new PuzzleFillRuleEntity();
BeanUtils.copyProperties(request, ruleEntity);
ruleMapper.updateById(ruleEntity);
// 2. 删除旧明细
itemMapper.deleteByRuleId(request.getId());
// 3. 批量插入新明细
if (request.getItems() != null && !request.getItems().isEmpty()) {
List<PuzzleFillRuleItemEntity> itemEntities = request.getItems().stream()
.map(dto -> {
PuzzleFillRuleItemEntity entity = new PuzzleFillRuleItemEntity();
BeanUtils.copyProperties(dto, entity);
entity.setRuleId(request.getId());
return entity;
})
.collect(Collectors.toList());
itemMapper.batchInsert(itemEntities);
}
log.info("更新填充规则成功, ruleId={}, itemCount={}", request.getId(),
request.getItems() != null ? request.getItems().size() : 0);
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean delete(Long id) {
// MyBatis-Plus会级联删除明细(通过外键ON DELETE CASCADE)
ruleMapper.deleteById(id);
log.info("删除填充规则成功, ruleId={}", id);
return true;
}
@Override
public PuzzleFillRuleDTO getById(Long id) {
PuzzleFillRuleEntity ruleEntity = ruleMapper.selectById(id);
if (ruleEntity == null) {
return null;
}
PuzzleFillRuleDTO dto = new PuzzleFillRuleDTO();
BeanUtils.copyProperties(ruleEntity, dto);
// 查询明细
List<PuzzleFillRuleItemEntity> itemEntities = itemMapper.listByRuleId(id);
if (itemEntities != null && !itemEntities.isEmpty()) {
List<PuzzleFillRuleItemDTO> itemDTOs = itemEntities.stream()
.map(entity -> {
PuzzleFillRuleItemDTO itemDTO = new PuzzleFillRuleItemDTO();
BeanUtils.copyProperties(entity, itemDTO);
return itemDTO;
})
.collect(Collectors.toList());
dto.setItems(itemDTOs);
}
return dto;
}
@Override
public List<PuzzleFillRuleDTO> listByTemplateId(Long templateId) {
List<PuzzleFillRuleEntity> ruleEntities = ruleMapper.listByTemplateId(templateId);
if (ruleEntities == null || ruleEntities.isEmpty()) {
return new ArrayList<>();
}
return ruleEntities.stream()
.map(ruleEntity -> {
PuzzleFillRuleDTO dto = new PuzzleFillRuleDTO();
BeanUtils.copyProperties(ruleEntity, dto);
// 查询明细
List<PuzzleFillRuleItemEntity> itemEntities = itemMapper.listByRuleId(ruleEntity.getId());
if (itemEntities != null && !itemEntities.isEmpty()) {
List<PuzzleFillRuleItemDTO> itemDTOs = itemEntities.stream()
.map(entity -> {
PuzzleFillRuleItemDTO itemDTO = new PuzzleFillRuleItemDTO();
BeanUtils.copyProperties(entity, itemDTO);
return itemDTO;
})
.collect(Collectors.toList());
dto.setItems(itemDTOs);
}
return dto;
})
.collect(Collectors.toList());
}
@Override
public Boolean toggleEnabled(Long id, Integer enabled) {
LambdaUpdateWrapper<PuzzleFillRuleEntity> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(PuzzleFillRuleEntity::getId, id)
.set(PuzzleFillRuleEntity::getEnabled, enabled);
int count = ruleMapper.update(null, updateWrapper);
log.info("切换规则启用状态, ruleId={}, enabled={}", id, enabled);
return count > 0;
}
}

View File

@@ -2,17 +2,23 @@ package com.ycwl.basic.puzzle.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.fill.FillResult;
import com.ycwl.basic.puzzle.fill.PuzzleElementFillEngine;
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.utils.WxMpUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -21,9 +27,14 @@ import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
@@ -41,12 +52,20 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
private final PuzzleElementMapper elementMapper;
private final PuzzleGenerationRecordMapper recordMapper;
private final PuzzleImageRenderer imageRenderer;
private final PuzzleElementFillEngine fillEngine;
private final ScenicRepository scenicRepository;
private final PuzzleDuplicationDetector duplicationDetector;
@Override
public PuzzleGenerateResponse generate(PuzzleGenerateRequest request) {
long startTime = System.currentTimeMillis();
log.info("开始生成拼图: templateCode={}, userId={}, orderId={}",
request.getTemplateCode(), request.getUserId(), request.getOrderId());
log.info("开始生成拼图: templateCode={}, userId={}, faceId={}",
request.getTemplateCode(), request.getUserId(), request.getFaceId());
// 业务层校验:faceId 必填
if (request.getFaceId() == null) {
throw new IllegalArgumentException("人脸ID不能为空");
}
// 1. 查询模板和元素
PuzzleTemplateEntity template = templateMapper.getByCode(request.getTemplateCode());
@@ -58,28 +77,65 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
}
// 2. 校验景区隔离
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
if (elements.isEmpty()) {
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
}
// 2. 按z-index排序元素
// 3. 按z-index排序元素
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
Comparator.nullsFirst(Comparator.naturalOrder())));
// 3. 创建生成记录
PuzzleGenerationRecordEntity record = createRecord(template, request);
// 4. 准备dynamicData(合并自动填充和手动数据)
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
// 5. 执行重复图片检测
// 如果所有IMAGE元素使用相同URL,抛出DuplicateImageException
duplicationDetector.detectDuplicateImages(finalDynamicData, elements);
// 6. 计算内容哈希
String contentHash = duplicationDetector.calculateContentHash(finalDynamicData);
// 7. 查询历史记录(去重核心逻辑)
PuzzleGenerationRecordEntity duplicateRecord = duplicationDetector.findDuplicateRecord(
template.getId(), contentHash, resolvedScenicId);
if (duplicateRecord != null) {
// 发现重复内容,直接返回历史记录
long duration = System.currentTimeMillis() - startTime;
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
// 直接返回历史图片URL(语义化生成成功)
return PuzzleGenerateResponse.success(
duplicateRecord.getResultImageUrl(),
duplicateRecord.getResultFileSize(),
duplicateRecord.getResultWidth(),
duplicateRecord.getResultHeight(),
(int) duration,
duplicateRecord.getId(),
true, // isDuplicate=true
duplicateRecord.getId() // originalRecordId(复用时指向自己)
);
}
// 8. 没有历史记录,创建新的生成记录
PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId);
record.setContentHash(contentHash);
recordMapper.insert(record);
try {
// 4. 渲染图片
BufferedImage resultImage = imageRenderer.render(template, elements, request.getDynamicData());
// 9. 渲染图片
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
// 5. 上传到OSS
// 10. 上传到OSS
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("图片上传成功: url={}", imageUrl);
// 6. 更新记录为成功
// 11. 更新记录为成功
long duration = (int) (System.currentTimeMillis() - startTime);
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
recordMapper.updateSuccess(
@@ -91,7 +147,7 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
(int) duration
);
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
log.info("拼图生成成功(新生成): recordId={}, imageUrl={}, duration={}ms",
record.getId(), imageUrl, duration);
return PuzzleGenerateResponse.success(
@@ -100,7 +156,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration,
record.getId()
record.getId(),
false, // isDuplicate=false
null // originalRecordId=null
);
} catch (Exception e) {
@@ -114,14 +172,16 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
/**
* 创建生成记录
*/
private PuzzleGenerationRecordEntity createRecord(PuzzleTemplateEntity template, PuzzleGenerateRequest request) {
private PuzzleGenerationRecordEntity createRecord(PuzzleTemplateEntity template,
PuzzleGenerateRequest request,
Long scenicId) {
PuzzleGenerationRecordEntity record = new PuzzleGenerationRecordEntity();
record.setTemplateId(template.getId());
record.setTemplateCode(template.getCode());
record.setUserId(request.getUserId());
record.setOrderId(request.getOrderId());
record.setFaceId(request.getFaceId());
record.setBusinessType(request.getBusinessType());
record.setScenicId(request.getScenicId());
record.setScenicId(scenicId);
record.setStatus(0); // 生成中
record.setRetryCount(0);
@@ -179,4 +239,165 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
return 0L;
}
}
/**
* 构建dynamicData(合并自动填充和手动数据)
* 优先级: 手动传入的数据 > 自动填充的数据
*/
private Map<String, String> buildDynamicData(PuzzleTemplateEntity template,
PuzzleGenerateRequest request,
Long scenicId,
List<PuzzleElementEntity> elements) {
Map<String, String> dynamicData = new HashMap<>();
// 0. 检查是否需要自动生成 travelResultWxaCode 二维码
if (request.getFaceId() != null && scenicId != null) {
boolean hasTravelResultWxaCode = elements.stream()
.anyMatch(e -> "travelResultWxaCode".equals(e.getElementKey()));
if (hasTravelResultWxaCode && !dynamicDataContainsKey(request.getDynamicData(), "travelResultWxaCode")) {
String qrcodeUrl = generateWechatQrcode(request.getFaceId(), scenicId);
if (qrcodeUrl != null) {
dynamicData.put("travelResultWxaCode", qrcodeUrl);
log.info("自动生成微信小程序二维码成功: faceId={}, url={}", request.getFaceId(), qrcodeUrl);
}
}
}
// 1. 自动填充(基于faceId和规则)
boolean ruleMatched = false;
if (request.getFaceId() != null) {
try {
FillResult fillResult = fillEngine.execute(
template.getId(),
request.getFaceId(),
scenicId
);
ruleMatched = fillResult.isRuleMatched();
if (fillResult.isRuleMatched()) {
log.info("自动填充成功: 匹配规则[{}], 填充了{}个元素",
fillResult.getMatchedRuleName(),
fillResult.getFilledCount());
dynamicData.putAll(fillResult.getDynamicData());
} else {
log.info("自动填充未匹配任何规则, templateId={}, faceId={}",
template.getId(), request.getFaceId());
}
} catch (Exception e) {
log.error("自动填充异常, templateId={}, faceId={}", template.getId(), request.getFaceId(), e);
// 自动填充失败不影响整体流程,继续执行
}
}
// 2. 检查是否必须匹配规则
Boolean requireRuleMatch = request.getRequireRuleMatch();
if (Boolean.TRUE.equals(requireRuleMatch) && !ruleMatched) {
throw new IllegalArgumentException(
String.format("未匹配到任何填充规则,无法生成图片 (templateCode=%s, faceId=%s, requireRuleMatch=true)",
request.getTemplateCode(), request.getFaceId())
);
}
// 3. 手动数据覆盖(优先级更高)
if (request.getDynamicData() != null && !request.getDynamicData().isEmpty()) {
dynamicData.putAll(request.getDynamicData());
log.debug("合并手动传入的dynamicData, count={}", request.getDynamicData().size());
}
log.info("最终dynamicData: {}", dynamicData.keySet());
return dynamicData;
}
/**
* 检查dynamicData中是否包含指定key
*/
private boolean dynamicDataContainsKey(Map<String, String> dynamicData, String key) {
return dynamicData != null && dynamicData.containsKey(key);
}
/**
* 生成微信小程序二维码
*
* @param faceId 人脸ID
* @param scenicId 景区ID
* @return 二维码URL,失败返回null
*/
private String generateWechatQrcode(Long faceId, Long scenicId) {
File qrcode = null;
try {
// 获取景区的小程序配置
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(scenicId);
if (scenicMpConfig == null) {
log.error("生成微信小程序二维码失败: 未找到景区[{}]的小程序配置", scenicId);
return null;
}
// 生成临时文件
qrcode = new File("qrcode_" + faceId + "_" + UUID.randomUUID().toString().substring(0, 8) + ".jpg");
// 调用微信API生成小程序码
WxMpUtil.generateUnlimitedWXAQRCode(
scenicMpConfig.getAppId(),
scenicMpConfig.getAppSecret(),
"pages/videoSynthesis/from_face",
faceId.toString(),
qrcode
);
// 上传到OSS
try (FileInputStream fis = new FileInputStream(qrcode)) {
String fileName = String.format("qrcode_%d.jpg", faceId);
boolean exists = StorageFactory.use().isExists("puzzle", "wechat_qrcode", fileName);
if (exists) {
log.debug("微信小程序二维码已存在, 不重复上传: faceId={}, url={}", faceId, StorageFactory.use().getUrl("puzzle", "wechat_qrcode", fileName));
return StorageFactory.use().getUrl("puzzle", "wechat_qrcode", fileName);
}
return StorageFactory.use().uploadFile(
"image/jpeg",
fis,
"puzzle",
"wechat_qrcode",
fileName
);
}
} catch (Exception e) {
log.error("生成微信小程序二维码失败: faceId={}, scenicId={}", faceId, scenicId, e);
return null;
} finally {
// 清理临时文件
if (qrcode != null && qrcode.exists()) {
try {
Files.delete(qrcode.toPath());
} catch (Exception e) {
log.warn("删除临时二维码文件失败: {}", qrcode.getAbsolutePath(), e);
}
}
}
}
/**
* 校验模板与请求景区的合法性
*
* @param template 模板
* @param requestedScenic 请求中的景区ID
* @return 最终生效的景区ID
*/
private Long resolveScenicId(PuzzleTemplateEntity template, Long requestedScenic) {
Long templateScenicId = template.getScenicId();
if (templateScenicId == null) {
return requestedScenic;
}
if (requestedScenic == null) {
throw new IllegalArgumentException("模板绑定景区, scenicId为必填项");
}
if (!templateScenicId.equals(requestedScenic)) {
throw new IllegalArgumentException("模板不属于当前景区, 请检查templateCode与scenicId");
}
return templateScenicId;
}
}

View File

@@ -3,6 +3,7 @@ import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.util.ElementConfigHelper;
import cn.hutool.core.bean.BeanUtil;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
@@ -19,6 +20,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -134,6 +137,44 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
.collect(Collectors.toList());
}
@Override
public PageResponse<PuzzleTemplateDTO> pageTemplates(Integer page, Integer pageSize,
Long scenicId, String category, Integer status) {
log.debug("分页查询拼图模板: page={}, pageSize={}, scenicId={}, category={}, status={}",
page, pageSize, scenicId, category, status);
// 参数校验
if (page == null || page < 1) {
page = 1;
}
if (pageSize == null || pageSize < 1) {
pageSize = 10;
}
if (pageSize > 100) {
pageSize = 100; // 限制最大页面大小
}
// 使用PageHelper进行分页
com.github.pagehelper.Page<PuzzleTemplateEntity> pageResult =
com.github.pagehelper.PageHelper.startPage(page, pageSize)
.doSelectPage(() -> templateMapper.list(scenicId, category, status));
// 转换为DTO
List<PuzzleTemplateDTO> dtoList = pageResult.getResult().stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
// 构建分页响应
PageResponse<PuzzleTemplateDTO> response = new PageResponse<>();
response.setList(dtoList);
response.setTotal((int) pageResult.getTotal());
response.setPage(page);
response.setPageSize(pageSize);
log.debug("分页查询完成: total={}, currentSize={}", pageResult.getTotal(), dtoList.size());
return response;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long addElement(ElementCreateRequest request) {
@@ -188,6 +229,72 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void replaceElements(Long templateId, List<ElementCreateRequest> elements) {
log.info("批量替换元素: templateId={}, count={}", templateId, elements.size());
// 1. 校验模板
PuzzleTemplateEntity template = templateMapper.getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + templateId);
}
// 2. 获取现有元素(建立 elementKey -> Entity 的映射)
List<PuzzleElementEntity> existingElements = elementMapper.getByTemplateId(templateId);
Map<String, PuzzleElementEntity> existingMap = existingElements.stream()
.collect(Collectors.toMap(
PuzzleElementEntity::getElementKey,
entity -> entity,
(e1, e2) -> e1 // 如果有重复key,保留第一个
));
// 3. 构建新元素的 key 集合
Set<String> newElementKeys = elements.stream()
.map(ElementCreateRequest::getElementKey)
.collect(Collectors.toSet());
// 4. 找出需要删除的元素(旧元素中不在新元素列表中的)
List<Long> toDeleteIds = existingElements.stream()
.filter(entity -> !newElementKeys.contains(entity.getElementKey()))
.map(PuzzleElementEntity::getId)
.collect(Collectors.toList());
// 5. 删除不需要的元素
int deletedCount = 0;
for (Long id : toDeleteIds) {
elementMapper.deleteById(id);
deletedCount++;
}
log.info("删除不需要的元素: templateId={}, count={}", templateId, deletedCount);
// 6. 处理新元素列表:更新或插入
int updatedCount = 0;
int insertedCount = 0;
for (ElementCreateRequest request : elements) {
request.setTemplateId(templateId);
ElementConfigHelper.validateRequest(request);
PuzzleElementEntity existingEntity = existingMap.get(request.getElementKey());
if (existingEntity != null) {
// 更新现有元素
PuzzleElementEntity entity = ElementConfigHelper.toEntity(request);
entity.setId(existingEntity.getId());
elementMapper.update(entity);
updatedCount++;
} else {
// 插入新元素
PuzzleElementEntity entity = ElementConfigHelper.toEntity(request);
entity.setDeleted(0);
elementMapper.insert(entity);
insertedCount++;
}
}
log.info("批量替换元素完成: templateId={}, deleted={}, updated={}, inserted={}",
templateId, deletedCount, updatedCount, insertedCount);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateElement(Long id, ElementCreateRequest request) {

View File

@@ -2,6 +2,7 @@ package com.ycwl.basic.puzzle.util;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.extern.slf4j.Slf4j;
@@ -108,13 +109,12 @@ public class ElementConfigHelper {
return false;
}
// 当前支持的类型
return "TEXT".equalsIgnoreCase(elementType) ||
"IMAGE".equalsIgnoreCase(elementType) ||
"QRCODE".equalsIgnoreCase(elementType) ||
"GRADIENT".equalsIgnoreCase(elementType) ||
"SHAPE".equalsIgnoreCase(elementType) ||
"DYNAMIC_IMAGE".equalsIgnoreCase(elementType);
try {
ElementType type = ElementType.fromCode(elementType);
return type.isImplemented();
} catch (IllegalArgumentException ex) {
return false;
}
}
/**

View File

@@ -0,0 +1,176 @@
package com.ycwl.basic.puzzle.util;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.exception.DuplicateImageException;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.stream.Collectors;
/**
* 拼图去重检测器
* 负责检测重复内容和重复图片,避免不必要的图片生成
*
* @author Claude
* @since 2025-01-21
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PuzzleDuplicationDetector {
private final Set<String> skippedElementKeys = Set.of("dateStr");
private final PuzzleGenerationRecordMapper recordMapper;
/**
* 计算内容哈希
* 基于所有元素的实际内容值(按elementKey排序后拼接)计算SHA256哈希
*
* @param finalData 最终的元素数据映射(elementKey -> value)
* @return SHA256哈希值(64位16进制字符串)
*/
public String calculateContentHash(Map<String, String> finalData) {
if (finalData == null || finalData.isEmpty()) {
log.warn("计算内容哈希时发现空数据,返回空哈希");
return "";
}
try {
// 1. 按key排序,确保相同内容生成相同哈希
List<String> sortedKeys = new ArrayList<>(finalData.keySet());
Collections.sort(sortedKeys);
// 2. 拼接为固定格式: "key1:value1|key2:value2|..."
StringBuilder sb = new StringBuilder();
for (int i = 0; i < sortedKeys.size(); i++) {
String key = sortedKeys.get(i);
if (skippedElementKeys.contains(key)) {
continue;
}
String value = finalData.get(key);
sb.append(key).append(":").append(value != null ? value : "");
if (i < sortedKeys.size() - 1) {
sb.append("|");
}
}
String content = sb.toString();
log.debug("内容哈希计算原文: {}", content);
// 3. 计算SHA256
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(content.getBytes(StandardCharsets.UTF_8));
// 4. 转换为16进制字符串
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
String hash = hexString.toString();
log.debug("计算得到内容哈希: {}", hash);
return hash;
} catch (NoSuchAlgorithmException e) {
log.error("SHA-256算法不可用", e);
throw new RuntimeException("内容哈希计算失败: SHA-256算法不可用", e);
}
}
/**
* 检测重复图片
* 检查所有IMAGE类型元素是否使用相同的图片URL
* 如果发现所有图片元素使用同一个URL,抛出异常
*
* @param finalData 最终的元素数据映射
* @param elements 元素列表
* @throws DuplicateImageException 所有图片元素使用相同URL时抛出
*/
public void detectDuplicateImages(Map<String, String> finalData, List<PuzzleElementEntity> elements) {
if (finalData == null || finalData.isEmpty() || elements == null || elements.isEmpty()) {
log.debug("跳过重复图片检测: 数据为空");
return;
}
// 1. 筛选出所有IMAGE类型的元素(elementType="IMAGE")
List<PuzzleElementEntity> imageElements = elements.stream()
.filter(e -> "IMAGE".equals(e.getElementType()))
.collect(Collectors.toList());
if (imageElements.size() < 2) {
log.debug("图片元素数量不足2个,跳过重复检测");
return;
}
// 2. 提取所有图片元素的实际URL值
List<String> imageUrls = new ArrayList<>();
for (PuzzleElementEntity element : imageElements) {
String url = finalData.get(element.getElementKey());
if (url != null && !url.trim().isEmpty()) {
imageUrls.add(url);
}
}
if (imageUrls.isEmpty()) {
log.debug("没有有效的图片URL,跳过重复检测");
return;
}
// 3. 对URL去重
Set<String> uniqueUrls = new HashSet<>(imageUrls);
// 4. 如果去重后只有1个URL,说明所有图片相同
if (uniqueUrls.size() == 1) {
String duplicateUrl = uniqueUrls.iterator().next();
log.warn("检测到重复图片: 所有{}个图片元素使用相同URL: {}", imageUrls.size(), duplicateUrl);
throw new DuplicateImageException(duplicateUrl, imageUrls.size());
}
log.debug("重复图片检测通过: 发现{}个不同的图片URL", uniqueUrls.size());
}
/**
* 查找重复记录
* 根据模板ID、内容哈希和景区ID查询历史记录
* 返回最新的成功生成记录(如果存在)
*
* @param templateId 模板ID
* @param contentHash 内容哈希
* @param scenicId 景区ID
* @return 历史记录,如果不存在则返回null
*/
public PuzzleGenerationRecordEntity findDuplicateRecord(Long templateId, String contentHash, Long scenicId) {
if (contentHash == null || contentHash.isEmpty()) {
log.debug("内容哈希为空,跳过去重查询");
return null;
}
try {
PuzzleGenerationRecordEntity record = recordMapper.findByContentHash(templateId, contentHash, scenicId);
if (record != null) {
log.info("发现重复内容: templateId={}, contentHash={}, 历史记录ID={}, imageUrl={}",
templateId, contentHash, record.getId(), record.getResultImageUrl());
} else {
log.debug("未发现重复内容: templateId={}, contentHash={}", templateId, contentHash);
}
return record;
} catch (Exception e) {
log.error("查询重复记录失败: templateId={}, contentHash={}", templateId, contentHash, e);
// 查询失败时返回null,继续正常生成流程
return null;
}
}
}

View File

@@ -30,7 +30,11 @@ public class FaceRepository {
public FaceEntity getFace(Long id) {
if (redisTemplate.hasKey(String.format(FACE_CACHE_KEY, id))) {
return JacksonUtil.parseObject(redisTemplate.opsForValue().get(String.format(FACE_CACHE_KEY, id)), FaceEntity.class);
String json = redisTemplate.opsForValue().get(String.format(FACE_CACHE_KEY, id));
if (json == null) {
return null;
}
return JacksonUtil.parseObject(json, FaceEntity.class);
}
FaceEntity face = faceMapper.get(id);
if (face != null) {

View File

@@ -86,22 +86,6 @@ public class OrderRepository {
return orderMapper.getUserBuyItem(userId, goodsType, goodsId);
}
public boolean checkUserBuyFaceSourceImage(Long userId, Long faceId) {
return checkUserBuyItem(userId, 2, faceId);
}
public boolean checkUserBuyFaceSourceVideo(Long userId, Long faceId) {
return checkUserBuyItem(userId, 1, faceId);
}
public boolean checkUserBuyVideo(Long userId, Long videoId) {
return checkUserBuyItem(userId, 0, videoId);
}
public boolean checkUserBuyTemplate(Long userId, Long templateId) {
return checkUserBuyItem(userId, -1, templateId);
}
public void clearUserBuyItemCache(Long userId, int goodsType, Long goodsId) {
redisTemplate.delete(String.format(ORDER_USER_TYPE_BUY_ITEM_CACHE_KEY, userId, goodsType, goodsId));
}

View File

@@ -62,38 +62,6 @@ public class SourceRepository {
memberRelationRepository.clearSCacheByFace(faceId);
}
public boolean getUserIsBuy(Long userId, int type, Long faceId) {
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.info("faceId:{} is not exist", faceId);
return false;
}
// 确认人员faceId是否有券码
List<VoucherInfo> voucherDetails = iVoucherService.getVoucherDetails(faceId, face.getScenicId());
if (voucherDetails != null && !voucherDetails.isEmpty()) {
VoucherInfo voucherInfo = voucherDetails.getFirst();
if (voucherInfo.getDiscountType().equals(VoucherDiscountType.FREE_ALL)) {
return true;
}
}
switch (type) {
case 1:
List<SourceEntity> videoSourceList = sourceMapper.listVideoByFaceRelation(faceId);
if (videoSourceList == null || videoSourceList.isEmpty()) {
return false;
}
return videoSourceList.stream().filter(Objects::nonNull).anyMatch(item -> Integer.valueOf(1).equals(item.getIsBuy()));
case 2:
List<SourceEntity> imageSourceList = sourceMapper.listImageByFaceRelation(faceId);
if (imageSourceList == null || imageSourceList.isEmpty()) {
return false;
}
return imageSourceList.stream().filter(Objects::nonNull).anyMatch(item -> Integer.valueOf(1).equals(item.getIsBuy()));
default:
return false;
}
}
public SourceEntity getSource(Long id) {
return sourceMapper.getEntity(id);
}

View File

@@ -107,35 +107,6 @@ public class VideoRepository {
}
}
public boolean getUserIsBuy(Long userId, Long videoId) {
MemberVideoEntity memberVideo = videoMapper.queryUserVideo(userId, videoId);
if (memberVideo == null) {
return false;
}
boolean isBuy = Integer.valueOf(1).equals(memberVideo.getIsBuy());
if (isBuy) {
return isBuy;
}
// 一口价
IsBuyBatchRespVO buy = priceBiz.isBuy(userId, memberVideo.getFaceId(), memberVideo.getScenicId(), -1, null);
if (buy == null) {
return false;
}
if (buy.isBuy()) {
return true;
}
// 确认人员faceId是否有券码
List<VoucherInfo> voucherDetails = iVoucherService.getVoucherDetails(memberVideo.getFaceId(), memberVideo.getScenicId());
if (voucherDetails != null && !voucherDetails.isEmpty()) {
VoucherInfo voucherInfo = voucherDetails.getFirst();
if (voucherInfo.getDiscountType().equals(VoucherDiscountType.FREE_ALL)) {
isBuy = true;
}
}
return isBuy;
}
public boolean clearVideoCache(Long videoId) {
if (redisTemplate.hasKey(String.format(VIDEO_CACHE_KEY, videoId))) {
VideoEntity video = getVideo(videoId);

View File

@@ -10,6 +10,7 @@ import com.ycwl.basic.exception.BizException;
import com.ycwl.basic.mapper.VideoMapper;
import com.ycwl.basic.mapper.VideoReviewMapper;
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO;
@@ -45,6 +46,9 @@ public class VideoReviewServiceImpl implements VideoReviewService {
@Autowired
private VideoMapper videoMapper;
@Autowired
private DeviceRepository deviceRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
@@ -153,11 +157,37 @@ public class VideoReviewServiceImpl implements VideoReviewService {
reqDTO.setPageSize(Integer.MAX_VALUE);
List<VideoReviewRespDTO> list = videoReviewMapper.selectReviewList(reqDTO);
// 2. 创建Excel工作簿
// 2. 收集所有机位ID并批量查询机位名称
Set<Long> allDeviceIds = new LinkedHashSet<>();
for (VideoReviewRespDTO review : list) {
Map<String, Map<String, Integer>> cameraRating = review.getCameraPositionRating();
if (cameraRating != null && !cameraRating.isEmpty()) {
// 收集机位ID (按顺序)
for (String deviceIdStr : cameraRating.keySet()) {
try {
allDeviceIds.add(Long.valueOf(deviceIdStr));
} catch (NumberFormatException e) {
log.warn("无效的机位ID: {}", deviceIdStr);
}
}
}
}
// 批量查询机位名称
Map<Long, String> deviceNames = new HashMap<>();
if (!allDeviceIds.isEmpty()) {
deviceNames = deviceRepository.batchGetDeviceNames(new ArrayList<>(allDeviceIds));
}
// 对机位ID按ID排序,保证表头顺序一致
List<Long> sortedDeviceIds = new ArrayList<>(allDeviceIds);
sortedDeviceIds.sort(Long::compareTo);
// 3. 创建Excel工作簿
Workbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet("视频评价数据");
// 3. 创建标题行样式
// 4. 创建标题行样式
CellStyle headerStyle = workbook.createCellStyle();
Font headerFont = workbook.createFont();
headerFont.setBold(true);
@@ -165,53 +195,110 @@ public class VideoReviewServiceImpl implements VideoReviewService {
headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
// 4. 创建标题行
// 5. 创建单元格自动换行样式
CellStyle wrapStyle = workbook.createCellStyle();
wrapStyle.setWrapText(true);
wrapStyle.setVerticalAlignment(VerticalAlignment.TOP);
// 6. 生成动态表头 - 使用机位名称作为表头
Row headerRow = sheet.createRow(0);
String[] headers = {"评价ID", "视频ID", "景区ID", "景区名称", "评价人ID", "评价人名称",
"评分", "文字评价", "机位评价", "创建时间", "更新时间"};
for (int i = 0; i < headers.length; i++) {
List<String> headerList = new ArrayList<>();
headerList.add("评价ID");
headerList.add("视频ID");
headerList.add("视频模板名称");
headerList.add("景区名称");
headerList.add("评价人名称");
headerList.add("评分");
headerList.add("文字评价");
// 添加机位列 - 表头直接使用机位名称
Map<Long, String> finalDeviceNames = deviceNames;
for (Long deviceId : sortedDeviceIds) {
String deviceName = finalDeviceNames.getOrDefault(deviceId, "未知设备(ID:" + deviceId + ")");
headerList.add(deviceName);
}
headerList.add("创建时间");
headerList.add("更新时间");
// 设置表头
for (int i = 0; i < headerList.size(); i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellValue(headerList.get(i));
cell.setCellStyle(headerStyle);
}
// 5. 填充数据
// 7. 填充数据
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
int rowNum = 1;
for (VideoReviewRespDTO review : list) {
Row row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue(review.getId());
row.createCell(1).setCellValue(review.getVideoId());
row.createCell(2).setCellValue(review.getScenicId());
row.createCell(3).setCellValue(review.getScenicName());
row.createCell(4).setCellValue(review.getCreator());
row.createCell(5).setCellValue(review.getCreatorName());
row.createCell(6).setCellValue(review.getRating());
row.createCell(7).setCellValue(review.getContent());
int colIndex = 0;
// 机位评价JSON转字符串
try {
String cameraRatingJson = review.getCameraPositionRating() != null ?
objectMapper.writeValueAsString(review.getCameraPositionRating()) : "";
row.createCell(8).setCellValue(cameraRatingJson);
} catch (Exception e) {
row.createCell(8).setCellValue("");
// 基础信息列
row.createCell(colIndex++).setCellValue(review.getId());
row.createCell(colIndex++).setCellValue(review.getVideoId());
row.createCell(colIndex++).setCellValue(review.getTemplateName() != null ? review.getTemplateName() : "");
row.createCell(colIndex++).setCellValue(review.getScenicName());
row.createCell(colIndex++).setCellValue(review.getCreatorName());
row.createCell(colIndex++).setCellValue(review.getRating());
row.createCell(colIndex++).setCellValue(review.getContent());
// 机位评价列 - 按表头顺序填充
Map<String, Map<String, Integer>> cameraRating = review.getCameraPositionRating();
for (Long deviceId : sortedDeviceIds) {
String deviceIdStr = String.valueOf(deviceId);
Map<String, Integer> dimensions = null;
if (cameraRating != null && cameraRating.containsKey(deviceIdStr)) {
dimensions = cameraRating.get(deviceIdStr);
}
// 构建单元格内容: 只显示评分维度(不再重复机位名称)
StringBuilder cellContent = new StringBuilder();
if (dimensions != null && !dimensions.isEmpty()) {
// 按维度名排序,保证一致性
List<Map.Entry<String, Integer>> sortedDimensions = dimensions.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.collect(Collectors.toList());
boolean first = true;
for (Map.Entry<String, Integer> dimEntry : sortedDimensions) {
if (!first) {
cellContent.append("\n");
}
cellContent.append(dimEntry.getKey()).append("").append(dimEntry.getValue());
first = false;
}
}
Cell cell = row.createCell(colIndex++);
cell.setCellValue(cellContent.toString());
cell.setCellStyle(wrapStyle);
}
row.createCell(9).setCellValue(review.getCreateTime() != null ? sdf.format(review.getCreateTime()) : "");
row.createCell(10).setCellValue(review.getUpdateTime() != null ? sdf.format(review.getUpdateTime()) : "");
// 时间列
row.createCell(colIndex++).setCellValue(review.getCreateTime() != null ? sdf.format(review.getCreateTime()) : "");
row.createCell(colIndex).setCellValue(review.getUpdateTime() != null ? sdf.format(review.getUpdateTime()) : "");
}
// 6. 自动调整列宽
for (int i = 0; i < headers.length; i++) {
// 8. 自动调整列宽
for (int i = 0; i < headerList.size(); i++) {
sheet.autoSizeColumn(i);
// 对于机位列,设置最小宽度以便换行内容显示完整
if (i >= 7 && i < 7 + sortedDeviceIds.size()) {
int currentWidth = sheet.getColumnWidth(i);
sheet.setColumnWidth(i, Math.max(currentWidth, 5000)); // 最小25个字符宽度
}
}
// 7. 写入输出流
// 9. 写入输出流
workbook.write(outputStream);
workbook.close();
log.info("导出视频评价数据成功,共{}条", list.size());
log.info("导出视频评价数据成功,共{}条,机位数:{}", list.size(), sortedDeviceIds.size());
}
/**

View File

@@ -11,13 +11,6 @@ import java.util.List;
*/
public interface GoodsService {
/**
* 查询商品列表
* @param query 查询条件
* @return
*/
ApiResponse<List<GoodsPageVO>> goodsList(GoodsReqQuery query);
/**
* 查询源素材商品列表
*
@@ -57,4 +50,6 @@ public interface GoodsService {
* @return 视频更新检查结果
*/
VideoUpdateCheckVO checkVideoUpdate(Long videoId);
ApiResponse<List<GoodsPageVO>> listGoodsByFaceIdList(List<Long> faceIds, Integer isBuy, Long scenicId);
}

View File

@@ -4,11 +4,13 @@ import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.video.dto.VideoViewPermissionDTO;
import com.ycwl.basic.model.pc.task.entity.TaskEntity;
import com.ycwl.basic.model.pc.video.entity.UserVideoViewEntity;
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.repository.UserVideoViewRepository;
import com.ycwl.basic.repository.VideoRepository;
import com.ycwl.basic.repository.VideoTaskRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -28,6 +30,7 @@ public class VideoViewPermissionService {
private final VideoRepository videoRepository;
private final ScenicRepository scenicRepository;
private final OrderBiz orderBiz;
private final VideoTaskRepository videoTaskRepository;
/**
* 检查并记录用户查看视频
@@ -50,9 +53,10 @@ public class VideoViewPermissionService {
log.warn("视频缺少景区信息: videoId={}", videoId);
return createErrorPermission("视频信息不完整");
}
TaskEntity taskById = videoTaskRepository.getTaskById(video.getTaskId());
// 检查用户是否已购买
IsBuyRespVO buy = orderBiz.isBuy(userId, scenicId, 0, videoId);
IsBuyRespVO buy = orderBiz.isBuy(scenicId, userId, taskById.getFaceId(), 0, videoId);
if (buy != null && (buy.isBuy() || buy.isFree())) {
// 已购买,不限制查看
log.debug("用户已购买视频,无查看限制: userId={}, videoId={}", userId, videoId);
@@ -119,9 +123,10 @@ public class VideoViewPermissionService {
if (scenicId == null) {
return createErrorPermission("视频信息不完整");
}
TaskEntity taskById = videoTaskRepository.getTaskById(video.getTaskId());
// 检查用户是否已购买
IsBuyRespVO buy = orderBiz.isBuy(userId, scenicId, 0, videoId);
IsBuyRespVO buy = orderBiz.isBuy(scenicId, userId, taskById.getFaceId(), 0, videoId);
if (buy != null && (buy.isBuy() || buy.isFree())) {
// 已购买,不限制查看
log.debug("用户已购买视频,无查看限制: userId={}, videoId={}", userId, videoId);

View File

@@ -10,7 +10,6 @@ import com.ycwl.basic.utils.JacksonUtil;
import com.ycwl.basic.biz.CouponBiz;
import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.biz.TaskStatusBiz;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.constant.StorageConstant;
import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
@@ -44,8 +43,6 @@ import com.ycwl.basic.repository.VideoRepository;
import com.ycwl.basic.repository.VideoTaskRepository;
import com.ycwl.basic.service.mobile.GoodsService;
import com.ycwl.basic.repository.TemplateRepository;
import com.ycwl.basic.repository.SourceRepository;
import com.ycwl.basic.biz.TemplateBiz;
import com.ycwl.basic.config.VideoUpdateConfig;
import com.ycwl.basic.model.repository.TaskUpdateResult;
import com.ycwl.basic.service.task.TaskService;
@@ -56,6 +53,7 @@ import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.WxMpUtil;
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.stereotype.Service;
@@ -66,6 +64,7 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.LinkedHashMap;
import java.util.stream.Collectors;
/**
@@ -102,95 +101,11 @@ public class GoodsServiceImpl implements GoodsService {
@Autowired
private CouponBiz couponBiz;
@Autowired
private SourceRepository sourceRepository;
@Autowired
private TemplateBiz templateBiz;
@Autowired
private VideoUpdateConfig videoUpdateConfig;
@Autowired
private MemberRelationRepository memberRelationRepository;
public ApiResponse<List<GoodsPageVO>> goodsList(GoodsReqQuery query) {
Long scenicId = query.getScenicId();
if (query.getFaceId() != null) {
FaceEntity face = faceRepository.getFace(query.getFaceId());
if (face == null) {
return ApiResponse.success(Collections.emptyList());
}
scenicId = face.getScenicId();
}
//查询原素材
List<GoodsPageVO> goodsList = new ArrayList<>();
VideoReqQuery videoReqQuery = new VideoReqQuery();
videoReqQuery.setScenicId(scenicId);
videoReqQuery.setIsBuy(query.getIsBuy());
videoReqQuery.setFaceId(query.getFaceId());
//查询成片vlog
List<VideoRespVO> videoList = videoMapper.queryByRelation(videoReqQuery);
videoList.forEach(videoRespVO -> {
GoodsPageVO goodsPageVO = new GoodsPageVO();
goodsPageVO.setGoodsName(videoRespVO.getTemplateName());
goodsPageVO.setScenicId(videoRespVO.getScenicId());
try {
ScenicV2DTO scenic = scenicRepository.getScenicBasic(videoRespVO.getScenicId());
goodsPageVO.setScenicName(scenic.getName());
} catch (Exception e) {
goodsPageVO.setScenicName("");
}
goodsPageVO.setGoodsType(0);
goodsPageVO.setFaceId(videoRespVO.getFaceId());
goodsPageVO.setGoodsId(videoRespVO.getId());
goodsPageVO.setTemplateName(videoRespVO.getTemplateName());
goodsPageVO.setTemplateCoverUrl(videoRespVO.getTemplateCoverUrl());
goodsList.add(goodsPageVO);
});
SourceReqQuery sourceReqQuery = new SourceReqQuery();
sourceReqQuery.setScenicId(scenicId);
sourceReqQuery.setIsBuy(query.getIsBuy());
sourceReqQuery.setFaceId(query.getFaceId());
//查询源素材
List<SourceRespVO> sourceList = sourceMapper.queryByRelation(sourceReqQuery);
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
List<GoodsPageVO> sourceGoods = sourceList.stream().collect(Collectors.groupingBy(SourceRespVO::getFaceId)).entrySet().stream().flatMap((faceEntry) -> {
Long faceId = faceEntry.getKey();
List<SourceRespVO> goods = faceEntry.getValue();
return goods.stream().collect(Collectors.groupingBy(SourceRespVO::getType)).keySet().stream().filter(type -> {
if (Integer.valueOf(1).equals(type)) {
return !Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_video"));
} else if (Integer.valueOf(2).equals(type)) {
return !Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_image"));
}
return true;
}).map(type -> {
GoodsPageVO goodsPageVO = new GoodsPageVO();
goodsPageVO.setFaceId(faceId);
goodsPageVO.setGoodsType(type);
if (type == 1) {
goodsPageVO.setGoodsName("录像集");
goodsPageVO.setTemplateCoverUrl(scenicConfig.getString("video_cover_url"));
} else if (type == 2) {
goodsPageVO.setGoodsName("照片集");
goodsPageVO.setTemplateCoverUrl(scenicConfig.getString("photo_cover_url"));
} else {
goodsPageVO.setGoodsName("未知商品");
}
if (StringUtils.isBlank(goodsPageVO.getTemplateCoverUrl())) {
goodsPageVO.setTemplateCoverUrl(goods.getFirst().getUrl());
}
goodsPageVO.setScenicId(query.getScenicId());
return goodsPageVO;
});
}).toList();
if (!sourceGoods.isEmpty()) {
if (goodsList.size() > 2) {
goodsList.addAll(2, sourceGoods);
} else {
goodsList.addAll(sourceGoods);
}
}
return ApiResponse.success(goodsList);
}
@Autowired
private PrinterMapper printerMapper;
@Override
public List<GoodsDetailVO> sourceGoodsList(GoodsReqQuery query) {
@@ -218,7 +133,7 @@ public class GoodsServiceImpl implements GoodsService {
//图片编号
int i=1;
for (SourceRespVO sourceRespVO : list) {
GoodsDetailVO goodsDetailVO = new GoodsDetailVO();
GoodsDetailPrintSceneVO goodsDetailVO = new GoodsDetailPrintSceneVO();
goodsDetailVO.setFaceId(sourceRespVO.getFaceId());
goodsDetailVO.setGoodsId(sourceRespVO.getId());
String shootingTime = DateUtil.format(sourceRespVO.getCreateTime(), "yyyy.MM.dd HH:mm:ss");
@@ -261,6 +176,10 @@ public class GoodsServiceImpl implements GoodsService {
goodsDetailVO.setUrl(sourceRespVO.getUrl());
goodsDetailVO.setCreateTime(sourceRespVO.getCreateTime());
goodsDetailVO.setIsFree(sourceRespVO.getIsFree());
if (Strings.CI.equals("print", query.getScene())) {
// 查询该素材是否在用户打印列表中
goodsDetailVO.setInList(printerMapper.countFacePhoto(sourceRespVO.getScenicId(), face.getId(), sourceRespVO.getId()) > 0);
}
goodsDetailVOList.add(goodsDetailVO);
i++;
}
@@ -307,7 +226,7 @@ public class GoodsServiceImpl implements GoodsService {
goodsDetailVO.setFaceId(entity.getFaceId());
goodsDetailVO.setIsBuy(entity.getIsBuy());
if (Integer.valueOf(0).equals(entity.getIsBuy())) {
IsBuyRespVO buy = orderBiz.isBuy(userId, videoRespVO.getScenicId(), 0, videoId);
IsBuyRespVO buy = orderBiz.isBuy(videoRespVO.getScenicId(), userId, entity.getFaceId(), 0, videoId);
if (!buy.isBuy()) {
PriceObj priceObj = orderBiz.queryPrice(videoRespVO.getScenicId(), 0, videoId);
if (priceObj.isFree()) {
@@ -581,7 +500,7 @@ public class GoodsServiceImpl implements GoodsService {
goodsUrlVO.setCreateTime(source.getCreateTime());
return goodsUrlVO;
}).collect(Collectors.toList());
IsBuyRespVO isBuy = orderBiz.isBuy(face.getMemberId(), face.getScenicId(), query.getSourceType(), face.getId());
IsBuyRespVO isBuy = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), query.getSourceType(), face.getId());
if (!isBuy.isBuy()) {
ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(face.getScenicId());
if (scenicConfig != null && ((scenicConfig.getAntiScreenRecordType() & 2) == 0)) {
@@ -673,7 +592,7 @@ public class GoodsServiceImpl implements GoodsService {
}
return true;
}).count();
IsBuyRespVO isBuy = orderBiz.isBuy(face.getMemberId(), face.getScenicId(), query.getSourceType(), face.getId());
IsBuyRespVO isBuy = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), query.getSourceType(), face.getId());
if (count > 0) {
if (!isBuy.isBuy()) {
return Collections.emptyList();
@@ -829,4 +748,102 @@ public class GoodsServiceImpl implements GoodsService {
return result;
}
@Override
public ApiResponse<List<GoodsPageVO>> listGoodsByFaceIdList(List<Long> faceIds, Integer isBuy, Long scenicId) {
// 参数校验
if (faceIds == null || faceIds.isEmpty()) {
return ApiResponse.success(Collections.emptyList());
}
if (scenicId == null) {
return ApiResponse.success(Collections.emptyList());
}
// 获取景区配置
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
// 使用 LinkedHashMap 按 goodsType-goodsId 去重
Map<String, GoodsPageVO> goodsMap = new LinkedHashMap<>();
// 循环查询每个 faceId 的商品
for (Long faceId : faceIds) {
// 查询成片 vlog (goodsType = 0)
VideoReqQuery videoReqQuery = new VideoReqQuery();
videoReqQuery.setScenicId(scenicId);
videoReqQuery.setIsBuy(isBuy);
videoReqQuery.setFaceId(faceId);
List<VideoRespVO> videoList = videoMapper.queryByRelation(videoReqQuery);
for (VideoRespVO videoRespVO : videoList) {
String key = "0-" + videoRespVO.getId(); // goodsType=0, goodsId=videoId
if (!goodsMap.containsKey(key)) {
GoodsPageVO goodsPageVO = new GoodsPageVO();
goodsPageVO.setGoodsName(videoRespVO.getTemplateName());
goodsPageVO.setScenicId(videoRespVO.getScenicId());
try {
ScenicV2DTO scenic = scenicRepository.getScenicBasic(videoRespVO.getScenicId());
goodsPageVO.setScenicName(scenic.getName());
} catch (Exception e) {
goodsPageVO.setScenicName("");
}
goodsPageVO.setGoodsType(0);
goodsPageVO.setFaceId(videoRespVO.getFaceId());
goodsPageVO.setGoodsId(videoRespVO.getId());
goodsPageVO.setTemplateName(videoRespVO.getTemplateName());
goodsPageVO.setTemplateCoverUrl(videoRespVO.getTemplateCoverUrl());
goodsMap.put(key, goodsPageVO);
}
}
}
// 查询源素材 (goodsType = 1/2) - 使用新的 GROUP BY 方法
SourceReqQuery sourceReqQuery = new SourceReqQuery();
sourceReqQuery.setScenicId(scenicId);
sourceReqQuery.setIsBuy(isBuy);
sourceReqQuery.setFaceIds(faceIds);
// 使用 queryGroupedByFaceAndType 方法,数据库已经按 faceId+type 分组
List<SourceRespVO> sourceList = sourceMapper.queryGroupedByFaceAndType(sourceReqQuery);
// 遍历分组后的结果,每个 faceId+type 组合只有一条记录
for (SourceRespVO source : sourceList) {
Integer type = source.getType();
Long sourceFaceId = source.getFaceId();
// 根据景区配置过滤禁用的素材类型
boolean isDisabled = false;
if (Integer.valueOf(1).equals(type)) {
isDisabled = Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_video"));
} else if (Integer.valueOf(2).equals(type)) {
isDisabled = Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_image"));
}
if (!isDisabled) {
String key = type + "-" + sourceFaceId; // goodsType=type, goodsId=faceId(源素材用faceId作为ID)
if (!goodsMap.containsKey(key)) {
GoodsPageVO goodsPageVO = new GoodsPageVO();
goodsPageVO.setFaceId(sourceFaceId);
goodsPageVO.setGoodsType(type);
if (type == 1) {
goodsPageVO.setGoodsName("录像集");
goodsPageVO.setTemplateCoverUrl(scenicConfig.getString("video_cover_url"));
} else if (type == 2) {
goodsPageVO.setGoodsName("照片集");
goodsPageVO.setTemplateCoverUrl(scenicConfig.getString("photo_cover_url"));
} else {
goodsPageVO.setGoodsName("未知商品");
}
if (StringUtils.isBlank(goodsPageVO.getTemplateCoverUrl())) {
goodsPageVO.setTemplateCoverUrl(source.getUrl());
}
goodsPageVO.setScenicId(scenicId);
goodsMap.put(key, goodsPageVO);
}
}
}
// 返回去重后的商品列表
List<GoodsPageVO> resultList = new ArrayList<>(goodsMap.values());
return ApiResponse.success(resultList);
}
}

View File

@@ -30,7 +30,7 @@ public interface FaceService {
FaceRecognizeResp faceUpload(MultipartFile file, Long scenicId, Long userId, String scene);
List<FaceRespVO> listByUser(Long userId, String scenicId);
List<FaceRespVO> listByUser(Long userId, Long scenicId);
SearchFaceRespVo matchFaceId(Long faceId);
SearchFaceRespVo matchFaceId(Long faceId, boolean isNew);

View File

@@ -45,6 +45,15 @@ import com.ycwl.basic.model.pc.video.entity.VideoEntity;
import com.ycwl.basic.model.repository.TaskUpdateResult;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
import com.ycwl.basic.pricing.dto.ProductItem;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.pricing.service.IPriceCalculationService;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository;
@@ -173,6 +182,13 @@ public class FaceServiceImpl implements FaceService {
private BuyStatusProcessor buyStatusProcessor;
@Autowired
private VideoRecreationHandler videoRecreationHandler;
@Autowired
private PuzzleGenerationRecordMapper puzzleGenerationRecordMapper;
@Autowired
private IPriceCalculationService iPriceCalculationService;
@Autowired
private PuzzleTemplateMapper puzzleTemplateMapper;
@Override
public ApiResponse<PageInfo<FaceRespVO>> pageQuery(FaceReqQuery faceReqQuery) {
PageHelper.startPage(faceReqQuery.getPageNum(),faceReqQuery.getPageSize());
@@ -303,7 +319,7 @@ public class FaceServiceImpl implements FaceService {
}
@Override
public List<FaceRespVO> listByUser(Long userId, String scenicId) {
public List<FaceRespVO> listByUser(Long userId, Long scenicId) {
return faceMapper.listByScenicAndUserId(scenicId, userId);
}
@@ -438,6 +454,50 @@ public class FaceServiceImpl implements FaceService {
}
}).collect(Collectors.toList());
List<PuzzleTemplateEntity> puzzleTemplateEntityList = puzzleTemplateMapper.list(face.getScenicId(), null, 1);
if (!puzzleTemplateEntityList.isEmpty()) {
List<PuzzleGenerationRecordEntity> records = puzzleGenerationRecordMapper.listByFaceId(faceId);
puzzleTemplateEntityList.forEach(template -> {
Optional<PuzzleGenerationRecordEntity> optionalRecord = records.stream().filter(r -> r.getTemplateId().equals(template.getId())).findFirst();
ContentPageVO sfpContent = new ContentPageVO();
sfpContent.setName(template.getName());
sfpContent.setGroup("plog");
sfpContent.setScenicId(face.getScenicId());
sfpContent.setContentType(3);
sfpContent.setSourceType(3);
sfpContent.setLockType(-1);
sfpContent.setContentId(optionalRecord.map(PuzzleGenerationRecordEntity::getId).orElse(null));
sfpContent.setTemplateId(template.getId());
sfpContent.setTemplateCoverUrl(template.getCoverImage());
sfpContent.setGoodsType(3);
sfpContent.setSort(0);
if (optionalRecord.isPresent()) {
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), faceId, 5, optionalRecord.get().getTemplateId());
if (isBuyRespVO.isBuy()) {
sfpContent.setIsBuy(1);
} else {
sfpContent.setIsBuy(0);
}
}
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
ProductItem productItem = new ProductItem();
productItem.setProductType(ProductType.PHOTO_LOG);
productItem.setProductId(template.getId().toString());
productItem.setPurchaseCount(1);
productItem.setScenicId(face.getScenicId().toString());
calculationRequest.setProducts(Collections.singletonList(productItem));
calculationRequest.setUserId(face.getMemberId());
calculationRequest.setFaceId(face.getId());
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
PriceCalculationResult calculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
if (calculationResult.getFinalAmount().compareTo(BigDecimal.ZERO) > 0) {
sfpContent.setFreeCount(0);
} else {
sfpContent.setFreeCount(1);
}
contentList.add(1, sfpContent);
});
}
SourceReqQuery sourceReqQuery = new SourceReqQuery();
sourceReqQuery.setScenicId(face.getScenicId());
sourceReqQuery.setFaceId(faceId);
@@ -460,9 +520,8 @@ public class FaceServiceImpl implements FaceService {
sourceImageContent.setLockType(-1);
sourceVideoContent.setGroup("直出原片");
sourceImageContent.setGroup("直出原片");
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
if (!scenicConfigFacade.isDisableSourceImage(face.getScenicId())) {
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(userId, face.getScenicId(), SourceType.IMAGE.getCode(), faceId);
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), userId, faceId, SourceType.IMAGE.getCode(), faceId);
sourceImageContent.setSourceType(isBuyRespVO.getGoodsType());
sourceImageContent.setContentId(isBuyRespVO.getGoodsId());
if (isBuyRespVO.isBuy()) {
@@ -481,7 +540,7 @@ public class FaceServiceImpl implements FaceService {
contentList.add(sourceImageContent);
}
if (!scenicConfigFacade.isDisableSourceVideo(face.getScenicId())) {
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(userId, face.getScenicId(), SourceType.VIDEO.getCode(), faceId);
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), userId, faceId, SourceType.VIDEO.getCode(), faceId);
sourceVideoContent.setSourceType(isBuyRespVO.getGoodsType());
sourceVideoContent.setContentId(isBuyRespVO.getGoodsId());
if (isBuyRespVO.isBuy()) {
@@ -513,7 +572,6 @@ public class FaceServiceImpl implements FaceService {
}
}
});
return contentList;
}

View File

@@ -183,6 +183,9 @@ public class OrderServiceImpl implements OrderService {
} else if (Integer.valueOf(4).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("一体机打印");
item.setOrderType("一体机打印");
} else if (Integer.valueOf(5).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("plog图");
item.setOrderType("plog图");
} else {
item.setGoodsName("未知商品");
item.setOrderType("未知商品");
@@ -447,6 +450,8 @@ public class OrderServiceImpl implements OrderService {
goodsName = "景区录像包";
} else if (type == 2) {
goodsName = "景区照片包";
} else {
goodsName = "景区售卖物品";
}
}
wxPayOrderReqVO.setOpenId(order.getOpenId())
@@ -691,10 +696,6 @@ public class OrderServiceImpl implements OrderService {
// 使用synchronized确保同一用户对同一商品的订单创建操作串行化
synchronized (orderKey.intern()) {
IsBuyRespVO isBuy = orderBiz.isBuy(userId, createOrderReqVO.getScenicId(), createOrderReqVO.getGoodsType(), createOrderReqVO.getGoodsId());
if (isBuy.isBuy()) {
return ApiResponse.fail("您已购买此内容,无需重复购买!");
}
// 看看有没有之前购买的订单
OrderEntity order = orderMapper.getUserOrderItem(userId, createOrderReqVO.getScenicId(), 0, null, createOrderReqVO.getGoodsType(), createOrderReqVO.getGoodsId());
boolean haveOldOrder = false;
@@ -722,8 +723,14 @@ public class OrderServiceImpl implements OrderService {
order.setPrice(priceObj.getPrice());
// 判断是否是本人数据
FaceEntity goodsFace = faceRepository.getFace(priceObj.getFaceId());
if (goodsFace != null && !goodsFace.getMemberId().equals(userId)) {
return ApiResponse.fail("您无权购买此内容!");
if (goodsFace != null) {
if (!goodsFace.getMemberId().equals(userId)) {
return ApiResponse.fail("您无权购买此内容!");
}
IsBuyRespVO isBuy = orderBiz.isBuy(createOrderReqVO.getScenicId(), userId, goodsFace.getId(), createOrderReqVO.getGoodsType(), createOrderReqVO.getGoodsId());
if (isBuy.isBuy()) {
return ApiResponse.fail("您已购买此内容,无需重复购买!");
}
}
// promo code
order.setPayPrice(priceObj.getPrice());
@@ -920,12 +927,14 @@ public class OrderServiceImpl implements OrderService {
FaceEntity face = faceRepository.getFace(request.getFaceId());
ProductItem productItem = request.getProducts().getFirst();
Integer type = switch (productItem.getProductType()) {
case PHOTO_LOG -> 5;
case PHOTO_SET -> 2;
case VLOG_VIDEO -> 0;
case RECORDING_SET -> 1;
default -> 0;
};
Long goodsId = switch (productItem.getProductType()) {
case PHOTO_LOG -> Long.valueOf(productItem.getProductId());
case PHOTO_SET, RECORDING_SET -> face.getId();
case VLOG_VIDEO -> {
List<MemberVideoEntity> videos = memberRelationRepository.listRelationByFaceAndTemplate(face.getId(), Long.valueOf(productItem.getProductId()));
@@ -933,7 +942,7 @@ public class OrderServiceImpl implements OrderService {
}
default -> 0L;
};
IsBuyRespVO isBuy = orderBiz.isBuy(userId, face.getScenicId(), type, goodsId);
IsBuyRespVO isBuy = orderBiz.isBuy(face.getScenicId(), userId, face.getId(), type, goodsId);
if (isBuy.isBuy()) {
throw new BaseException("您已购买此内容,无需重复购买!");
}

View File

@@ -1,4 +1,17 @@
package com.ycwl.basic.service.pc.orchestrator;
import cn.hutool.core.date.DateUtil;
import com.ycwl.basic.biz.TaskStatusBiz;
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.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.puzzle.service.IPuzzleTemplateService;
import java.util.HashMap;
import java.util.Map;
import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.exception.BaseException;
@@ -79,6 +92,14 @@ public class FaceMatchingOrchestrator {
private BuyStatusProcessor buyStatusProcessor;
@Autowired
private VideoRecreationHandler videoRecreationHandler;
@Autowired
private IPuzzleTemplateService puzzleTemplateService;
@Autowired
private IPuzzleGenerateService puzzleGenerateService;
@Autowired
private PuzzleGenerationRecordMapper puzzleGenerationRecordMapper;
@Autowired
private TaskStatusBiz taskStatusBiz;
/**
* 编排人脸匹配的完整流程
@@ -92,6 +113,11 @@ public class FaceMatchingOrchestrator {
throw new IllegalArgumentException("faceId 不能为空");
}
if (isNew) {
// 新用户,设置任务状态为待处理
taskStatusBiz.setFaceCutStatus(faceId, 0);
}
// 步骤1: 数据准备
MatchingContext context = prepareMatchingContext(faceId, isNew);
if (context == null) {
@@ -106,7 +132,7 @@ public class FaceMatchingOrchestrator {
SearchFaceRespVo searchResult = executeFaceRecognition(context);
if (searchResult == null) {
log.warn("人脸识别返回结果为空,faceId={}", faceId);
throw new BaseException("人脸识别失败请换一张试试把~");
throw new BaseException("人脸识别失败,请换一张试试把~");
}
// 执行补救逻辑
@@ -119,6 +145,9 @@ public class FaceMatchingOrchestrator {
// 步骤4-6: 处理源文件关联和业务逻辑
processSourceRelations(context, searchResult, faceId, isNew);
// 步骤7: 异步生成拼图模板
asyncGeneratePuzzleTemplate(context.face.getScenicId(), faceId, context.face.getMemberId());
return searchResult;
} catch (BaseException e) {
@@ -318,9 +347,93 @@ public class FaceMatchingOrchestrator {
taskService.autoCreateTaskByFaceId(faceId);
} else {
log.debug("景区配置 face_select_first=true,跳过自动创建任务:faceId={}", faceId);
taskStatusBiz.setFaceCutStatus(faceId, 2);
}
}
/**
* 步骤8: 异步生成拼图模板
* 在人脸匹配完成后,异步为该景区的所有启用的拼图模板生成图片
*/
private void asyncGeneratePuzzleTemplate(Long scenicId, Long faceId, Long memberId) {
new Thread(() -> {
try {
log.info("开始异步生成景区拼图模板: scenicId={}, faceId={}", scenicId, faceId);
// 查询该景区所有启用状态的拼图模板
List<PuzzleTemplateDTO> templateList = puzzleTemplateService.listTemplates(
scenicId, null, 1); // 查询启用状态的模板
if (templateList == null || templateList.isEmpty()) {
log.info("景区不存在启用的拼图模板,跳过生成: scenicId={}", scenicId);
return;
}
log.info("景区存在 {} 个启用的拼图模板,开始逐个生成: scenicId={}", templateList.size(), scenicId);
// 获取人脸信息用于动态数据
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.warn("人脸信息不存在,无法生成拼图: faceId={}", faceId);
return;
}
ScenicV2DTO scenicBasic = scenicRepository.getScenicBasic(face.getScenicId());
// 准备公共动态数据
Map<String, String> baseDynamicData = new HashMap<>();
if (face.getFaceUrl() != null) {
baseDynamicData.put("faceImage", face.getFaceUrl());
baseDynamicData.put("userAvatar", face.getFaceUrl());
}
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"));
// 遍历所有模板,逐个生成
int successCount = 0;
int failCount = 0;
for (PuzzleTemplateDTO template : templateList) {
try {
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());
successCount++;
} catch (Exception e) {
log.error("拼图生成失败: scenicId={}, templateCode={}, templateName={}",
scenicId, template.getCode(), template.getName(), e);
failCount++;
}
}
log.info("景区拼图模板批量生成完成: scenicId={}, 总数={}, 成功={}, 失败={}",
scenicId, templateList.size(), successCount, failCount);
} catch (Exception e) {
// 异步任务失败不影响主流程,仅记录日志
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
}
}, "PuzzleTemplateGenerator-" + scenicId).start();
}
/**
* 匹配上下文
* 封装匹配过程中需要的所有上下文信息

View File

@@ -45,7 +45,7 @@ public class BuyStatusProcessor {
}
// 获取用户购买状态
IsBuyRespVO isBuy = orderBiz.isBuy(memberId, scenicId,
IsBuyRespVO isBuy = orderBiz.isBuy(scenicId, memberId, faceId,
memberSourceEntityList.getFirst().getType(),
faceId);

View File

@@ -90,4 +90,15 @@ public interface PrinterService {
* @return 成功数量
*/
int rejectPrintTasks(List<Integer> taskIds);
/**
* 从拼图记录添加到打印列表
* @param memberId 用户ID
* @param scenicId 景区ID
* @param faceId 人脸ID
* @param resultImageUrl 拼图结果图片URL
* @param puzzleRecordId 拼图记录ID
* @return 添加成功的MemberPrint记录ID
*/
Integer addUserPhotoFromPuzzle(Long memberId, Long scenicId, Long faceId, String resultImageUrl, Long puzzleRecordId);
}

View File

@@ -755,7 +755,7 @@ public class PrinterServiceImpl implements PrinterService {
PrinterEntity printer = printerMapper.getById(item.getPrinterId());
// 水印处理逻辑(仅当sourceId不为空时执行)
String printUrl = item.getCropUrl();
if (item.getSourceId() != null) {
if (item.getSourceId() != null && item.getSourceId() > 0) {
try {
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId());
String printWatermarkType = scenicConfig.getString("print_watermark_type");
@@ -789,11 +789,18 @@ public class PrinterServiceImpl implements PrinterService {
boolean needRotation = false;
try {
HttpUtil.downloadFile(item.getCropUrl().replace("oss.zhentuai.com", "frametour-assets.oss-cn-shanghai-internal.aliyuncs.com"), originalFile);
HttpUtil.downloadFile(item.getCropUrl(), originalFile);
WatermarkInfo watermarkInfo = new WatermarkInfo();
// 判断图片方向并处理旋转
boolean isLandscape = ImageUtils.isLandscape(originalFile);
log.info("打印照片方向检测,照片ID: {}, 是否为横图: {}", item.getId(), isLandscape);
boolean isLandscape = false;
try {
Integer rotate = JacksonUtil.getInt(item.getCrop(), "rotation");
if (rotate != null) {
isLandscape = rotate % 180 == 0;
}
} catch (Exception ignored) {
}
if (!isLandscape) {
// 竖图需要旋转为横图
@@ -801,10 +808,10 @@ public class PrinterServiceImpl implements PrinterService {
rotatedOriginalFile = new File("print_" + processId + "_rotated.jpg");
ImageUtils.rotateImage90(originalFile, rotatedOriginalFile);
log.info("竖图已旋转为横图,照片ID: {}", item.getId());
watermarkInfo.setOffsetLeft(40);
}
// 处理水印
WatermarkInfo watermarkInfo = new WatermarkInfo();
watermarkInfo.setScenicLine(scenicConfig.getString("print_watermark_scenic_text", ""));
watermarkInfo.setOriginalFile(needRotation ? rotatedOriginalFile : originalFile);
watermarkInfo.setWatermarkedFile(watermarkedFile);
@@ -856,6 +863,55 @@ public class PrinterServiceImpl implements PrinterService {
} catch (Exception e) {
log.error("获取景区配置失败,使用原始照片进行打印。景区ID: {}, 照片ID: {}", item.getScenicId(), item.getId(), e);
}
} else if (item.getSourceId() != null && item.getSourceId() == 0) {
// 拼图:添加白边框并向上偏移以避免打印机偏移
try {
// 生成唯一的处理标识符,避免多线程环境下的文件冲突
String processId = item.getId() + "_" + UUID.randomUUID().toString();
File originalFile = new File("puzzle_" + processId + ".png");
File processedFile = new File("puzzle_" + processId + "_processed.png");
try {
// 下载原图
HttpUtil.downloadFile(item.getCropUrl(), originalFile);
// 添加白边框(左右20px,上下30px)并向上偏移15px
ImageUtils.addBorderAndShiftUp(originalFile, processedFile, 20, 30, 15);
// 上传处理后的图片
IStorageAdapter adapter;
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId());
String storeType = scenicConfig.getString("store_type");
if (storeType != null) {
adapter = StorageFactory.get(storeType);
String storeConfigJson = scenicConfig.getString("store_config_json");
if (StringUtils.isNotBlank(storeConfigJson)) {
adapter.loadConfig(JacksonUtil.parseObject(storeConfigJson, Map.class));
}
} else {
adapter = StorageFactory.use("assets-ext");
}
String processedUrl = adapter.uploadFile(null, processedFile, StorageConstant.PHOTO_WATERMARKED_PATH, processedFile.getName());
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH, processedFile.getName());
printUrl = processedUrl;
log.info("拼图照片添加白边框并向上偏移成功,照片ID: {}, 新URL: {}", item.getId(), processedUrl);
} catch (Exception e) {
log.error("拼图照片处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e);
} finally {
// 清理临时文件
if (originalFile != null && originalFile.exists()) {
originalFile.delete();
}
if (processedFile != null && processedFile.exists()) {
processedFile.delete();
}
}
} catch (Exception e) {
log.error("拼图照片处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e);
}
}
// 根据数量创建多个打印任务
@@ -965,7 +1021,7 @@ public class PrinterServiceImpl implements PrinterService {
Long faceId = null;
// 查询该用户在该景区的所有人脸记录
List<FaceRespVO> userFaces = faceMapper.listByScenicAndUserId(scenicId.toString(), userId);
List<FaceRespVO> userFaces = faceMapper.listByScenicAndUserId(scenicId, userId);
// 查找是否存在相同URL的记录
for (FaceRespVO faceResp : userFaces) {
@@ -1202,4 +1258,56 @@ public class PrinterServiceImpl implements PrinterService {
return selectedPrinter;
}
@Override
public Integer addUserPhotoFromPuzzle(Long memberId, Long scenicId, Long faceId, String resultImageUrl, Long puzzleRecordId) {
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
log.error("拼图图片URL为空: memberId={}, scenicId={}, puzzleRecordId={}", memberId, scenicId, puzzleRecordId);
return null;
}
// 检查是否已经存在未打印的记录(status=0),避免重复导入
List<MemberPrintResp> existingPhotos = printerMapper.listRelationByFaceId(memberId, scenicId, faceId);
if (existingPhotos != null && !existingPhotos.isEmpty()) {
for (MemberPrintResp photo : existingPhotos) {
// 检查是否是同一个拼图记录且状态为0(未打印)
if (photo.getOrigUrl() != null && photo.getOrigUrl().equals(resultImageUrl)
&& photo.getStatus() != null && photo.getStatus() == 0) {
log.info("拼图照片已存在于打印列表中,直接返回: memberId={}, scenicId={}, puzzleRecordId={}, memberPrintId={}",
memberId, scenicId, puzzleRecordId, photo.getId());
return photo.getId();
}
}
}
MemberPrintEntity entity = new MemberPrintEntity();
entity.setMemberId(memberId);
entity.setScenicId(scenicId);
entity.setFaceId(faceId);
entity.setSourceId(puzzleRecordId); // 使用拼图记录ID作为sourceId
entity.setOrigUrl(resultImageUrl);
// 获取打印尺寸并裁剪图片
String cropUrl = resultImageUrl; // 默认使用原图
entity.setCropUrl(cropUrl);
entity.setStatus(0);
try {
int rows = printerMapper.addUserPhoto(entity);
if (rows > 0 && entity.getId() != null) {
log.info("拼图照片添加到打印列表成功: memberId={}, scenicId={}, puzzleRecordId={}, memberPrintId={}",
memberId, scenicId, puzzleRecordId, entity.getId());
return entity.getId();
} else {
log.error("拼图照片添加到打印列表失败: memberId={}, scenicId={}, puzzleRecordId={}",
memberId, scenicId, puzzleRecordId);
return null;
}
} catch (Exception e) {
log.error("拼图照片添加到打印列表异常: memberId={}, scenicId={}, puzzleRecordId={}",
memberId, scenicId, puzzleRecordId, e);
return null;
}
}
}

View File

@@ -151,7 +151,7 @@ public class TaskFaceServiceImpl implements TaskFaceService {
return memberSourceEntity;
}).collect(Collectors.toList());
if (!memberSourceEntityList.isEmpty()) {
IsBuyRespVO isBuy = orderBiz.isBuy(face.getMemberId(), face.getScenicId(), memberSourceEntityList.getFirst().getType(), faceEntity.getId());
IsBuyRespVO isBuy = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), faceEntity.getId(), memberSourceEntityList.getFirst().getType(), faceEntity.getId());
for (MemberSourceEntity memberSourceEntity : memberSourceEntityList) {
if (isBuy.isBuy()) { // 如果用户买过
memberSourceEntity.setIsBuy(1);

View File

@@ -442,7 +442,7 @@ public class TaskTaskServiceImpl implements TaskService {
memberVideoEntity.setTaskId(list.getFirst().getId());
VideoEntity video = videoMapper.findByTaskId(list.getFirst().getId());
if (video != null) {
IsBuyRespVO isBuy = orderBiz.isBuy(face.getMemberId(), list.getFirst().getScenicId(), 0, video.getId());
IsBuyRespVO isBuy = orderBiz.isBuy(list.getFirst().getScenicId(), face.getMemberId(), face.getId(), 0, video.getId());
if (isBuy.isBuy()) {
memberVideoEntity.setIsBuy(1);
memberVideoEntity.setOrderId(isBuy.getOrderId());
@@ -516,7 +516,7 @@ public class TaskTaskServiceImpl implements TaskService {
int isBuy = 0;
FaceEntity face = faceRepository.getFace(task.getFaceId());
if (face != null) {
IsBuyRespVO priceObj = orderBiz.isBuy(face.getMemberId(), task.getScenicId(), 0, video.getId());
IsBuyRespVO priceObj = orderBiz.isBuy(task.getScenicId(), face.getMemberId(), face.getId(), 0, video.getId());
if (priceObj.isBuy()) {
isBuy = 1;
}

View File

@@ -0,0 +1,216 @@
package com.ycwl.basic.task;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ycwl.basic.device.DeviceFactory;
import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
import com.ycwl.basic.device.operator.IDeviceStorageOperator;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
import com.ycwl.basic.repository.DeviceRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Calendar;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
* 设备视频连续性检查定时任务
* - 仅在生产环境(prod)运行
* - 每5分钟执行一次
* - 检查前12分钟到前2分钟的视频连续性
* - 仅在9点到18点之间检查
* - 结果缓存到Redis中
*
* @author Claude Code
* @date 2025-09-01
*/
@Slf4j
@Component
@EnableScheduling
@Profile("prod")
public class DeviceVideoContinuityCheckTask {
private static final String REDIS_KEY_PREFIX = "device:video:continuity:";
private static final int CACHE_TTL_HOURS = 24; // 缓存24小时
private static final int START_HOUR = 9; // 开始检查时间 9:00
private static final int END_HOUR = 18; // 结束检查时间 18:00
@Autowired
private DeviceIntegrationService deviceIntegrationService;
@Autowired
private DeviceRepository deviceRepository;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ObjectMapper objectMapper;
/**
* 定时任务:每5分钟执行一次
* cron表达式: 0 0/5 * * * * 表示每5分钟执行一次
*/
@Scheduled(cron = "0 0/5 * * * *")
public void checkDeviceVideoContinuity() {
// 检查是否在执行时间范围内(9:00-18:00)
int currentHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
if (currentHour < START_HOUR || currentHour >= END_HOUR) {
log.debug("当前时间 {}:00 不在检查时间范围内({}:00-{}:00),跳过检查",
currentHour, START_HOUR, END_HOUR);
return;
}
log.info("开始执行设备视频连续性检查定时任务");
long startTime = System.currentTimeMillis();
try {
// 获取所有激活的设备(分页获取,每次100个)
int pageSize = 100;
int currentPage = 1;
int totalChecked = 0;
int successCount = 0;
int failureCount = 0;
while (true) {
PageResponse<DeviceV2DTO> pageResponse = deviceIntegrationService.listDevices(
currentPage, pageSize, null, null, null, 1, null
);
if (pageResponse == null || pageResponse.getList() == null
|| pageResponse.getList().isEmpty()) {
break;
}
// 检查每个设备的视频连续性
for (DeviceV2DTO device : pageResponse.getList()) {
try {
boolean checked = checkSingleDevice(device);
totalChecked++;
if (checked) {
successCount++;
} else {
failureCount++;
}
} catch (Exception e) {
log.error("检查设备 {} 视频连续性失败: {}", device.getId(), e.getMessage(), e);
failureCount++;
totalChecked++;
}
}
// 检查是否还有更多页
int totalPages = (int) Math.ceil((double) pageResponse.getTotal() / pageSize);
if (currentPage >= totalPages) {
break;
}
currentPage++;
}
long endTime = System.currentTimeMillis();
log.info("设备视频连续性检查任务完成: 总计检查 {} 个设备, 成功 {}, 失败 {}, 耗时 {}ms",
totalChecked, successCount, failureCount, (endTime - startTime));
} catch (Exception e) {
log.error("执行设备视频连续性检查定时任务失败", e);
}
}
/**
* 检查单个设备的视频连续性
*
* @param device 设备信息
* @return true表示检查成功并缓存,false表示跳过检查
*/
private boolean checkSingleDevice(DeviceV2DTO device) {
try {
// 获取设备配置
DeviceConfigEntity config = deviceRepository.getDeviceConfig(device.getId());
if (config == null) {
log.debug("设备 {} 没有配置信息,跳过检查", device.getId());
return false;
}
// 获取设备的存储操作器
IDeviceStorageOperator operator = DeviceFactory.getDeviceStorageOperator(device, config);
if (operator == null) {
log.debug("设备 {} 没有配置存储操作器,跳过检查", device.getId());
return false;
}
// 计算检查时间范围: 当前时间向前12分钟到向前2分钟
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, -2);
Date endDate = calendar.getTime();
calendar.add(Calendar.MINUTE, -10); // 再向前10分钟,总共12分钟
Date startDate = calendar.getTime();
// 执行连续性检查(允许2秒间隙)
VideoContinuityResult result = operator.checkVideoContinuity(startDate, endDate, 2000L);
// 创建缓存对象
DeviceVideoContinuityCache cache = DeviceVideoContinuityCache.fromResult(
device.getId(), result, startDate, endDate
);
// 存储到Redis
String redisKey = REDIS_KEY_PREFIX + device.getId();
String cacheJson = objectMapper.writeValueAsString(cache);
redisTemplate.opsForValue().set(redisKey, cacheJson, CACHE_TTL_HOURS, TimeUnit.HOURS);
log.info("设备 {} 视频连续性检查完成: support={}, continuous={}, videos={}, gaps={}, duration={}ms",
device.getId(), result.isSupport(), result.isContinuous(),
result.getTotalVideos(), result.getGapCount(), result.getTotalDurationMs());
return true;
} catch (Exception e) {
log.error("检查设备 {} 视频连续性失败", device.getId(), e);
throw new RuntimeException("检查设备视频连续性失败", e);
}
}
/**
* 手动触发检查(用于测试)
*
* @param deviceId 设备ID
* @return 检查结果
*/
public DeviceVideoContinuityCache manualCheck(Long deviceId) {
log.info("手动触发设备 {} 的视频连续性检查", deviceId);
try {
// 获取设备信息
DeviceV2DTO device = deviceIntegrationService.getDevice(deviceId);
if (device == null) {
throw new RuntimeException("设备不存在: " + deviceId);
}
// 检查设备
checkSingleDevice(device);
// 从Redis获取结果
String redisKey = REDIS_KEY_PREFIX + deviceId;
String cacheJson = redisTemplate.opsForValue().get(redisKey);
if (cacheJson == null) {
throw new RuntimeException("检查完成但未找到缓存结果");
}
return objectMapper.readValue(cacheJson, DeviceVideoContinuityCache.class);
} catch (Exception e) {
log.error("手动检查设备 {} 视频连续性失败", deviceId, e);
throw new RuntimeException("手动检查失败: " + e.getMessage(), e);
}
}
}

View File

@@ -401,7 +401,7 @@ public class VideoPieceGetter {
videoSource.setFaceId(task.getFaceId());
videoSource.setScenicId(deviceV2.getScenicId());
videoSource.setSourceId(sourceEntity.getId());
IsBuyRespVO isBuy = orderBiz.isBuy(task.getMemberId(), deviceV2.getScenicId(), 1, task.getFaceId());
IsBuyRespVO isBuy = orderBiz.isBuy(deviceV2.getScenicId(), task.getMemberId(), task.getFaceId(), 1, task.getFaceId());
if (isBuy.isBuy()) { // 如果用户买过
videoSource.setIsBuy(1);
} else if (isBuy.isFree()) { // 全免费逻辑
@@ -432,7 +432,7 @@ public class VideoPieceGetter {
// 有原视频,source已存在,可以直接添加关联关系
if (task.memberId != null && task.faceId != null) {
List<MemberSourceEntity> memberSourceEntities = memberRelationRepository.listSourceByFaceRelation(task.faceId, 1);
IsBuyRespVO isBuy = orderBiz.isBuy(task.getMemberId(), deviceV2.getScenicId(), 1, task.getFaceId());
IsBuyRespVO isBuy = orderBiz.isBuy(deviceV2.getScenicId(), task.getMemberId(), task.getFaceId(), 1, task.getFaceId());
MemberSourceEntity videoSource = new MemberSourceEntity();
videoSource.setId(SnowFlakeUtil.getLongId());
videoSource.setScenicId(deviceV2.getScenicId());

View File

@@ -193,6 +193,52 @@ public class ImageUtils {
}
}
/**
* 旋转图片180度
*
* @param sourceFile 源图片文件
* @param targetFile 目标图片文件
* @throws IOException 读取或写入文件失败
*/
public static void rotateImage180(File sourceFile, File targetFile) throws IOException {
BufferedImage sourceImage = null;
BufferedImage rotatedImage = null;
try {
sourceImage = ImageIO.read(sourceFile);
if (sourceImage == null) {
throw new IOException("无法读取图片文件: " + sourceFile.getPath());
}
int width = sourceImage.getWidth();
int height = sourceImage.getHeight();
// 创建旋转后的图片(宽高不变)
rotatedImage = new BufferedImage(width, height, sourceImage.getType());
Graphics2D g2d = rotatedImage.createGraphics();
// 设置旋转变换
AffineTransform transform = new AffineTransform();
transform.translate(width / 2.0, height / 2.0);
transform.rotate(Math.PI);
transform.translate(-width / 2.0, -height / 2.0);
g2d.setTransform(transform);
g2d.drawImage(sourceImage, 0, 0, null);
g2d.dispose();
// 保存旋转后的图片
ImageIO.write(rotatedImage, "jpg", targetFile);
log.info("图片旋转180度成功,尺寸保持: {}x{}", width, height);
} finally {
if (sourceImage != null) {
sourceImage.flush();
}
if (rotatedImage != null) {
rotatedImage.flush();
}
}
}
/**
* 智能裁切图片以填充目标尺寸,支持自动旋转以减少裁切损失
*
@@ -330,10 +376,6 @@ public class ImageUtils {
return source;
}
if (degrees != 270) {
throw new IllegalArgumentException("仅支持270度旋转");
}
int width = source.getWidth();
int height = source.getHeight();
@@ -406,6 +448,130 @@ public class ImageUtils {
return cropped;
}
/**
* 为图片添加白边框并向上偏移内容
* 用于拼图打印场景,避免打印机偏移问题
*
* @param sourceFile 源图片文件
* @param targetFile 目标图片文件
* @param horizontalBorder 左右白边框宽度(像素)
* @param verticalBorder 上下白边框高度(像素)
* @param upwardShift 内容向上偏移的像素数
* @throws IOException 读取或写入文件失败
*/
public static void addBorderAndShiftUp(File sourceFile, File targetFile,
int horizontalBorder, int verticalBorder, int upwardShift) throws IOException {
BufferedImage sourceImage = null;
BufferedImage resultImage = null;
try {
sourceImage = ImageIO.read(sourceFile);
if (sourceImage == null) {
throw new IOException("无法读取图片文件: " + sourceFile.getPath());
}
int srcWidth = sourceImage.getWidth();
int srcHeight = sourceImage.getHeight();
// 计算新图片尺寸(原图 + 左右边框 + 上下边框)
int newWidth = srcWidth + horizontalBorder * 2;
int newHeight = srcHeight + verticalBorder * 2;
// 创建新图片
resultImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = resultImage.createGraphics();
try {
// 填充白色背景
g2d.setColor(java.awt.Color.WHITE);
g2d.fillRect(0, 0, newWidth, newHeight);
// 绘制原图到新图中
// 原图应该绘制在: x=horizontalBorder, y=verticalBorder-upwardShift 的位置
// 这样图片内容会向上偏移upwardShift像素
int drawX = horizontalBorder;
int drawY = verticalBorder - upwardShift;
g2d.drawImage(sourceImage, drawX, drawY, null);
log.info("图片添加白边框并向上偏移: 原始尺寸={}x{}, 边框=(左右{}px,上下{}px), 向上偏移={}px, 结果尺寸={}x{}",
srcWidth, srcHeight, horizontalBorder, verticalBorder, upwardShift, newWidth, newHeight);
} finally {
g2d.dispose();
}
// 保存处理后的图片
ImageIO.write(resultImage, "png", targetFile);
} finally {
if (sourceImage != null) {
sourceImage.flush();
}
if (resultImage != null) {
resultImage.flush();
}
}
}
/**
* 向上偏移图片以避免打印机偏移问题
* 舍弃顶部指定像素,整体向上移动,并在底部补充白底
*
* @param sourceFile 源图片文件
* @param targetFile 目标图片文件
* @param offsetPixels 向上偏移的像素数(舍弃顶部的像素数,底部补充相同像素的白底)
* @throws IOException 读取或写入文件失败
* @deprecated 使用 addBorderAndShiftUp 代替
*/
@Deprecated
public static void shiftImageUp(File sourceFile, File targetFile, int offsetPixels) throws IOException {
BufferedImage sourceImage = null;
BufferedImage shiftedImage = null;
try {
sourceImage = ImageIO.read(sourceFile);
if (sourceImage == null) {
throw new IOException("无法读取图片文件: " + sourceFile.getPath());
}
int width = sourceImage.getWidth();
int height = sourceImage.getHeight();
if (offsetPixels <= 0 || offsetPixels >= height) {
throw new IllegalArgumentException("偏移像素必须大于0且小于图片高度,当前值: " + offsetPixels + ", 图片高度: " + height);
}
// 创建新图片,保持原始宽度和高度
shiftedImage = new BufferedImage(width, height, sourceImage.getType());
Graphics2D g2d = shiftedImage.createGraphics();
try {
// 先填充白色背景
g2d.setColor(java.awt.Color.WHITE);
g2d.fillRect(0, 0, width, height);
// 从源图的offsetPixels位置开始截取到底部,绘制到目标图的顶部
// 源图: 从(0, offsetPixels)到(width, height)的区域
// 目标图: 绘制到(0, 0)到(width, height-offsetPixels)的区域
g2d.drawImage(sourceImage, 0, 0, width, height - offsetPixels,
0, offsetPixels, width, height, null);
// 底部的offsetPixels像素保持白色(已通过fillRect填充)
} finally {
g2d.dispose();
}
// 保存处理后的图片
ImageIO.write(shiftedImage, "png", targetFile);
log.info("图片向上偏移成功,原始尺寸: {}x{}, 偏移: {}px, 结果尺寸: {}x{} (底部补充{}px白底)",
width, height, offsetPixels, width, height, offsetPixels);
} finally {
if (sourceImage != null) {
sourceImage.flush();
}
if (shiftedImage != null) {
shiftedImage.flush();
}
}
}
/**
* 裁切策略内部类
*/

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