Compare commits

..

136 Commits

Author SHA1 Message Date
0ed12af8c9 test(facebody): 更新人脸识别适配器集成测试
Some checks failed
ZhenTu-BE/pipeline/head There was a failure building this commit
- 重命名 AliFaceBodyAdapterTest 为 AliFaceBodyAdapterIT
- 重命名 BceFaceBodyAdapterTest 为 B
2026-01-27 11:00:01 +08:00
ecbdec4518 test(puzzle 2026-01-27 09:47:33 +08:00
bf6b866e67 refactor(member): 更新用户信息方法传递用户ID参数
- 在AppMemberController中从BaseContextHandler获取用户ID并传递给服务层
- 修改AppMemberServiceImpl中的update方法签名以接收用户ID参数
- 更新AppMemberService接口定义以包含用户ID参数
- 移除服务实现中重复的BaseContextHandler调用
- 确保用户信息更新时使用正确的用户上下文
2026-01-26 14:50:39 +08:00
93f9c1486f feat(app): 添加微信小程序内容安全检测功能
- 集成 WxMpUtil 工具类用于微信小程序消息安全检测
- 在用户更新昵称时添加内容安全校验逻辑
- 使用景区配置的微信小程序 AppId 和 AppSecret 进行检测
- 当昵称包含违规内容时抛出参数错误异常
- 实现 msgSecCheck 方法调用微信小程序内容安全接口
- 添加 MSG_SEC_CHECK_URL 常量定义检测接口地址
2026-01-26 14:09:54 +08:00
e87e38be03 feat(order): 添加商品重复购买检查功能
- 集成重复购买检查策略工厂和上下文管理
- 实现基于商品类型的重复购买验证机制
- 添加价格计算结果中是否已购买的标识字段
- 扩展商品项目DTO以支持已购买状态标记
- 实现异常捕获方式的购买状态检测逻辑
- 集成Redis缓存提升重复购买检查性能
2026-01-26 11:06:45 +08:00
85d0fc0996 fix(video): 解决视频数据获取时的空指针异常
- 添加了 contentPageVO 为 null 的检查并返回 null 避免后续操作
- 添加了 videoRespVO 为 null 的检查并返回 null 避免后续操作
- 在流处理后添加了非空过滤确保结果列表不包含 null 值
2026-01-26 10:47:06 +08:00
d25d09cb66 feat(task): 添加自动替换vlog配置控制功能
- 实现scenicConfig管理器获取景区配置
- 添加auto_replace_vlog配置项,默认值为true
- 当auto_replace_vlog为false时跳过自动创建任务
- 添加视频存在检查避免重复处理
- 记录跳过任务的详细日志信息
2026-01-23 21:07:58 +08:00
c40c6a0966 1 2026-01-23 19:25:52 +08:00
4fc0984994 feat(coupon): 优化优惠券领取结果返回逻辑
- 移除控制器中只返回首个错误的限制,改为返回完整的领取结果列表
- 在 CouponClaimResult DTO 中新增 claimedRecords 字段用于返回已领取记录
- 添加 failureWithClaimedRecords 静态方法支持携带已领取记录的失败结果
- 当用户达到领取上限时查询并返回其已领取的券记录供前端展示
- 实现无论成功或失败都向客户端返回完整结果数据的功能
2026-01-23 18:04:13 +08:00
918ff860c3 feat(pricing): 更新用户优惠券查询接口返回完整信息
- 修改 getUserCoupons 接口不再需要传入 userId 参数,从上下文获取当前登录用户
- 新增 UserCouponResp DTO 包含领取记录和优惠券配置的完整信息
- 更新 ICouponService 接口返回类型为 UserCouponResp 列表
- 在 Controller 层添加 getUserId 方法用于获取当前登录用户ID
- 实现完整的用户优惠券信息组装逻辑,包含领取时间、过期时间等记录信息
2026-01-22 15:55:05 +08:00
8b3bea8bed feat(AppTemplateController): 添加景区模板内容列表接口
- 新增 ScenicTemplateContentVO 数据传输对象
- 实现 /scenic/{scenicId}/contents 接口获取景区模板内容
- 支持获取普通模板和拼图模板的基础信息
- 返回模板名称、分组、ID和封面图片等信息
- 设置普通模板商品类型为0,拼图模板商品类型为3
- 拼图模板固定分组为"氛围拼图"
2026-01-22 15:54:54 +08:00
be54bbaa82 1 2026-01-22 14:05:29 +08:00
68a674ba51 feat(coupon): 添加优惠券领取后有效期功能
- 在 PriceCouponClaimRecord 实体中添加过期时间字段
- 在 PriceCouponConfig 实体中添加领取后有效天数配置
- 更新查询可用优惠券的 SQL 条件以过滤过期券
- 修改插入领用记录的 SQL 语句以包含过期时间
- 实现领取时根据配置计算过期时间的逻辑
2026-01-22 14:03:28 +08:00
80f8a6b56b feat(task): 添加景区ID到微信订阅通知触发请求
- 在CouponExpireNotificationTask任务中为微信订阅通知请求添加scenicId字段
- 确保通知请求包含正确的景区标识信息
2026-01-21 19:04:30 +08:00
973bd73e9a feat(task): 添加渲染预览任务创建功能
- 引入渲染相关DTO和服务类用于预览任务创建
- 移除未使用的消息生产者和通知认证工具依赖
- 在任务创建后异步触发渲染预览任务生成
- 实现虚拟线程异步处理渲染预览任务创建逻辑
- 添加素材映射转换支持视频和图片类型识别
- 实现异常捕获确保主流程不受渲染服务影响
2026-01-21 15:51:25 +08:00
819caab047 render_v2 2026-01-21 14:32:13 +08:00
00bf4b5a8b chore(task): 2026-01-20 20:33:10 +08:00
6c305f4cd1 fix(task): 优化下载通知任务的执行频率和时间范围
- 移除未使用的ZtMessageProducerService和NotificationAuthUtils依赖注入
- 将定时任务执行时间从每天21点调整为每天9点和21点
- 将查询时间范围从24小时缩短为12小时,提高查询效率
- 保持用户去重逻辑以避免重复发送通知
2026-01-20 20:26:51 +08:00
82e844a779 feat(notify): 添加微信订阅消息去重功能
- 在 WechatSubscribeTemplateConfigEntity 中新增 dedupSeconds 字段用于配置去重窗口
- 将去重配置从事件模板映射复制到通知配置实体中
- 集成 RedisTemplate 实现基于时间窗口的消息去重机制
- 支持三种去重模式:永久去重(0)、不设去重(负数)、窗口期去重(正数)
- 实现基于 Redis 分布式锁的重复消息过滤逻辑
- 为非永久去重场景生成唯一数据库幂等键以避免冲突
2026-01-20 20:19:44 +08:00
c3fcfdd633 ```
style(app): 调整日期格式显示

- 将日期时间格式从 yyyy-MM-dd HH:mm 修改为 yyyy-MM-dd
```
2026-01-20 18:51:52 +08:00
a8156976be feat(puzzle): 添加免费拼图通知任务功能
- 在MemberPuzzleMapper中新增listFreeUnpurchased方法用于查询指定时间范围内生成且未购买的免费拼图记录
- 新增FreePuzzleNotificationTask定时任务类,每天晚7点执行免费拼图通知
- 添加SQL映射配置实现免费拼图记录的查询逻辑
- 实现微信订阅通知触发机制,向符合条件的用户发送免费拼图领取通知
- 集成景区信息查询和会员信息获取功能用于通知内容构造
2026-01-20 18:35:57 +08:00
ce48bd00c9 feat(task): 添加优惠券领取和过半提醒定时任务
- 新增优惠券领取通知定时任务,每小时执行一次查询最近2小时内领取的优惠券
- 新增优惠券有效期过半提醒定时任务,每天18点执行
- 引入ScenicRepository和ScenicV2DTO用于获取景区基础信息
- 修改processNotification方法提取公共逻辑到processRecords方法
- 在微信订阅消息变量中增加景区ID和景区名称字段
- 优化优惠券相关查询逻辑和数据处理流程
2026-01-20 17:11:20 +08:00
c5df277e6c feat(task): 添加优惠券过期提醒定时任务
- 实现优惠券过期提醒功能,每天20点执行
- 实现优惠券临期提醒功能,每天8点执行
- 集成微信订阅消息通知服务
- 查询指定时间范围内已领取的优惠券记录
- 构建优惠券信息变量并触发通知
- 添加异常处理和日志记录机制
2026-01-20 16:58:36 +08:00
9a31e71e42 refactor(coupon): 移除优惠券相关模块代码
- 删除优惠券控制器相关类,包括 AppCouponController 和 CouponController
- 移除优惠券记录控制器 CouponRecordController
- 删除优惠券数据访问层接口及实现类
- 移除优惠券相关的实体类、请求响应对象
- 清理业务逻辑层中与优惠券相关的服务接口及实现
- 从 PriceBiz 中移除优惠券相关导入依赖
- 从任务类 DownloadNotificationTasker 中移除优惠券相关导入
- 删除优惠券相关的 MyBatis 映射文件
2026-01-20 16:30:47 +08:00
e268d236f4 fix(coupon): 修复优惠券重复领取和状态检查逻辑
- 修改数据库查询方法返回类型为List以支持多条记录查询
- 更新AutoCouponServiceImpl中的重复领取检查逻辑
- 在CouponServiceImpl中实现可用优惠券筛选功能
- 优化优惠券状态验证逻辑并改进错误信息提示
- 修复使用优惠券时的状态判断条件
2026-01-20 15:53:33 +08:00
143426db1f refactor(glm): 优化 GLM 客户端实现
- 添加 @Lazy 注解以延迟初始化 GLM 客户端
- 避免应用启动时不必要的资源消耗
- 提高系统启动性能和内存使用效率
2026-01-20 15:52:27 +08:00
fcc4b06295 refactor(goods): 优化视频片段更新状态检查逻辑
- 移除无新片段情况下的冗余日志输出
- 将视频未关联任务的日志级别从error调整为warn
- 保持原有的业务逻辑判断不变
2026-01-20 15:52:13 +08:00
f876dc59fa feat(task): 添加资源通知定时任务功能
- 实现了每日19点执行的资源通知定时任务
- 查询当日新增人脸数据并获取相关会员信息
- 整合景区、视频和照片资源统计数据
- 集成微信订阅消息推送服务
- 构建资源通知模板变量并触发消息发送
- 添加异常处理和日志记录机制
2026-01-20 15:34:46 +08:00
8e6d10ad95 feat(watermark): 调整水印模板布局为原图完整显示
- 将原图区域从90%高度调整为100%完整高度
- 添加底部扩展10%区域用于信息展示
- 更新PuzzleDefault和PuzzlePrint模板的画布尺寸计算逻辑
- 修改二维码尺寸计算基准为原始图片高度
- 调整布局参数常量命名以反映新的设计思路
2026-01-20 11:36:25 +08:00
42bf3d3d0a refactor(puzzle): 优化拼图记录查询逻辑
- 移除 BigDecimal 导入并修改拼图数量统计方式
- 使用关联表查询替换直接的数量统计方法
- 更新拼图记录查询逻辑,通过关联表获取数据
- 添加对空值的过滤处理确保数据完整性
- 修改内容页面转换方法,支持免费状态判断
- 删除价格计算相关依赖和服务调用
- 添加 MemberPuzzleEntity 和 FreeStatus 常量支持
- 从关联记录读取免费状态替代价格计算逻辑
2026-01-20 11:24:57 +08:00
679f2d3a79 feat(order): 添加退款单号幂等性支持并完善订单状态流转验证
- 在RefundRequest中新增refundNo字段作为退款单号幂等键
- 添加订单状态、支付状态、退款状态流转验证逻辑
- 完善updateRefundStatus方法支持退款单号和支付平台退款单号参数
- 优化createRefundRecord方法增加退款单号重复性检查和幂等处理
- 重构支付回调处理逻辑统一状态更新方式
- 增强订单状态流转的安全性和一致性校验
2026-01-19 21:28:24 +08:00
3084afc6a7 fix(pricing): 修复升级检查中的支付金额处理逻辑
- 移除 resolvePaidAmount 方法中对 purchasedDetails 的依赖
- 添加支付金额为空时的异常抛出机制
- 在升单结果中添加补差价金额的判断逻辑
- 更新文档中标注支付金额为必填字段
- 优化打包优惠计算中的补差价处理流程
2026-01-19 20:28:36 +08:00
91626626f4 feat(pricing): 优化升单价格计算逻辑支持补差价功能
- 修改 UpgradeBundleDiscountResult 和 UpgradeOnePriceResult 中 estimatedFinalAmount 字段含义为补差价金额
- 在 UpgradeCheckRequest 中新增 paidAmount 字段用于传递已支付金额
- 在 UpgradeCheckResult 中新增 bestUpgradeType 和 bestPayableAmount 字段提供最优升单建议
- 在 UpgradePriceSummary 中新增 paidAmount 字段记录已支付金额
- 更新价格计算服务实现,加入已支付金额处理逻辑
- 新增 normalizeAmount、calculateSupplementAmount 等工具方法确保金额计算精度
- 修复测试代码中的数据类型不匹配问题
2026-01-19 20:25:44 +08:00
b1cfef278d Merge branch 'order_update'
# Conflicts:
#	src/main/java/com/ycwl/basic/pricing/CLAUDE.md
2026-01-19 19:54:59 +08:00
c42474256e fix(LyCompatibleController): 修复视频列表获取逻辑
- 修改了ContentPageVO的获取方式,使用filter过滤掉contentId为null的记录
- 使用findFirst替换getFirst避免空指针异常
- 确保只有有效的contentId才会被用于后续的视频查询操作
2026-01-19 19:32:09 +08:00
63180159d2 feat(puzzle): 实现免费拼图下载时自动添加水印功能
- 在AppPuzzleController中新增水印相关依赖注入
- 添加免费拼图判断逻辑,区分付费与免费拼图下载流程
- 实现addWatermarkForFreePuzzle方法处理水印添加
- 集成景区信息、人脸信息、二维码等水印模板数据
- 添加水印任务创建与等待机制,支持30秒超时处理
- 增加水印操作的日志记录与异常处理机制
- 优化免费拼图下载的安全性保护措施
2026-01-19 18:58:08 +08:00
e647ad75c6 feat(clickhouse): 实现统计数据查询的时间序列填充功能
- 将日期时间处理从旧的 Date 和 SimpleDateFormat 迁移到新的 Java 8 时间 API
- 添加小时级别数据序列填充功能,确保每个小时都有数据记录
- 添加日期级别数据序列填充功能,确保每天都有数据记录
- 实现缺失时间段的数据自动补零机制
- 重构查询方法以支持连续时间序列数据返回
- 提高统计图表数据完整性和可视化效果
2026-01-17 16:58:23 +08:00
4a07f5bba9 fix(puzzle): 修复拼图生成服务中的打印队列关联ID问题
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 将硬编码的0L替换为实际的拼图记录ID
- 确保打印队列记录能正确关联到对应的拼图生成记录
- 更新代码注释以明确ID的用途和关联关系
2026-01-17 03:24:43 +08:00
1f7e6d69f4 fix(app): 修复拼图记录ID传递问题
- 将硬编码的0L替换为实际的recordId参数
- 确保拼图记录能够正确关联到puzzle_record表
- 移除打印特有标识注释,统一使用拼图记录ID逻辑
2026-01-17 03:05:07 +08:00
50aaf7cb1a refactor(puzzle): 移除边缘渲染任务数据访问层
- 删除了 PuzzleEdgeRenderTaskMapper 接口文件
- 移除了对应的 MyBatis XML 映射文件
- 清理了数据库操作相关的实体映射配置
- 移除了任务领取、成功标记、失败标记等数据库操作方法
- 删除了查询下一条可领取任务的业务逻辑实现
2026-01-17 02:50:10 +08:00
f2c739160a feat(printer): 添加图片类型字段支持不同来源图片处理
- 在 MemberPrintEntity 和 MemberPrintResp 中新增 imageType 字段
- 根据 sourceId 自动设置图片类型为移动上传或普通照片
- 拼图类型图片明确标记为 PUZZLE 类型
- 修改图片来源判断逻辑从 sourceId 改为 imageType 字段
- 更新数据库映射文件添加 image_type 字段映射
2026-01-17 02:45:14 +08:00
2efc66292e fix(order): 修复优惠券使用请求中的景区ID设置问题
- 将优惠券使用请求中的景区ID从缓存结果字符串改为订单实际景区ID
- 添加空值检查以避免潜在的空指针异常
- 确保景区ID正确传递为字符串格式
2026-01-17 01:57:01 +08:00
0eced869fa feat(pricing): 支持无限量优惠券功能
- 修改数据库更新逻辑以支持无限量优惠券
- 当 total_quantity 为 NULL 或 <= 0 时不限制使用数量
- 使用 COALESCE 函数处理空值情况
- 更新 SQL 条件判断逻辑以兼容无限量场景
2026-01-17 01:55:49 +08:00
aa2611d369 feat(printer): 添加拼图打印功能支持
- 在ImageWatermarkOperatorEnum中新增PUZZLE_PRINT类型
- 在WatermarkEdgeService中添加PuzzlePrint水印模板支持
- 修改ImageWatermarkFactory对PUZZLE_PRINT类型的处理逻辑
- 移除PuzzleBorderStage并创建专用的拼图打印处理管线
- 实现createPuzzlePrintPipeline方法用于拼图水印处理
- 添加preparePuzzleWatermarkConfig方法准备拼图专用水印配置
- 更新打印机服务中的拼图处理流程以使用新的水印配置
2026-01-17 01:55:37 +08:00
6a8f679540 feat(clickhouse): 添加打印样片页面访问统计功能
- 实现按小时统计访问打印样片页面人数的功能
- 实现按日期统计访问打印样片页面人数的功能
- 在ClickHouse查询服务中添加相应的SQL查询方法
- 在MySQL查询服务中添加接口实现
- 更新统计图表合并逻辑,支持打印样片访问数据展示
- 修改mergeChartData方法以支持三组数据合并
- 在MyBatis映射文件中添加对应的SQL查询语句
- 完善相关接口定义和文档注释
2026-01-16 20:15:41 +08:00
4fac129c3a feat(image): 启用边缘端水印处理并优化徕卡模板布局算法
- 将边缘端处理默认启用状态从false改为true
- 将边缘端处理超时时间从30秒调整为10秒
- 将徕卡水印模板的固定像素配置转换为基于1920x1080的百分比配置
- 新增多种百分比常量包括底部区域、Logo大小、边距、字体大小等
- 实现动态计算实际像素值的方法替代固定数值
- 在PrinterServiceImpl中注入WatermarkEdgeService依赖
- 配置水印处理流程启用边缘服务和存储适配器
2026-01-16 18:56:29 +08:00
830dd17071 feat(printer): 添加自定义打印图片URL功能
- 在CreateVirtualOrderRequest中新增printImgUrl字段
- 修改createVirtualOrder方法支持自定义打印图片URL参数
- 实现当提供printImgUrl时优先使用该URL进行打印
- 更新服务接口和实现类以支持新的参数传递
- 添加相应的文档注释说明新功能特性
2026-01-16 18:30:39 +08:00
83c831887e refactor(service): 移除视频URL内网代理逻辑
- 删除移动端商品服务中的视频URL内网地址代理转换代码
- 移除PC端资源服务中的视频URL内网代理处理逻辑
- 简化视频URL设置流程,直接使用原始URL地址
- 清理相关的异常处理和日志记录代码
2026-01-16 18:14:25 +08:00
5ab2882777 refactor(watermark): 将水印布局配置改为百分比方式
- 将固定像素配置改为基于1920x1080的百分比配置
- 添加底部距离、二维码大小、位置等百分比常量
- 修改二维码位置计算逻辑为基于百分比的方式
- 调整景区名和日期时间文字的布局和对齐方式
- 移除原有的固定偏移计算方法
- 优化文字区域的垂直居中对齐效果
2026-01-16 17:43:31 +08:00
a5a9ff09f2 feat(watermark): 添加边缘端水印处理功能
- 引入 WatermarkEdgeService 支持边缘端渲染
- 在 WatermarkConfig 中添加边缘端相关配置参数
- 在 WatermarkStage 中实现边缘端处理逻辑和降级机制
- 修改 ImageWatermarkOperatorEnum 的默认输出格式为 jpg
- 移除已废弃的 DefaultImageWatermarkOperator 类
- 更新 GoodsServiceImpl 使用边缘端处理水印
- 优化 PuzzleEdgeWorkerIpInterceptor 允许本地回环地址访问
- 修正 PrinterDefaultWatermarkTemplateBuilder 样式常量名称
2026-01-16 17:25:19 +08:00
83e47ed843 refactor(goods): 移除预览功能并优化水印处理逻辑
- 移除了 sourceGoodsListPreview 接口及相关实现
- 新增 WatermarkEdgeService 和 FaceService 依赖注入
- 实现边缘端水印处理支持,包含降级机制
- 优化二维码和头像文件处理流程
- 统一水印处理的异常处理和资源清理逻辑
2026-01-16 17:19:31 +08:00
e9a4c26a83 refactor(watermark): 调整徕卡水印模板构建器的画布布局策略
- 画布大小改为原图大小(不再扩展底部区域)
- 原图收缩后放置在画布上半部分,为底部留出空间
- 计算原图收缩后的区域高度和底部区域起始Y坐标
- 将原图元素从画布顶部调整为收缩后放在画布上半部分
- 调整Logo、帧途文字和二维码元素的Y坐标计算方式
- 更新布局说明文档以反映新的设计策略
2026-01-16 17:07:48 +08:00
8c76a4fb03 refactor(printer): 简化人脸二维码生成逻辑
- 移除原有的复杂二维码生成和文件操作代码
- 使用 pcFaceService.bindWxaCode 方法替代
- 直接重定向到生成的二维码 URL
- 消除临时文件创建和删除操作
- 简化 HTTP 响应处理流程
2026-01-16 16:26:47 +08:00
8198b0c537 feat(watermark): 添加拼图水印模板构建器
- 实现拼图默认水印模板构建器,支持原图区域和底部信息区域布局
- 实现拼图打印水印模板构建器,增加四周白边设计
- 配置二维码、头像、景区名和日期时间的文字布局
- 支持动态数据绑定和图片元素的COVER模式显示
- 提供可选的头像圆形裁剪功能和右对齐文字显示
2026-01-16 16:16:59 +08:00
0235d1d121 feat(watermark): 添加水印边缘渲染模板构建功能
- 实现抽象水印模板构建器基类提供通用构建工具方法
- 定义水印模板构建器接口规范模板构建契约
- 实现徕卡风格水印模板构建器支持底部扩展布局
- 实现普通风格水印模板构建器支持左下角布局
- 实现打印专用水印模板构建器支持缩放和偏移
- 创建水印边缘任务服务统一管理模板构建流程
- 添加水印请求参数类定义边缘渲染所需字段
- 实现水印模板构建结果类封装模板元素和动态数据
- 集成拼图边缘渲染任务服务实现异步渲染机制
2026-01-16 15:21:38 +08:00
8d5a10cce1 feat(puzzle): 添加水印拼图功能支持
- 在 PuzzleEdgeRenderTaskEntity 中新增 taskType 和 watermarkType 字段
- 添加 TASK_TYPE_PUZZLE 和 TASK_TYPE_WATERMARK 常量定义
- 新增 PuzzleWatermarkMapper 依赖注入
- 实现 handleWatermarkTaskSuccess 方法处理水印拼图任务成功逻辑
- 修改 taskSuccess 方法根据任务类型分别处理原始拼图和水印拼图
- 新增 createWatermarkRenderTask 方法创建水印拼图边缘渲染任务
- 为水印拼图任务添加独立的存储目录和文件命名规则
- 实现水印拼图结果写入 puzzle_watermark 表的功能
2026-01-16 13:56:29 +08:00
eba727b446 feat(puzzle): 添加拼图水印功能支持
- 创建 PuzzleWatermarkEntity 实体类用于存储拼图水印信息
- 定义水印类型、URL、关联记录ID等关键字段
- 实现 PuzzleWatermarkMapper 数据访问层接口
- 提供新增水印记录的 insert 方法
- 添加批量查询水印的 listByRecordIds 方法
- 实现按记录和类型查询单条水印的 getByRecordAndType 方法
- 支持按人脸ID和水印类型进行条件查询
- 为拼图不同场景下的水印版本管理提供数据支撑
2026-01-16 13:55:40 +08:00
27a18096b5 feat(face): 添加小程序码异步预生成功能
- 在人脸创建后异步预生成小程序码,提升后续获取速度
- 实现小程序码文件按日期目录存储优化文件管理
- 添加阿里云OSS内网域名替换为公网域名的逻辑
- 增加小程序码文件存在性检查避免重复生成
- 添加异步任务异常处理和日志记录机制
- 优化文件路径命名规则提高系统可维护性
2026-01-16 11:59:01 +08:00
d15d070cb4 refactor(puzzle): 重构拼图功能实现会员拼图关联管理
- 移除原有的图片裁切功能和userArea字段
- 删除originalImageUrl字段,统一使用resultImageUrl
- 添加MemberPuzzleEntity实体类管理会员拼图关联关系
- 创建MemberPuzzleMapper接口及XML映射文件
- 实现PuzzleRelationProcessor处理器负责关联记录创建
- 在拼图生成完成后自动创建会员拼图关联记录
- 添加景区配置中的免费拼图数量设置
- 实现免费拼图逻辑控制
- 更新拼图模板和生成记录的数据结构
- 修改AppPuzzleController中图片URL的获取方式
- 优化PuzzleEdgeRenderTaskService中的图片处理流程
2026-01-16 11:23:21 +08:00
fb4568721a feat(template): 添加景区模板封面接口的token忽略注解
- 在AppTemplateController中导入IgnoreToken注解
- 为getScenicTemplateCoverUrls方法添加@IgnoreToken注解以允许匿名访问
- 确保景区模板封面获取接口无需身份验证即可访问
2026-01-15 18:25:28 +08:00
63d31d69a9 feat(puzzle): 实现拼图模板缓存功能
- 集成 PuzzleRepository 缓存层替代直接数据库查询
- 在 PriceBiz 中使用缓存查询拼图模板数据
- 在 AppTemplateController 中添加景区模板封面URL批量获取接口
- 在 PuzzleTemplateServiceImpl 中实现模板增改时的缓存清理逻辑
- 在 FaceServiceImpl 中使用缓存查询拼图模板
- 优化模板查询性能并减少数据库压力
2026-01-15 17:01:17 +08:00
2fb6aa42cf feat(image): 添加图片叠加功能支持
- 新增 OverlayImageConfig 类用于配置叠加图片参数
- 支持通过 imageKey 从 dynamicData 动态获取图片 URL
- 提供默认图片 URL 配置选项
- 支持设置叠加图片宽高比例(0.0-1.0 范围)
- 实现图片适配模式配置(CONTAIN、COVER、FILL、SCALE_DOWN)
- 添加圆角半径配置,支持自动圆形效果
- 支持水平垂直对齐方式设置(CENTER、LEFT、RIGHT、TOP、BOTTOM)
- 提供水平垂直偏移量调节功能
- 更新配置验证逻辑,增加叠加图片配置校验
- 修改图片 URL 校验规则,支持动态数据填充
- 更新 JSON Schema 配置模板,包含叠加图片配置项
2026-01-15 13:36:13 +08:00
fed92c5445 feat(face): 添加人脸绑定功能
- 实现了 faceId 绑定接口
- 集成了人脸识别服务匹配功能
- 添加了绑定状态参数支持
2026-01-14 21:59:56 +08:00
6d774e4d76 fix(print): 修复打印队列添加逻辑
- 将打印记录ID参数
2026-01-14 10:11:07 +08:00
57b71c309e feat(task): 添加视频ID到下载通知任务
- 在DownloadNotificationTasker中添加videoId变量到模板参数
- 将item.getVideoId()方法调用结果存入variables映射中
- 确保视频ID信息能够在通知模板中正确渲染使用
2026-01-13 11:17:34 +08:00
93e28828ad fix(statistics): 修复统计数据合并中的类型转换问题
- 将订单数据转为 Map 时使用 String.valueOf 处理 Object 类型数值
- 在合并数据时对时间键和金额字段进行字符串类型转换
- 防止因数值类型不匹配导致的数据丢失问题
2026-01-12 22:36:00 +08:00
f8c6604a8a refactor(statistics): 切换数据查询服务并优化扫码统计功能
- 将 BrokerBiz 和 OrderBiz 中的数据查询从 StatisticsMapper 切换到 StatsQueryService
- 更新 StatisticsServiceImpl 使用 StatsQueryService 进行数据查询
- 添加订单数据合并功能到扫码统计图表中
- 重构扫码统计查询逻辑以支持统计数据和订单数据的合并显示
- 新增按小时和按日期统计订单数据的查询方法
- 优化 SQL 查询以分离统计数据和订单数据的查询逻辑
2026-01-12 18:30:27 +08:00
3bd658cc1f refactor(clickhouse): 优化景点统计数据查询逻辑
- 将原有的 scenicId 参数匹配条件替换为子查询方式
- 统一使用 enterScenicTraceIdSubQuery 方法处理景点访问轨迹ID筛选
- 移除重复的时间范围过滤条件以提高查询效率
- 保持 LOAD 和 FACE_UPLOAD 操作的数据统计一致性
- 简化 LAUNCH 操作的时间范围过滤逻辑
- 提高代码可维护性和查询性能
2026-01-12 17:43:43 +08:00
7b417aa4f1 fix(clickhouse): 修复查询时间范围条件处理逻辑
- 修改了小时统计查询中的时间范围条件,添加空值检查
- 修改了天统计查询中的时间范围条件,添加空值检查
- 将固定的时间范围查询改为可选的时间范围过滤
- 避免当开始或结束时间为空时的SQL语法错误
- 确保查询参数的灵活性和安全性
2026-01-12 13:46:51 +08:00
6ca7dceb0e feat(wechat): 支持微信订阅消息模板渲染嵌套数据结构
- 实现renderValue方法支持递归渲染Map类型的值
- 添加对非字符串类型值的直接返回处理
- 在任务服务中根据分组配置动态设置视频结果页面变量
- 为分组启用场景添加travelVideoCenter页面配置
- 为非分组场景保留videoSynthesis页面配置
2026-01-12 12:54:26 +08:00
0b3dd19de5 rm template v2 2026-01-11 22:25:51 +08:00
e56c2e6642 refactor(printer): 移除 WebSocket 任务推送功能
- 删除 PrinterTaskPushService 接口及其实现类
- 移除 WebSocketConfig 配置类及 PrinterWebSocketHandler 处理器
- 删除 WebSocket 相关模型类包括 WsMessage、WsMessageType、ErrorData、TaskAckData
- 移除 PrinterConnectionManager 连接管理器
- 从 PrinterServiceImpl 中删除 taskPushService 的依赖注入
- 移除创建打印任务时的 WebSocket 推送逻辑
- 移除审核通过任务的 WebSocket 推送逻辑
- 移除重新打印任务的 WebSocket 推送逻辑
2026-01-11 22:24:14 +08:00
482789b523 feat(task): 根据景区配置动态设置视频结果页面
- 获取景区配置管理器并检查分组功能是否启用
- 当分组功能启用时将视频结果页面设置
2026-01-11 00:04:04 +08:00
d902b480b8 fix(print): 修复打印队列添加功能
- 将打印记录ID参数修改为固定值0L
- 添加代码注释说明打印特有逻辑
2026-01-10 20:37:56 +08:00
fc0d5fed9b refactor(puzzle): 移除 worker 认证逻辑并简化任务处理
- 删除 PuzzleEdgeWorkerAuthRequest 认证请求类
- 移除 Controller 中的 accessKey 参数验证
- 删除 RenderWorkerEntity 和 RenderWorkerRepository 相关依赖
- 使用默认 workerId 替代动态 worker 验证逻辑
- 将 IP 验证职责移至拦截器层
- 简化客户端状态上报处理逻辑
- 统一任务处理流程中的 workerId 使用方式
2026-01-10 20:33:03 +08:00
31b9220a32 feat(notification): 添加视频任务统计信息到微信订阅通知
- 在任务服务中添加视频设备数量、镜头数量和拍摄时间变量
- 注入VideoTaskRepository依赖以获取任务统计数据
- 更新下载通知任务器中的变量映射逻辑
- 格式化日期时间为yyyy-MM-dd HH:mm格式
- 移除未使用的导入和重复的依赖注入
2026-01-10 20:30:15 +08:00
c4b78f1b09 fix(puzzle): 修复拼图记录图片URL获取错误
- 将resultImageUrl替换为originalImageUrl以正确获取原始图片URL
- 修正了拼图记录中图片URL为空时的错误提示逻辑
- 确保拼图功能能够正确处理原始图片URL而非结果图片URL
2026-01-10 20:29:10 +08:00
c9cc90c842 feat(notify): 添加批量查询用户授权余额功能
- 新增批量查询用户授权余额接口 /api/mobile/notify/auth/batch-remaining
- 实现批量检查用户对多个模板的授权记录功能
- 添加景区所有场景及模板列表查询接口并支持缓存
- 优化授权记录查询性能,使用批量查询替代逐个查询
- 新增批量查询请求对象 BatchRemainingCountReq 和响应对象 WechatSubscribeAllScenesResp
- 在数据层添加批量查询用户授权记录的 SQL 映射
- 实现缓存管理机制,支持所有场景模板配置的缓存读写与清理
2026-01-10 17:30:48 +08:00
02f1392355 feat(printer): 添加人脸图片URL重定向功能
- 实现通过人脸样本ID重定向到人脸图片URL的功能
- 实现通过人脸ID重定向到人脸图片URL的功能
- 添加404状态码处理当人脸数据不存在或URL为空的情况
- 使用response.sendRedirect实现URL重定向逻辑
2026-01-10 14:59:58 +08:00
d02aca9bf1 chore(AppFaceController): 移除人脸绑定功能的实现代码
- 注释掉 JWT 用户信息获取逻辑
- 移除人脸服务绑定调用
- 添加临时占位注释替代原有业务逻辑
2026-01-10 14:47:58 +08:00
05e269a305 fix(printer): 修复虚拟订单二维码生成问题
- 添加会员信息查询逻辑
- 实现虚拟订单使用无限二维码生成功能
- 非虚拟订单保持原有二维码生成方式
- 解决faceId绑定页面路径参数传递问题
2026-01-10 14:46:39 +08:00
74c146c104 feat(printer): 添加图像增强功能支持
- 在CreateVirtualOrderRequest中新增needEnhance字段
- 修改createVirtualOrder方法支持图像增强参数传递
- 更新setUserIsBuyItem方法以支持图像增强选项
- 在processPhotoWithPipeline调用中传入图像增强参数
- 为虚拟订单创建流程添加图像增强功能支持
2026-01-10 14:12:25 +08:00
42000df311 feat(order): 添加照片日记产品类型的价格计算支持
- 新增 case 5 分支处理照片日记产品类型
- 创建 PhotoLog 产品的价格计算请求对象
- 设置产品类型为 PHOTO_LOG 并配置相关参数
- 调用价格计算服务获取最终价格和原价
- 设置价格对象的 faceId 和 scenicId 字段
- 实现仅查询价格不使用优惠的预览模式
2026-01-09 22:58:29 +08:00
8b7f3d8eae fix(face-matching): 修复无人脸匹配结果时切片状态未更新问题
- 无匹配结果时将切片状态设置为已完成,避免前端一直显示"合成中"
- 确保人脸状态管理器正确更新切片状态为COMPLETED
2026-01-09 22:18:44 +08:00
6e345f2da4 refactor(pricing): 优化优惠券配置实体和领取逻辑
- 将时间字段类型从 LocalDateTime 改为 Date
- 为优惠券领取数量更新添加无条件增加方法
- 区分有限量和无限量优惠券的领取处理逻辑
- 实现有总量限制优惠券的库存检查机制
- 统一更新已领取数量的计数逻辑
2026-01-08 17:27:27 +08:00
d7c2c5b830 fix(coupon): 修复优惠券适用商品类型为空时的处理逻辑
- 添加空数组检查,当适用商品类型为空时不进行过滤
- 修复商品类型为空时直接返回全部商品总价的逻辑
- 保持原有商品类型过滤功能的完整性
2026-01-08 17:11:07 +08:00
07593694c8 feat(pricing): 添加优惠券配置中的申领数量和用户申领限制字段
- 在 PriceCouponConfigMapper 中新增 claimed_quantity 和 user_claim_limit 字段映射
- 更新 INSERT 语句以包含新的申领相关字段
- 修改 insertCoupon 方法以支持优惠券申领数量控制功能
2026-01-08 16:48:53 +08:00
3ff76a0bea fix(goods): 修复商品服务中视频任务状态返回逻辑
- 检查是否有完成的模板,如果没有则返回待处理状态
- 在计数为0时提前返回响应,避免后续处理逻辑执行
2026-01-08 14:37:31 +08:00
5952390093 fix(service): 修复视频任务状态和内容ID设置问题
- 修复当完成数量小于等于0时视频任务状态设置为待处理
- 调整内容页面VO中内容ID的设置顺序以确保正确赋值
2026-01-08 11:11:47 +08:00
e896f58d82 perf(notify): 优化微信订阅消息配置查询性能
- 为微信订阅消息配置接口添加 Redis 缓存支持
- 在 WechatSubscribeNotifyConfigRepository 中实现缓存读写和清除机制
- 修改 Controller 层接口添加 @IgnoreToken 注解支持匿名访问
- 优化查询逻辑,添加 memberId 为空时的提前返回处理
- 在管理服务中添加缓存清除逻辑,确保配置变更时缓存同步更新
- 实现批量缓存清除功能,支持按景区和全局范围清除缓存
2026-01-07 17:40:58 +08:00
3291371dd7 feat(puzzle): 添加景区模板列表缓存功能
- 新增景区模板列表缓存KEY常量PUZZLE_TEMPLATES_BY_SCENIC_KEY
- 在清除模板缓存时同步清除对应景区的模板列表缓存
- 实现listTemplateByScenic方法根据景区ID获取启用模板列表并缓存
- 实现clearTemplateByScenicCache方法清除景区模板列表缓存
- 重构人脸匹配编排器使用新的缓存方法替代原有数据库查询
- 移除过期的redisTemplate依赖
2026-01-07 17:40:58 +08:00
917668da0c refactor(puzzle): 优化拼图模板生成逻辑
- 移除 Redis 缓存检查机制
- 改用 PuzzleRepository 直接查询拼图模板数据
- 更新日志记录格式,使用 e.getMessage() 替代完整异常对象
- 调整依赖注入顺序,添加 PuzzleRepository 注入
- 简化模板列表查询逻辑,提升代码可读性
2026-01-07 17:40:58 +08:00
d3884c8aa2 refactor(facebody): 移除重复的日志记录
- 移除了重试成功时的冗余日志输出
- 保持了原有的重试逻辑和错误处理机制
- 优化了代码的可读性和日志输出的合理性
2026-01-07 17:40:58 +08:00
a652124a93 refactor(FaceMatchingOrchestrator): 重构人脸匹配拼图生成逻辑
- 移除外层异常捕获处理,将异常处理移到模板循环内部
- 将日志级别从 info 调整为 debug,减少不必要的日志输出
- 优化代码结构,移除多余的 try-catch 包装
- 保持原有的业务逻辑不变,仅调整代码组织方式
- 确保异常处理不影响主流程执行
2026-01-07 17:40:58 +08:00
54cdee333d feat(puzzle): 添加拼图素材版本缓存优化重复生成
- 新增 puzzleSourceVersionCache 缓存用于记录拼图素材版本
- 实现 isPuzzleSourceChanged 方法判断素材是否变化
- 添加 markPuzzleSourceVersion 方法标记当前素材版本
- 实现 invalidatePuzzleSourceVersion 方法清除指定人脸缓存
- 在人脸关系变更时自动清除相关拼图素材版本缓存
- 重构 AppPuzzleController 使用 PuzzleRepository 替代直接访问 Mapper
- 添加生成记录缓存机制,包括按人脸ID和记录ID的缓存
- 实现素材版本缓存命中时复用历史记录功能
- 优化重复内容检测逻辑,添加缓存标记机制
- 在各种生成流程中添加缓存清除逻辑确保数据一致性
2026-01-07 17:40:58 +08:00
286062a81a feat(app-statistics): 添加实时统计模式支持
- 在非实时模式下才写入当天统计缓存
- 实时模式由调用方自行控制写入目标日期
- 添加了实时模式查询的逻辑分支处理
2026-01-07 01:45:15 +08:00
e0856a1b9c feat(pricing): 添加场景优惠券功能
- 创建场景优惠券领取控制器,提供前端优惠券领取接口
- 创建场景优惠券配置管理控制器,提供后台管理端配置接口
- 定义场景优惠券领取和配置相关的请求响应DTO
- 创建场景优惠券配置实体和数据库表结构
- 实现场景优惠券配置的数据访问和业务逻辑处理
- 实现场景优惠券领取功能,支持景区隔离和默认配置回退
- 添加优惠券领取状态检查和用户限制验证逻辑
- 实现分页查询和配置管理功能
2026-01-06 18:30:23 +08:00
123a081eab refactor(notifications): 重构通知系统使用统一的微信订阅通知触发服务
- 移除 UserNotificationAuthController 中的 getScenicTemplatesWithAuth 方法
- 从 ScenicRepository 中删除微信模板ID相关方法和配置
- 重命名 WechatSubscribeNotifyTriggerService 为 notifyTriggerService
- 更新 TaskTaskServiceImpl 中的视频生成通知逻辑
- 重构 DownloadNotificationTasker 中的通知发送方式
- 统一使用 WechatSubscribeNotifyTriggerRequest 和 WechatSubscribeNotifyTriggerResult
- 移除 ZT 消息服务相关代码
- 简化变量传递和通知模板逻辑
2026-01-06 15:35:09 +08:00
95e86fb996 refactor(video): 移除设备视频连续性检查定时任务
- 删除了 DeviceVideoContinuityCheckTask 定时任务类
- 从 DeviceVideoContinuityController 中移除手动检查接口
- 从生产环境日志配置中移除相关日志记录器配置
- 移除了 RedisTemplate 和 ObjectMapper 的依赖注入
- 移除了设备视频连续性检查相关的定时任务逻辑
- 移除了手动触发检查的 API 接口实现
2026-01-06 14:57:11 +08:00
6c3a413778 refactor(task): 移除未使用的常量和依赖注入
- 移除未使用的 VIDEO_NOTIFICATION_CACHE_KEY 常量
- 移除未使用的 NOTIFICATION_CACHE_EXPIRE_MINUTES 常量
- 移除未使用的 faceMapper 依赖注入
- 移除未使用的 faceSampleMapper 依赖注入
- 移除未使用的 sourceMapper 依赖注入
2026-01-06 13:35:00 +08:00
da2286bc80 Merge branch 'notify_v2' 2026-01-06 11:30:52 +08:00
1df6a4bc23 refactor(order): 优化重复购买检查器的延迟初始化
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 在DuplicatePurchaseCheckerFactory类上添加@Lazy注解实现延迟加载
- 在NoCheckDuplicateChecker类上添加@Lazy注解实现延迟加载
- 在ParentResourceDuplicateChecker类上添加@Lazy注解实现延迟加载
- 在UniqueResourceDuplicateChecker类上添加@Lazy注解实现延迟加载
- 添加org.springframework.context.annotation.Lazy导入语句
- 通过延迟初始化提升应用启动性能
2026-01-05 18:12:25 +08:00
981a4ba7bd perf(logging): 移除视频切分任务中的冗余日志输出
- 移除了 VideoRecreationHandler 中的 info 级别日志
- 移除了 TaskFaceServiceImpl 中的 info 级别日志
- 移除了 TaskTaskServiceImpl 中的 info 级别日志
- 保留了 debug 级别的视频重切逻辑日志
- 减少了生产环境中的日志输出量
2026-01-05 18:04:05 +08:00
017ced34fa change(FaceMatchingOrchestrator): 修改图像输出格式和质量设置
- 将输出格式从PNG更改为JPEG
- 将图像质量从90调整为80
2026-01-05 18:03:57 +08:00
a9ae00d580 refactor(puzzle): 重构拼图同步生成逻辑
- 添加详细的方法执行日志记录
- 实现参数校验和模板查询验证
- 增加元素排序和动态数据构建
- 集成重复图片检测机制
- 添加内容去重检测和历史记录复用
- 实现边缘渲染任务创建和
2026-01-05 16:02:28 +08:00
99f75b6805 style(log): 移除日志输出语句
- 移除了 BceFaceBodyAdapter 中无法访问URL图片的警告日志
- 移除了 VideoPieceGetter 中计数器更新和进度检查的调试日志
- 清理了设备关联计数器相关的日志输出
- 移除了 placeholder 完成状态的日志记录
- 删除了进度检查相关的统计日志输出
2026-01-05 14:59:33 +08:00
295815f1fa feat(puzzle): 添加拼图渲染任务同步等待机制
- 引入 CompletableFuture 支持任务异步等待
- 创建 TaskWaitResult 类封装任务执行结果
- 实现 registerWait 和 waitForTask 方法支持同步等待
- 添加 waitFutures 缓存池管理等待任务
- 实现超时清理机制防止内存泄漏
- 提供 createAndWait 便捷方法一键创建并等待
- 在任务完成和失败时自动通知等待方
- 添加过期 future 清理机制优化内存使用
2026-01-05 14:59:25 +08:00
010bac1091 test(integration): 添加集成回退服务的单元测试
- 验证缓存存在时返回缓存值的功能
- 测试无缓存时调用远程服务并缓存结果
- 验证远程调用失败且无缓存时抛出异常
- 测试清除单个缓存项功能
- 验证清除服务所有缓存项功能
- 测试获取缓存统计信息功能
- 验证并发请求时只调用一次远程服务的互斥锁机制
2026-01-05 14:56:51 +08:00
eb9b781fd3 Merge branch 'puzzle_edge_w'
# Conflicts:
#	src/main/java/com/ycwl/basic/config/WebMvcConfig.java
2026-01-05 11:58:56 +08:00
8d3dae32f3 feat(task): 添加版本校验和任务重分配功能
- 实现版本号比较方法,支持版本号大小判断
- 添加客户端版本校验逻辑,防止低版本上报覆盖高版本缓存
- 增加任务重分配功能,在更新旧任务时解除任务分配
- 修复worker状态处理中的版本冲突问题
2026-01-05 11:54:07 +08:00
43775f550b refactor(clickhouse): 修复日期格式化器线程安全问题
- 移除静态 SimpleDateFormat 实例,避免线程安全问题
- 添加上海时区配置确保日期格式化一致性
- 创建新的日期和日期时间格式化器方法
- 修改格式化方法使用新创建的格式化器实例
- 更新每日扫描统计查询中的日期格式化逻辑
2026-01-04 14:47:37 +08:00
24f72091b3 fix(stats): 修复景点人脸识别统计数据查询逻辑
- 修正了人脸上传统计查询中景点ID的过滤方式,从子查询改为直接解析params字段
- 移除了应用统计服务中的过期缓存逻辑
- 修复了任务完成用户统计的表关联错误,从task表改为member_video表进行统计
2026-01-04 14:43:01 +08:00
cc62fb4c18 refactor(clickhouse): 优化统计查询SQL性能和代码结构
- 提取进入景区trace_id子查询逻辑到独立方法appendEnterScenicTraceIdSubQuery
- 将count函数替换为uniqExact以提高去重统计性能
- 优化视频预览统计查询,使用WITH子句提取JSON字段减少重复计算
- 简化经纪人ID列表查询,移除不必要的子查询包装
- 修复每日扫码统计查询的时间范围过滤条件
- 优化按小时和按日期的扫码会员图表查询,使用ClickHouse内置时间函数
- 在子查询中添加时间范围过滤以减少数据扫描量
2026-01-04 13:53:37 +08:00
d1962ed615 refactor(clickhouse): 将统计数据查询从 MyBatis 迁移到 JDBC 模板
- 移除 ClickHouseStatsMapper 接口及 XML 映射文件
- 使用 NamedParameterJdbcTemplate 替代 MyBatis 实现数据查询
- 添加日期格式化工具类处理 ClickHouse 时间格式
- 重构所有统计查询方法使用原生 SQL 字符串构建
- 添加 MySQL 主数据源配置确保多数据源正确配置
- 升级 ClickHouse JDBC 驱动版本到 0.8.5
- 解决 0.6.x 版本参数绑定问题通过手动 SQL 构建
- 保持原有查询逻辑不变仅改变实现方式
2026-01-04 13:17:01 +08:00
e1023b6ea8 refactor(stats): 移除统计追踪模块相关代码
- 删除 StatsBiz 业务类
- 移除 TraceController 控制器及其接口实现
- 删除 AddTraceReq 数据传输对象
- 移除 StatsEntity 和 StatsRecordEntity 实体类
- 移除 StatsInterceptor 拦截器
- 删除 StatsMapper 和 StatsRecordMapper 数据访问接口
- 移除 StatsService 服务接口及 StatsServiceImpl 实现类
- 删除 StatsUtil 工具类
2026-01-04 12:16:01 +08:00
aec5e57df7 feat(database): 迁移统计数据查询到ClickHouse
- 添加ClickHouse数据源配置和相关依赖
- 实现ClickHouse统计查询服务和MySQL兜底方案
- 新增扫码统计、订单统计等数据查询接口
- 重构分销员数据统计逻辑,整合MySQL和ClickHouse数据源
- 更新应用配置文件以支持ClickHouse启用开关
- 修改分布式任务统计以支持跨库查询场景
2026-01-04 10:34:17 +08:00
52ce26e630 feat(puzzle): 添加拼图边缘渲染功能
- 集成 PuzzleEdgeWorkerIpInterceptor 拦截器进行 IP 校验
- 添加 PuzzleEdgeWorkerSecurityProperties 配置类
- 创建 PuzzleEdgeRenderTaskController 提供边缘渲染接口
- 添加多种 DTO 类用于边缘渲染任务数据传输
- 创建 PuzzleEdgeRenderTaskEntity 实体和 Mapper 接口
- 实现 PuzzleEdgeRenderTaskService 核心服务逻辑
- 重构 PuzzleGenerateServiceImpl 使用边缘渲染服务
- 移除原有的线程池执行器和同步渲染逻辑
- 添加定时任务处理渲染超时和重试机制
- 实现自动打印队列添加功能
2026-01-03 23:47:37 +08:00
32297dc29c refactor(printer): 优化人脸素材查询逻辑
- 移除不必要的MemberSourceEntity和相关Repository依赖
- 将数据查询逻辑从Repository层迁移到Mapper层
- 添加type参数支持素材类型过滤
- 修复方法注释中的人脸ID描述错误
- 直接返回SourceEntity列表避免额外的转换操作
2026-01-03 23:46:58 +08:00
21d8c56e82 feat(puzzle): 添加拼图模块缓存仓库并集成缓存功能
- 新增 PuzzleRepository 缓存仓库类,提供模板和元素的 Redis 缓存功能
- 实现模板按 ID 和编码的双向缓存,减少数据库查询压力
- 实现元素列表按模板 ID 缓存,避免重复查询
- 在模板服务中集成缓存,更新和删除时自动清除相关缓存
- 在生成服务中使用缓存读取模板和元素数据
- 添加缓存过期机制,设置 24 小时自动过期
- 实现批量缓存清除功能,支持按模式删除缓存
2026-01-01 21:39:43 +08:00
f8374519c3 feat(puzzle): 添加拼图生成异步处理能力
- 移除 @RequiredArgsConstructor 注解,改用手动构造函数注入
- 添加 ThreadPoolExecutor 实现拼图生成异步处理
- 新增 generateAsync 方法支持异步生成拼图
- 新增 generateSync 方法支持同步生成拼图
- 重构核心生成逻辑为 doGenerateInternal 方法供同步异步共用
- 在 FaceMatchingOrchestrator 中优化拼图模板生成逻辑
- 支持根据场景选择同步或异步生成模式
- 添加线程池队列大小监控和日志记录
2026-01-01 21:26:34 +08:00
44f5008fd1 refactor(task): 优化重复任务处理逻辑
- 移除旧任务更新逻辑,简化重复任务处理流程
- 替换视频查询方法调用,使用新的存储库方法
- 保持任务缓存清除功能
- 简化日志输出信息
2026-01-01 19:55:33 +08:00
6e0ebcd1bd fix(puzzle): 优化拼图生成失败日志记录
- 修改日志记录格式,添加异常消息详情
- 保持错误响应信息的一致性
2026-01-01 19:43:13 +08:00
5caf9a0ebf refactor(face): 重构人脸服务接口和实现
- 修改 getById 方法返回类型为 FaceEntity 并直接调用仓库层
- 移除删除人脸时的用户权限检查逻辑
- 删除 contentListUseDefaultFace 方法的实现
- 从服务接口中移除 contentListUseDefaultFace 方法定义
2026-01-01 19:40:45 +08:00
06bc2c2020 refactor(scenic): 移除景区移动端控制器和服务中的分页查询功能
- 删除 AppScenicController 中的 pageQuery 接口方法
- 删除 AppScenicController 中的 deviceCountByScenicId 接口方法
- 删除 AppScenicController 中的 scenicListByLnLa 接口方法
- 从 AppScenicService 接口中移除 pageQuery 方法定义
- 从 AppScenicService 接口中移除 deviceCountByScenicId 方法定义
- 从 AppScenicService 接口中移除 scenicListByLnLa 方法定义
- 完全移除 AppScenicController 类文件
- 简化 AppScenicServiceImpl 中
2026-01-01 19:32:49 +08:00
f1a2958251 feat(notification): 添加微信订阅消息配置管理及幂等授权功能
- 新增微信订阅消息配置管理控制器,支持模板、场景、事件映射配置
- 实现用户通知授权服务的幂等控制,避免前端重试导致授权次数虚增
- 添加微信订阅消息发送日志记录,用于幂等与排障
- 新增视频生成完成时的订阅消息触发功能
- 实现场景模板查询接口,返回用户授权余额信息
- 添加模板V2相关数据表映射器和实体类
- 集成微信订阅消息触发服务到任务完成流程中
2026-01-01 17:53:59 +08:00
81dc2f1b86 feat(printer): 添加景区ID参数并优化用户照片打印去重逻辑
- 在价格计算请求中添加景区ID参数
- 实现用户照片按sourceId去重机制,避免重复添加相同照片
- 查询用户在景区的已有打印记录用于去重判断
- 优化普通照片打印商品项,添加设备ID属性信息
- 过滤无效数据并去重后生成设备ID属性列表
2025-12-31 23:37:00 +08:00
41e90bab9c fix(task): 修复任务重复处理中的日志和更新逻辑
- 移除了创建任务日志中的敏感参数信息
- 更新重复任务日志以包含任务ID信息
- 移除了workerId重置逻辑,改为显式清除方法
- 修复TaskMapper中status字段的SQL语法问题
- 优化了任务参数更新的处理流程
2025-12-31 20:36:31 +08:00
b4628bd3e8 refactor(task): 优化重复任务处理逻辑
- 修复重复任务时直接使用旧任务ID的问题
- 实现重复任务的更新机制:重置workerId为空,status为0
- 添加taskParams的更新功能
- 集成任务缓存清理机制
- 修正订单购买状态检查的参数传递
2025-12-31 20:03:58 +08:00
cfb4284b7c refactor(video): 优化视频片段获取任务的设备配对处理
- 添加 Caffeine 缓存优化景区设备配对关系查询性能
- 实现设备配对关系缓存机制,避免重复数据库查询
- 重构线程安全的回调调用逻辑,使用 compareAndSet 保证原子性
- 添加调试日志的条件判断,减少不必要的日志输出
- 优化任务执行流程,调整线程池关闭和资源清理逻辑
- 实现设备配对关系加载方法,返回不可变Map提高安全性
2025-12-31 19:50:44 +08:00
5a61432dc9 refactor(orchestrator): 优化人脸匹配拼图模板生成逻辑
- 引入线程同步机制确保打印机场景下拼图模板生成完成
- 修改 asyncGeneratePuzzleTemplate 方法返回 Thread 对象便于控制
- 使用虚拟线程池优化拼图模板并发生成性能
- 简化原子计数器和异步任务相关代码实现
- 添加线程 join 等待确保关键场景执行顺序
- 修复方法返回值类型和资源管理相关问题
2025-12-31 19:50:18 +08:00
91160a1adb fix(task): 修复任务重复创建和空指针问题
- 在原位替换模式下设置taskParams为null,避免按参数匹配
- 添加isReuseOldTask标识判断是否复用旧任务
- 复用旧任务时执行更新操作而非新增操作
- 添加member和item空值检查,防止空指针异常
- 优化日志记录,提供更准确的操作信息
2025-12-30 18:12:29 +08:00
991a8b10e3 fix(printer): 解决图片类型设置逻辑问题
- 添加source为空时的图片类型判断逻辑
- 当source为空时将图片类型设置为PUZZLE
- 保持原有source不为空时的IPC类型设置逻辑
- 确保PHONE类型的设置逻辑不受影响
2025-12-30 17:49:39 +08:00
ab1e8cf7ef fix(printer): 解决图片类型设置逻辑问题
- 添加source为空时的图片类型判断逻辑
- 当source为空时将图片类型设置为PUZZLE
- 保持原有source不为空时的IPC类型设置逻辑
- 确保PHONE类型的设置逻辑不受影响
2025-12-30 17:34:30 +08:00
5d5643e7d7 feat(pricing): 新增照片打印SKU及价格计算逻辑
- 添加 PHOTO_PRINT_MU 和 PHOTO_PRINT_FX 枚举类型定义
- 实现手机照片打印和特效照片打印的基础价格计算(单价×数量)
- 支持景区特定配置的价格计算逻辑
- 验证新SKU与现有 PHOTO_PRINT 的行为一致性
- 添加相关单元测试确保价格计算准确性
2025-11-17 08:53:08 +08:00
dc4091e058 feat(pricing): 新增升单检测功能- 添加升单检测API端点 /api/pricing/upgrade-check
- 实现 `checkUpgrade` 核心方法,用于检测已购与待购商品组合优惠
- 支持一口价和打包优惠的综合评估逻辑- 提供详细的请求参数与响应结果结构定义
- 更新文档说明升单检测的业务价值与使用场景- 补充关键架构变更记录与兼容性注意事项
2025-10-11 21:09:36 +08:00
a5c815b6ed feat(pricing): 新增升单检测功能
- 添加升单检测接口和相关 DTO 类
- 实现升单检测逻辑,包括价格汇总、一口价评估和打包优惠评估
- 优化商品列表复制和规范化处理
- 新增 IBundleDiscountService 依赖
2025-09-18 19:51:13 +08:00
303 changed files with 17231 additions and 7168 deletions

3
.gitignore vendored
View File

@@ -1,7 +1,8 @@
.idea/ .idea/
logs/ logs/
target/ target/
.serena .*
.claude .claude
.vscode .vscode
*.jpg *.jpg
!.gitignore

View File

@@ -303,6 +303,14 @@
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
<version>5.4.0</version> <version>5.4.0</version>
</dependency> </dependency>
<!-- ClickHouse JDBC Driver -->
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.8.5</version>
<classifier>all</classifier>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.biz; package com.ycwl.basic.biz;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import com.ycwl.basic.clickhouse.service.StatsQueryService;
import com.ycwl.basic.mapper.BrokerMapper; import com.ycwl.basic.mapper.BrokerMapper;
import com.ycwl.basic.mapper.BrokerRecordMapper; import com.ycwl.basic.mapper.BrokerRecordMapper;
import com.ycwl.basic.mapper.StatisticsMapper; import com.ycwl.basic.mapper.StatisticsMapper;
@@ -34,7 +35,7 @@ public class BrokerBiz {
@Autowired @Autowired
private ScenicRepository scenicRepository; private ScenicRepository scenicRepository;
@Autowired @Autowired
private StatisticsMapper statisticsMapper; private StatsQueryService statsQueryService;
public void processOrder(Long orderId) { public void processOrder(Long orderId) {
log.info("开始处理订单分佣,订单ID:{}", orderId); log.info("开始处理订单分佣,订单ID:{}", orderId);
@@ -52,7 +53,7 @@ public class BrokerBiz {
if (scenicConfig.getInteger("sample_store_day") != null) { if (scenicConfig.getInteger("sample_store_day") != null) {
expireDay = scenicConfig.getInteger("sample_store_day"); expireDay = scenicConfig.getInteger("sample_store_day");
} }
List<Long> brokerIdList = statisticsMapper.getBrokerIdListForUser(order.getMemberId(), DateUtil.offsetDay(DateUtil.beginOfDay(order.getCreateAt()), -expireDay), order.getCreateAt()); List<Long> brokerIdList = statsQueryService.getBrokerIdListForUser(order.getMemberId(), DateUtil.offsetDay(DateUtil.beginOfDay(order.getCreateAt()), -expireDay), order.getCreateAt());
if (brokerIdList == null || brokerIdList.isEmpty()) { if (brokerIdList == null || brokerIdList.isEmpty()) {
log.info("用户与推客无关,订单ID:{}", orderId); log.info("用户与推客无关,订单ID:{}", orderId);
return; return;

View File

@@ -42,6 +42,13 @@ public class FaceStatusManager {
*/ */
private final Cache<String, Integer> templateRenderCache; private final Cache<String, Integer> templateRenderCache;
/**
* 拼图素材版本缓存
* 键:faceId:puzzleTemplateId -> 当时的图片源数量
* 用于判断拼图模板的素材是否发生变化,避免重复生成
*/
private final Cache<String, Integer> puzzleSourceVersionCache;
@Autowired @Autowired
private TaskMapper taskMapper; private TaskMapper taskMapper;
@@ -61,6 +68,11 @@ public class FaceStatusManager {
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS) .expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
.maximumSize(10000) .maximumSize(10000)
.build(); .build();
this.puzzleSourceVersionCache = Caffeine.newBuilder()
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
} }
// ==================== 切片状态相关方法 ==================== // ==================== 切片状态相关方法 ====================
@@ -293,4 +305,80 @@ public class FaceStatusManager {
log.debug("批量删除模板渲染状态缓存: faceId={}, count={}", faceId, count); log.debug("批量删除模板渲染状态缓存: faceId={}, count={}", faceId, count);
} }
} }
// ==================== 拼图素材版本相关方法 ====================
/**
* 标记拼图素材版本(记录当前的图片源数量)
* 在拼图生成成功后调用,用于后续判断素材是否变化
*
* @param faceId 人脸ID
* @param puzzleTemplateId 拼图模板ID(全局唯一)
* @param sourceCount 当前的图片源数量
*/
public void markPuzzleSourceVersion(Long faceId, Long puzzleTemplateId, int sourceCount) {
if (faceId == null || puzzleTemplateId == null) {
log.warn("标记拼图素材版本参数为空: faceId={}, puzzleTemplateId={}", faceId, puzzleTemplateId);
return;
}
String key = faceId + ":" + puzzleTemplateId;
puzzleSourceVersionCache.put(key, sourceCount);
log.debug("标记拼图素材版本: faceId={}, puzzleTemplateId={}, sourceCount={}", faceId, puzzleTemplateId, sourceCount);
}
/**
* 判断拼图素材是否发生变化
* 通过比较当前的图片源数量与缓存中记录的数量
*
* @param faceId 人脸ID
* @param puzzleTemplateId 拼图模板ID(全局唯一)
* @param currentSourceCount 当前的图片源数量
* @return true=素材已变化(需要重新生成),false=素材未变化(可以跳过生成)
*/
public boolean isPuzzleSourceChanged(Long faceId, Long puzzleTemplateId, int currentSourceCount) {
if (faceId == null || puzzleTemplateId == null) {
log.warn("判断拼图素材变化参数为空: faceId={}, puzzleTemplateId={}", faceId, puzzleTemplateId);
return true; // 参数不合法时默认认为有变化
}
String key = faceId + ":" + puzzleTemplateId;
Integer cachedCount = puzzleSourceVersionCache.getIfPresent(key);
if (cachedCount == null) {
// 缓存不存在,认为有变化(首次生成或缓存过期)
log.debug("拼图素材版本缓存不存在,需要生成: faceId={}, puzzleTemplateId={}", faceId, puzzleTemplateId);
return true;
}
boolean changed = !cachedCount.equals(currentSourceCount);
if (changed) {
log.debug("拼图素材已变化: faceId={}, puzzleTemplateId={}, cachedCount={}, currentCount={}",
faceId, puzzleTemplateId, cachedCount, currentSourceCount);
} else {
log.debug("拼图素材未变化,可跳过生成: faceId={}, puzzleTemplateId={}, sourceCount={}",
faceId, puzzleTemplateId, currentSourceCount);
}
return changed;
}
/**
* 使指定人脸的所有拼图素材版本缓存失效
* 当人脸的图片关联发生变化时调用(如人脸匹配后新增了关联)
*
* @param faceId 人脸ID
*/
public void invalidatePuzzleSourceVersion(Long faceId) {
if (faceId == null) {
return;
}
String prefix = faceId + ":";
long count = puzzleSourceVersionCache.asMap().keySet().stream()
.filter(key -> key.startsWith(prefix))
.peek(puzzleSourceVersionCache::invalidate)
.count();
if (count > 0) {
log.debug("批量使拼图素材版本缓存失效: faceId={}, count={}", faceId, count);
}
}
} }

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.biz; package com.ycwl.basic.biz;
import com.ycwl.basic.clickhouse.service.StatsQueryService;
import com.ycwl.basic.enums.StatisticEnum; import com.ycwl.basic.enums.StatisticEnum;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager; import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.OrderMapper; import com.ycwl.basic.mapper.OrderMapper;
@@ -66,8 +67,10 @@ public class OrderBiz {
private PrinterService printerService; private PrinterService printerService;
@Autowired @Autowired
private IPriceCalculationService iPriceCalculationService; private IPriceCalculationService iPriceCalculationService;
@Autowired
private StatsQueryService statsQueryService;
public PriceObj queryPrice(Long scenicId, int goodsType, Long goodsId) { public PriceObj queryPrice(Long scenicId, Long memberId, int goodsType, Long goodsId) {
PriceObj priceObj = new PriceObj(); PriceObj priceObj = new PriceObj();
priceObj.setGoodsType(goodsType); priceObj.setGoodsType(goodsType);
priceObj.setGoodsId(goodsId); priceObj.setGoodsId(goodsId);
@@ -99,8 +102,10 @@ public class OrderBiz {
vlogProductItem.setQuantity(videoTaskRepository.getTaskLensNum(video.getTaskId())); vlogProductItem.setQuantity(videoTaskRepository.getTaskLensNum(video.getTaskId()));
vlogProductItem.setScenicId(scenicId.toString()); vlogProductItem.setScenicId(scenicId.toString());
vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem)); vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem));
vlogCalculationRequest.setUserId(memberId);
vlogCalculationRequest.setFaceId(priceObj.getFaceId()); vlogCalculationRequest.setFaceId(priceObj.getFaceId());
vlogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠 vlogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
vlogCalculationRequest.setAutoUseCoupon(true);
PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest); PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest);
priceObj.setPrice(vlogCalculationResult.getFinalAmount()); priceObj.setPrice(vlogCalculationResult.getFinalAmount());
priceObj.setSlashPrice(vlogCalculationResult.getOriginalAmount()); priceObj.setSlashPrice(vlogCalculationResult.getOriginalAmount());
@@ -120,13 +125,33 @@ public class OrderBiz {
if (face != null) { if (face != null) {
calculationRequest.setUserId(face.getMemberId()); calculationRequest.setUserId(face.getMemberId());
} }
calculationRequest.setUserId(memberId);
calculationRequest.setFaceId(goodsId); calculationRequest.setFaceId(goodsId);
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠 calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
calculationRequest.setAutoUseCoupon(true);
PriceCalculationResult priceCalculationResult = iPriceCalculationService.calculatePrice(calculationRequest); PriceCalculationResult priceCalculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
priceObj.setPrice(priceCalculationResult.getFinalAmount()); priceObj.setPrice(priceCalculationResult.getFinalAmount());
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount()); priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
priceObj.setFaceId(goodsId); priceObj.setFaceId(goodsId);
break; break;
case 5:
PriceCalculationRequest plogCalculationRequest = new PriceCalculationRequest();
ProductItem plogProductItem = new ProductItem();
plogProductItem.setProductType(ProductType.PHOTO_LOG);
plogProductItem.setProductId(scenicId.toString());
plogProductItem.setPurchaseCount(1);
plogProductItem.setScenicId(scenicId.toString());
plogCalculationRequest.setProducts(Collections.singletonList(plogProductItem));
plogCalculationRequest.setUserId(memberId);
plogCalculationRequest.setFaceId(goodsId);
plogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
plogCalculationRequest.setAutoUseCoupon(true);
PriceCalculationResult plogPriceCalculationResult = iPriceCalculationService.calculatePrice(plogCalculationRequest);
priceObj.setPrice(plogPriceCalculationResult.getFinalAmount());
priceObj.setSlashPrice(plogPriceCalculationResult.getOriginalAmount());
priceObj.setFaceId(goodsId);
priceObj.setScenicId(scenicId);
break;
case 13: case 13:
PriceCalculationRequest aiCamCalculationRequest = new PriceCalculationRequest(); PriceCalculationRequest aiCamCalculationRequest = new PriceCalculationRequest();
ProductItem aiCamProductItem = new ProductItem(); ProductItem aiCamProductItem = new ProductItem();
@@ -135,7 +160,10 @@ public class OrderBiz {
aiCamProductItem.setPurchaseCount(1); aiCamProductItem.setPurchaseCount(1);
aiCamProductItem.setScenicId(scenicId.toString()); aiCamProductItem.setScenicId(scenicId.toString());
aiCamCalculationRequest.setProducts(Collections.singletonList(aiCamProductItem)); aiCamCalculationRequest.setProducts(Collections.singletonList(aiCamProductItem));
aiCamCalculationRequest.setUserId(memberId);
aiCamCalculationRequest.setFaceId(goodsId);
aiCamCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠 aiCamCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
aiCamCalculationRequest.setAutoUseCoupon(true);
PriceCalculationResult aiCamPriceCalculationResult = iPriceCalculationService.calculatePrice(aiCamCalculationRequest); PriceCalculationResult aiCamPriceCalculationResult = iPriceCalculationService.calculatePrice(aiCamCalculationRequest);
priceObj.setPrice(aiCamPriceCalculationResult.getFinalAmount()); priceObj.setPrice(aiCamPriceCalculationResult.getFinalAmount());
priceObj.setSlashPrice(aiCamPriceCalculationResult.getOriginalAmount()); priceObj.setSlashPrice(aiCamPriceCalculationResult.getOriginalAmount());
@@ -190,7 +218,7 @@ public class OrderBiz {
} }
} }
} }
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId); PriceObj priceObj = queryPrice(scenicId, memberId, goodsType, goodsId);
if (priceObj == null) { if (priceObj == null) {
return respVO; return respVO;
} }
@@ -229,7 +257,7 @@ public class OrderBiz {
orderRepository.clearOrderCache(orderId); // 更新完了,清理下 orderRepository.clearOrderCache(orderId); // 更新完了,清理下
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq(); StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
statisticsRecordAddReq.setMemberId(order.getMemberId()); statisticsRecordAddReq.setMemberId(order.getMemberId());
Long enterType = statisticsMapper.getUserRecentEnterType(order.getMemberId(), order.getCreateAt()); Long enterType = statsQueryService.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());
if(!Long.valueOf(1014).equals(enterType)){// if(!Long.valueOf(1014).equals(enterType)){//
statisticsRecordAddReq.setType(StatisticEnum.ON_SITE_PAYMENT.code); statisticsRecordAddReq.setType(StatisticEnum.ON_SITE_PAYMENT.code);
}else { }else {

View File

@@ -1,8 +1,6 @@
package com.ycwl.basic.biz; package com.ycwl.basic.biz;
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO; import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
import com.ycwl.basic.model.pc.face.entity.FaceEntity; import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.order.entity.OrderEntity; import com.ycwl.basic.model.pc.order.entity.OrderEntity;
import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity; import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity;
@@ -15,6 +13,7 @@ import com.ycwl.basic.product.capability.ProductTypeCapability;
import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService; import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity; import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper; import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.repository.FaceRepository; import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository; import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.OrderRepository; import com.ycwl.basic.repository.OrderRepository;
@@ -50,6 +49,8 @@ public class PriceBiz {
@Autowired @Autowired
private PuzzleTemplateMapper puzzleTemplateMapper; private PuzzleTemplateMapper puzzleTemplateMapper;
@Autowired @Autowired
private PuzzleRepository puzzleRepository;
@Autowired
private IProductTypeCapabilityManagementService productTypeCapabilityManagementService; private IProductTypeCapabilityManagementService productTypeCapabilityManagementService;
@Autowired @Autowired
private OrderRepository orderRepository; private OrderRepository orderRepository;
@@ -74,8 +75,8 @@ public class PriceBiz {
goodsList.add(new GoodsListRespVO(2L, "照片集", 2)); goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
} }
} }
// 拼图 // 拼图(使用缓存)
puzzleTemplateMapper.list(scenicId, null, 1).forEach(puzzleTemplate -> { puzzleRepository.listTemplateByScenic(scenicId).forEach(puzzleTemplate -> {
GoodsListRespVO goods = new GoodsListRespVO(); GoodsListRespVO goods = new GoodsListRespVO();
goods.setGoodsId(puzzleTemplate.getId()); goods.setGoodsId(puzzleTemplate.getId());
goods.setGoodsName(puzzleTemplate.getName()); goods.setGoodsName(puzzleTemplate.getName());
@@ -131,7 +132,7 @@ public class PriceBiz {
case "PHOTO_LOG": case "PHOTO_LOG":
// 从 template 表查询pLog模板 // 从 template 表查询pLog模板
List<PuzzleTemplateEntity> puzzleList = puzzleTemplateMapper.list(scenicId, null, null); List<PuzzleTemplateEntity> puzzleList = puzzleRepository.listTemplateByScenic(scenicId);
puzzleList.stream() puzzleList.stream()
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType)) .map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
.forEach(goodsList::add); .forEach(goodsList::add);

View File

@@ -0,0 +1,104 @@
package com.ycwl.basic.clickhouse.service;
import com.ycwl.basic.model.mobile.statistic.req.CommonQueryReq;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
/**
* 统计数据查询服务接口
* 用于抽象 t_stats 和 t_stats_record 表的查询
* 支持 MySQL 和 ClickHouse 两种实现
*/
public interface StatsQueryService {
/**
* 统计预览视频人数
*/
Integer countPreviewVideoOfMember(CommonQueryReq query);
/**
* 统计扫码访问人数
*/
Integer countScanCodeOfMember(CommonQueryReq query);
/**
* 统计推送订阅人数
*/
Integer countPushOfMember(CommonQueryReq query);
/**
* 统计上传头像人数
*/
Integer countUploadFaceOfMember(CommonQueryReq query);
/**
* 统计生成视频人数
*/
Integer countCompleteVideoOfMember(CommonQueryReq query);
/**
* 统计生成视频条数
*/
Integer countCompleteOfVideo(CommonQueryReq query);
/**
* 统计总访问人数
*/
Integer countTotalVisitorOfMember(CommonQueryReq query);
/**
* 统计预览视频条数
*/
Integer countPreviewOfVideo(CommonQueryReq query);
/**
* 获取用户分销员 ID 列表
*/
List<Long> getBrokerIdListForUser(Long memberId, Date startTime, Date endTime);
/**
* 获取用户最近进入类型
*/
Long getUserRecentEnterType(Long memberId, Date endTime);
/**
* 获取用户项目 ID 列表
*/
List<Long> getProjectIdListForUser(Long memberId, Date startTime, Date endTime);
/**
* 统计分销员扫码次数
*/
Integer countBrokerScanCount(Long brokerId);
/**
* 按日期统计分销员扫码数据
*/
List<HashMap<String, Object>> getDailyScanStats(Long brokerId, Date startTime, Date endTime);
/**
* 按小时统计扫码人数(仅返回统计数据,不含订单)
* 返回格式: [{t: "MM-dd HH", count: "xxx"}, ...]
*/
List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query);
/**
* 按日期统计扫码人数(仅返回统计数据,不含订单)
* 返回格式: [{t: "MM-dd", count: "xxx"}, ...]
*/
List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query);
/**
* 按小时统计访问打印样片页面人数
* 返回格式: [{t: "MM-dd HH", count: "xxx"}, ...]
*/
List<HashMap<String, String>> printerFromSampleChartByHour(CommonQueryReq query);
/**
* 按日期统计访问打印样片页面人数
* 返回格式: [{t: "MM-dd", count: "xxx"}, ...]
*/
List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query);
}

View File

@@ -0,0 +1,536 @@
package com.ycwl.basic.clickhouse.service.impl;
import com.ycwl.basic.clickhouse.service.StatsQueryService;
import com.ycwl.basic.mapper.TaskMapper;
import com.ycwl.basic.model.mobile.statistic.req.CommonQueryReq;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
/**
* ClickHouse 统计数据查询服务实现
* 当 clickhouse.enabled=true 时启用
*
* 注意:ClickHouse JDBC 驱动 0.6.x 对参数绑定支持有问题,
* 因此使用字符串格式化方式构建 SQL(参数均为内部生成的数值或日期,无 SQL 注入风险)
*/
@Slf4j
@Service
@ConditionalOnProperty(prefix = "clickhouse", name = "enabled", havingValue = "true")
public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
private static final TimeZone CLICKHOUSE_TIMEZONE = TimeZone.getTimeZone("Asia/Shanghai");
/**
* 创建日期格式化器(SimpleDateFormat 非线程安全,每次创建新实例)
*/
private SimpleDateFormat createDateFormat() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setTimeZone(CLICKHOUSE_TIMEZONE);
return sdf;
}
/**
* 创建日期时间格式化器
*/
private SimpleDateFormat createDateTimeFormat() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
sdf.setTimeZone(CLICKHOUSE_TIMEZONE);
return sdf;
}
@Autowired
@Qualifier("clickHouseJdbcTemplate")
private NamedParameterJdbcTemplate namedJdbcTemplate;
private JdbcTemplate jdbcTemplate;
@Autowired
private TaskMapper taskMapper;
private JdbcTemplate getJdbcTemplate() {
if (jdbcTemplate == null) {
jdbcTemplate = namedJdbcTemplate.getJdbcTemplate();
}
return jdbcTemplate;
}
/**
* 格式化日期时间为 ClickHouse 可识别的字符串
*/
private String formatDateTime(Date date) {
return date != null ? "'" + createDateTimeFormat().format(date) + "'" : null;
}
/**
* 格式化日期为 ClickHouse 可识别的字符串
*/
private String formatDate(Date date) {
return date != null ? "'" + createDateFormat().format(date) + "'" : null;
}
/**
* 拼接“进入景区”的 trace_id 子查询。
* <p>
* ClickHouse 上 t_stats_record 往往按时间分区/排序;给子查询补充时间范围可显著减少扫描量。
*/
private void appendEnterScenicTraceIdSubQuery(StringBuilder sql, Long scenicId, Date startTime, Date endTime) {
sql.append("SELECT DISTINCT trace_id FROM t_stats_record ");
sql.append("WHERE action = 'ENTER_SCENIC' ");
if (scenicId != null) {
sql.append("AND identifier = '").append(scenicId).append("' ");
}
if (startTime != null) {
sql.append("AND create_time >= ").append(formatDateTime(startTime)).append(" ");
}
if (endTime != null) {
sql.append("AND create_time <= ").append(formatDateTime(endTime)).append(" ");
}
}
@Override
public Integer countPreviewVideoOfMember(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT toInt32(uniqExact(s.member_id)) AS count ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.action = 'LOAD' ");
sql.append("AND r.identifier = 'pages/videoSynthesis/buy' ");
sql.append("AND r.trace_id IN (");
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
sql.append(") ");
sql.append("AND JSONExtractString(r.params, 'share') = '' ");
if (query.getStartTime() != null) {
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
}
if (query.getEndTime() != null) {
sql.append("AND r.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
}
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
}
@Override
public Integer countScanCodeOfMember(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT toInt32(uniqExact(s.member_id)) AS count ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.trace_id IN (");
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
sql.append(") ");
sql.append("AND r.action = 'LAUNCH' ");
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
if (query.getStartTime() != null) {
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
}
if (query.getEndTime() != null) {
sql.append("AND s.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
}
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
}
@Override
public Integer countPushOfMember(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT toInt32(uniqExact(s.member_id)) AS count ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.trace_id IN (");
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
sql.append(") ");
sql.append("AND r.action = 'PERM_REQ' ");
sql.append("AND r.identifier = 'NOTIFY' ");
if (query.getStartTime() != null) {
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
}
if (query.getEndTime() != null) {
sql.append("AND r.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
}
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
}
@Override
public Integer countUploadFaceOfMember(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT toInt32(uniqExact(s.member_id)) AS count ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.action = 'FACE_UPLOAD' ");
sql.append("AND r.trace_id IN (");
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
sql.append(") ");
if (query.getStartTime() != null) {
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
}
if (query.getEndTime() != null) {
sql.append("AND s.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
}
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
}
private List<String> listFaceIdsWithUpload(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT DISTINCT r.identifier FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.action = 'FACE_UPLOAD' ");
sql.append("AND r.trace_id IN (");
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
sql.append(") ");
if (query.getStartTime() != null) {
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
}
if (query.getEndTime() != null) {
sql.append("AND s.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
}
return getJdbcTemplate().queryForList(sql.toString(), String.class);
}
@Override
public Integer countCompleteVideoOfMember(CommonQueryReq query) {
List<String> faceIds = listFaceIdsWithUpload(query);
if (faceIds == null || faceIds.isEmpty()) {
return 0;
}
return taskMapper.countCompletedTaskMembersByFaceIds(faceIds);
}
@Override
public Integer countCompleteOfVideo(CommonQueryReq query) {
List<String> faceIds = listFaceIdsWithUpload(query);
if (faceIds == null || faceIds.isEmpty()) {
return 0;
}
return taskMapper.countCompletedTasksByFaceIds(faceIds);
}
@Override
public Integer countTotalVisitorOfMember(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT toInt32(uniqExact(s.member_id)) AS count ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.trace_id IN (");
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
sql.append(") ");
sql.append("AND r.action = 'LAUNCH' ");
if (query.getStartTime() != null) {
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
}
if (query.getEndTime() != null) {
sql.append("AND s.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
}
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
}
@Override
public Integer countPreviewOfVideo(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("WITH JSONExtractString(params, 'id') AS videoId, ");
sql.append(" JSONExtractString(params, 'share') AS share ");
sql.append("SELECT toInt32(uniqExact(videoId)) AS count ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.trace_id IN (");
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
sql.append(") ");
sql.append("AND r.action = 'LOAD' ");
sql.append("AND r.identifier = 'pages/videoSynthesis/buy' ");
sql.append("AND videoId != '' ");
sql.append("AND share = '' ");
if (query.getStartTime() != null) {
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
}
if (query.getEndTime() != null) {
sql.append("AND s.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
}
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
}
@Override
public List<Long> getBrokerIdListForUser(Long memberId, Date startTime, Date endTime) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT toInt64(r.identifier) AS identifier ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.action = 'CODE_SCAN' ");
sql.append(" AND s.member_id = ").append(memberId).append(" ");
if (startTime != null) {
sql.append(" AND r.create_time >= ").append(formatDateTime(startTime)).append(" ");
}
if (endTime != null) {
sql.append(" AND r.create_time <= ").append(formatDateTime(endTime)).append(" ");
}
sql.append("GROUP BY r.identifier ");
sql.append("ORDER BY max(r.create_time) DESC");
return getJdbcTemplate().queryForList(sql.toString(), Long.class);
}
@Override
public Long getUserRecentEnterType(Long memberId, Date endTime) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT JSONExtractInt(r.params, 'scene') AS scene ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.action = 'LAUNCH' ");
sql.append(" AND s.member_id = ").append(memberId).append(" ");
if (endTime != null) {
sql.append(" AND r.create_time <= ").append(formatDateTime(endTime)).append(" ");
}
sql.append("ORDER BY r.create_time DESC LIMIT 1");
try {
return getJdbcTemplate().queryForObject(sql.toString(), Long.class);
} catch (Exception e) {
return null;
}
}
@Override
public List<Long> getProjectIdListForUser(Long memberId, Date startTime, Date endTime) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT toInt64(r.identifier) AS identifier ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE s.member_id = ").append(memberId).append(" ");
sql.append(" AND r.action = 'ENTER_PROJECT' ");
sql.append(" AND r.create_time < ").append(formatDateTime(endTime)).append(" ");
sql.append(" AND r.create_time > ").append(formatDateTime(startTime)).append(" ");
sql.append("ORDER BY r.create_time DESC LIMIT 1");
return getJdbcTemplate().queryForList(sql.toString(), Long.class);
}
@Override
public Integer countBrokerScanCount(Long brokerId) {
String sql = "SELECT count(1) AS count FROM t_stats_record " +
"WHERE action = 'CODE_SCAN' AND identifier = '" + brokerId + "'";
return getJdbcTemplate().queryForObject(sql, Integer.class);
}
@Override
public List<HashMap<String, Object>> getDailyScanStats(Long brokerId, Date startTime, Date endTime) {
SimpleDateFormat dateFormat = createDateFormat();
String startDateStr = dateFormat.format(startTime);
String endDateStr = dateFormat.format(endTime);
String startDateTimeStr = "'" + startDateStr + " 00:00:00'";
String endDateTimeStr = "'" + endDateStr + " 23:59:59'";
String sql = "SELECT toDate(create_time) AS date, count(DISTINCT id) AS scanCount " +
"FROM t_stats_record " +
"WHERE action = 'CODE_SCAN' " +
" AND identifier = '" + brokerId + "' " +
" AND create_time >= " + startDateTimeStr + " " +
" AND create_time <= " + endDateTimeStr + " " +
"GROUP BY toDate(create_time) " +
"ORDER BY toDate(create_time)";
return getJdbcTemplate().query(sql, (rs, rowNum) -> {
HashMap<String, Object> map = new HashMap<>();
map.put("date", rs.getDate("date"));
map.put("scanCount", rs.getLong("scanCount"));
return map;
});
}
@Override
public List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT formatDateTime(toStartOfHour(s.create_time), '%m-%d %H') AS t, ");
sql.append(" uniqExact(s.member_id) AS count ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.trace_id IN (");
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
sql.append(") ");
sql.append("AND r.action = 'LAUNCH' ");
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
sql.append("GROUP BY toStartOfHour(s.create_time) ");
sql.append("ORDER BY toStartOfHour(s.create_time)");
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
HashMap<String, String> map = new HashMap<>();
map.put("t", rs.getString("t"));
map.put("count", rs.getString("count"));
return map;
});
return fillHourSeries(rawData, query.getStartTime(), query.getEndTime());
}
@Override
public List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT formatDateTime(toStartOfDay(s.create_time), '%m-%d') AS t, ");
sql.append(" uniqExact(s.member_id) AS count ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.trace_id IN (");
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
sql.append(") ");
sql.append("AND r.action = 'LAUNCH' ");
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
sql.append("GROUP BY toStartOfDay(s.create_time) ");
sql.append("ORDER BY toStartOfDay(s.create_time)");
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
HashMap<String, String> map = new HashMap<>();
map.put("t", rs.getString("t"));
map.put("count", rs.getString("count"));
return map;
});
return fillDateSeries(rawData, query.getStartTime(), query.getEndTime());
}
@Override
public List<HashMap<String, String>> printerFromSampleChartByHour(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT formatDateTime(toStartOfHour(s.create_time), '%m-%d %H') AS t, ");
sql.append(" uniqExact(s.member_id) AS count ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.action = 'LOAD' ");
sql.append("AND r.identifier = 'pages/printer/hello' ");
if (query.getScenicId() != null) {
sql.append("AND JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
}
if (query.getStartTime() != null) {
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
}
if (query.getEndTime() != null) {
sql.append("AND r.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
}
sql.append("GROUP BY toStartOfHour(s.create_time) ");
sql.append("ORDER BY toStartOfHour(s.create_time)");
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
HashMap<String, String> map = new HashMap<>();
map.put("t", rs.getString("t"));
map.put("count", rs.getString("count"));
return map;
});
return fillHourSeries(rawData, query.getStartTime(), query.getEndTime());
}
@Override
public List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT formatDateTime(toStartOfDay(s.create_time), '%m-%d') AS t, ");
sql.append(" uniqExact(s.member_id) AS count ");
sql.append("FROM t_stats_record r ");
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
sql.append("WHERE r.action = 'LOAD' ");
sql.append("AND r.identifier = 'pages/printer/hello' ");
if (query.getScenicId() != null) {
sql.append("AND JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
}
if (query.getStartTime() != null) {
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
}
if (query.getEndTime() != null) {
sql.append("AND r.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
}
sql.append("GROUP BY toStartOfDay(s.create_time) ");
sql.append("ORDER BY toStartOfDay(s.create_time)");
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
HashMap<String, String> map = new HashMap<>();
map.put("t", rs.getString("t"));
map.put("count", rs.getString("count"));
return map;
});
return fillDateSeries(rawData, query.getStartTime(), query.getEndTime());
}
/**
* 填充小时序列,确保每个小时都有数据(缺失的填充为0)
*/
private List<HashMap<String, String>> fillHourSeries(List<HashMap<String, String>> rawData, Date startTime, Date endTime) {
if (startTime == null || endTime == null) {
return rawData;
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd HH");
LocalDateTime start = startTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().truncatedTo(ChronoUnit.HOURS);
LocalDateTime end = endTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().truncatedTo(ChronoUnit.HOURS);
// 将原始数据转为 Map 以便快速查找
Map<String, String> dataMap = rawData.stream()
.collect(Collectors.toMap(
m -> m.get("t"),
m -> m.get("count"),
(existing, replacement) -> existing
));
List<HashMap<String, String>> result = new ArrayList<>();
LocalDateTime current = start;
while (!current.isAfter(end)) {
String timeKey = current.format(formatter);
HashMap<String, String> item = new HashMap<>();
item.put("t", timeKey);
item.put("count", dataMap.getOrDefault(timeKey, "0"));
result.add(item);
current = current.plusHours(1);
}
return result;
}
/**
* 填充日期序列,确保每天都有数据(缺失的填充为0)
*/
private List<HashMap<String, String>> fillDateSeries(List<HashMap<String, String>> rawData, Date startTime, Date endTime) {
if (startTime == null || endTime == null) {
return rawData;
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd");
LocalDate start = startTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
LocalDate end = endTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
// 将原始数据转为 Map 以便快速查找
Map<String, String> dataMap = rawData.stream()
.collect(Collectors.toMap(
m -> m.get("t"),
m -> m.get("count"),
(existing, replacement) -> existing
));
List<HashMap<String, String>> result = new ArrayList<>();
LocalDate current = start;
while (!current.isAfter(end)) {
String timeKey = current.format(formatter);
HashMap<String, String> item = new HashMap<>();
item.put("t", timeKey);
item.put("count", dataMap.getOrDefault(timeKey, "0"));
result.add(item);
current = current.plusDays(1);
}
return result;
}
}

View File

@@ -0,0 +1,111 @@
package com.ycwl.basic.clickhouse.service.impl;
import com.ycwl.basic.clickhouse.service.StatsQueryService;
import com.ycwl.basic.mapper.StatisticsMapper;
import com.ycwl.basic.model.mobile.statistic.req.CommonQueryReq;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
/**
* MySQL 统计数据查询服务实现
* 当 clickhouse.enabled 未启用时使用此实现(兜底)
*/
@Slf4j
@Service
@ConditionalOnProperty(prefix = "clickhouse", name = "enabled", havingValue = "false", matchIfMissing = true)
public class MySqlStatsQueryServiceImpl implements StatsQueryService {
@Autowired
private StatisticsMapper statisticsMapper;
@Override
public Integer countPreviewVideoOfMember(CommonQueryReq query) {
return statisticsMapper.countPreviewVideoOfMember(query);
}
@Override
public Integer countScanCodeOfMember(CommonQueryReq query) {
return statisticsMapper.countScanCodeOfMember(query);
}
@Override
public Integer countPushOfMember(CommonQueryReq query) {
return statisticsMapper.countPushOfMember(query);
}
@Override
public Integer countUploadFaceOfMember(CommonQueryReq query) {
return statisticsMapper.countUploadFaceOfMember(query);
}
@Override
public Integer countCompleteVideoOfMember(CommonQueryReq query) {
return statisticsMapper.countCompleteVideoOfMember(query);
}
@Override
public Integer countCompleteOfVideo(CommonQueryReq query) {
return statisticsMapper.countCompleteOfVideo(query);
}
@Override
public Integer countTotalVisitorOfMember(CommonQueryReq query) {
return statisticsMapper.countTotalVisitorOfMember(query);
}
@Override
public Integer countPreviewOfVideo(CommonQueryReq query) {
return statisticsMapper.countPreviewOfVideo(query);
}
@Override
public List<Long> getBrokerIdListForUser(Long memberId, Date startTime, Date endTime) {
return statisticsMapper.getBrokerIdListForUser(memberId, startTime, endTime);
}
@Override
public Long getUserRecentEnterType(Long memberId, Date endTime) {
return statisticsMapper.getUserRecentEnterType(memberId, endTime);
}
@Override
public List<Long> getProjectIdListForUser(Long memberId, Date startTime, Date endTime) {
return statisticsMapper.getProjectIdListForUser(memberId, startTime, endTime);
}
@Override
public Integer countBrokerScanCount(Long brokerId) {
return statisticsMapper.countBrokerScanCount(brokerId);
}
@Override
public List<HashMap<String, Object>> getDailyScanStats(Long brokerId, Date startTime, Date endTime) {
return statisticsMapper.getDailyScanStats(brokerId, startTime, endTime);
}
@Override
public List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query) {
return statisticsMapper.scanCodeMemberChartByHour(query);
}
@Override
public List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query) {
return statisticsMapper.scanCodeMemberChartByDate(query);
}
@Override
public List<HashMap<String, String>> printerFromSampleChartByHour(CommonQueryReq query) {
return statisticsMapper.printerFromSampleChartByHour(query);
}
@Override
public List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query) {
return statisticsMapper.printerFromSampleChartByDate(query);
}
}

View File

@@ -0,0 +1,37 @@
package com.ycwl.basic.config;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import javax.sql.DataSource;
/**
* ClickHouse 数据源配置
* 用于 t_stats 和 t_stats_record 表的查询
*
* 使用 NamedParameterJdbcTemplate 而非 MyBatis,以避免干扰 MyBatis-Plus 的自动配置
*/
@Configuration
@ConditionalOnProperty(prefix = "clickhouse", name = "enabled", havingValue = "true")
public class ClickHouseDataSourceConfig {
/**
* ClickHouse 数据源(非 Primary)
*/
@Bean(name = "clickHouseDataSource")
@ConfigurationProperties(prefix = "clickhouse.datasource")
public DataSource clickHouseDataSource() {
return new HikariDataSource();
}
@Bean(name = "clickHouseJdbcTemplate")
public NamedParameterJdbcTemplate clickHouseJdbcTemplate(
@Qualifier("clickHouseDataSource") DataSource dataSource) {
return new NamedParameterJdbcTemplate(dataSource);
}
}

View File

@@ -0,0 +1,41 @@
package com.ycwl.basic.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
/**
* MySQL 主数据源配置
*
* 当 ClickHouse 启用时,需要显式配置 MySQL 数据源并标记为 @Primary,
* 以确保 MyBatis-Plus 和其他组件使用正确的数据源
*/
@Configuration
@ConditionalOnProperty(prefix = "clickhouse", name = "enabled", havingValue = "true")
public class MySqlPrimaryDataSourceConfig {
/**
* MySQL 数据源属性
*/
@Primary
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSourceProperties mysqlDataSourceProperties() {
return new DataSourceProperties();
}
/**
* MySQL 主数据源
* 使用 @Primary 确保这是默认数据源
*/
@Primary
@Bean(name = "dataSource")
public DataSource mysqlDataSource(DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().build();
}
}

View File

@@ -3,7 +3,7 @@ package com.ycwl.basic.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.module.SimpleModule;
import com.ycwl.basic.interceptor.AuthInterceptor; import com.ycwl.basic.interceptor.AuthInterceptor;
import com.ycwl.basic.stats.interceptor.StatsInterceptor; import com.ycwl.basic.puzzle.edge.interceptor.PuzzleEdgeWorkerIpInterceptor;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -25,20 +25,19 @@ import java.util.List;
*/ */
@Configuration @Configuration
public class WebMvcConfig implements WebMvcConfigurer { public class WebMvcConfig implements WebMvcConfigurer {
@Autowired @Autowired
private AuthInterceptor authInterceptor; private AuthInterceptor authInterceptor;
@Autowired @Autowired
private StatsInterceptor statsInterceptor; private PuzzleEdgeWorkerIpInterceptor puzzleEdgeWorkerIpInterceptor;
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(puzzleEdgeWorkerIpInterceptor)
.addPathPatterns("/puzzle/render/v1/**");
registry.addInterceptor(authInterceptor) registry.addInterceptor(authInterceptor)
// 拦截除指定接口外的所有请求,通过判断 注解 来决定是否需要做登录验证 // 拦截除指定接口外的所有请求,通过判断 注解 来决定是否需要做登录验证
.addPathPatterns("/**") .addPathPatterns("/**")
.excludePathPatterns("/swagger-resources/**", "/webjars/**", "/v2/api-docs", "/doc.html/**", "/error", "/"); .excludePathPatterns("/swagger-resources/**", "/webjars/**", "/v2/api-docs", "/doc.html/**", "/error", "/");
registry.addInterceptor(statsInterceptor)
.addPathPatterns("/api/mobile/**");
} }
/** /**

View File

@@ -203,10 +203,17 @@ public class LyCompatibleController {
return response; return response;
} }
List<Map<String, Object>> videoList = collect.get(0).stream().collect(Collectors.groupingBy(ContentPageVO::getTemplateId)) List<Map<String, Object>> videoList = collect.get(0).stream().collect(Collectors.groupingBy(ContentPageVO::getTemplateId))
.values().stream().map(contentPageVOs -> { .values().stream()
ContentPageVO contentPageVO = contentPageVOs.getFirst(); .map(contentPageVOs -> {
Map<String, Object> map = new HashMap<>(); ContentPageVO contentPageVO = contentPageVOs.stream().filter(vo -> vo.getContentId() != null).findFirst().orElse(null);
if (contentPageVO == null) {
return null;
}
VideoEntity videoRespVO = videoRepository.getVideo(contentPageVO.getContentId()); VideoEntity videoRespVO = videoRepository.getVideo(contentPageVO.getContentId());
if (videoRespVO == null) {
return null;
}
Map<String, Object> map = new HashMap<>();
map.put("id", videoRespVO.getId().toString()); map.put("id", videoRespVO.getId().toString());
map.put("task_id", videoRespVO.getTaskId().toString()); map.put("task_id", videoRespVO.getTaskId().toString());
if (videoRespVO.getFaceId() != null) { if (videoRespVO.getFaceId() != null) {
@@ -220,7 +227,7 @@ public class LyCompatibleController {
map.put("title", contentPageVO.getName()); map.put("title", contentPageVO.getName());
map.put("ossurldm", videoRespVO.getVideoUrl()); map.put("ossurldm", videoRespVO.getVideoUrl());
return map; return map;
}).collect(Collectors.toList()); }).filter(java.util.Objects::nonNull).collect(Collectors.toList());
GoodsReqQuery goodsReqQuery = new GoodsReqQuery(); GoodsReqQuery goodsReqQuery = new GoodsReqQuery();
goodsReqQuery.setFaceId(faceVO.getId()); goodsReqQuery.setFaceId(faceVO.getId());
goodsReqQuery.setSourceType(1); goodsReqQuery.setSourceType(1);

View File

@@ -1,47 +0,0 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.model.mobile.coupon.req.ClaimCouponReq;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
import com.ycwl.basic.service.mobile.AppCouponRecordService;
import com.ycwl.basic.utils.ApiResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/mobile/coupon/v1")
public class AppCouponController {
@Autowired
private AppCouponRecordService appCouponRecordService;
/**
* 根据memberId、faceId和type查找优惠券记录
*/
@GetMapping("/record")
public ApiResponse<CouponRecordEntity> getCouponRecords(
@RequestParam Long faceId,
@RequestParam Integer type) {
CouponRecordEntity record = appCouponRecordService.queryByMemberIdAndFaceIdAndType(Long.valueOf(BaseContextHandler.getUserId()), faceId, type);
return ApiResponse.success(record);
}
/**
* 领取优惠券
*/
@PostMapping("/claim")
public ApiResponse<CouponEntity> claimCoupon(@RequestBody ClaimCouponReq request) {
request.setMemberId(Long.valueOf(BaseContextHandler.getUserId()));
try {
CouponEntity coupon = appCouponRecordService.claimCoupon(
request.getMemberId(),
request.getFaceId(),
request.getType()
);
return ApiResponse.success(coupon);
} catch (RuntimeException e) {
return ApiResponse.fail(e.getMessage());
}
}
}

View File

@@ -64,23 +64,17 @@ public class AppFaceController {
} }
@GetMapping("/{faceId}") @GetMapping("/{faceId}")
public ApiResponse<FaceRespVO> getById(@PathVariable("faceId") Long faceId) { public ApiResponse<FaceEntity> getById(@PathVariable("faceId") Long faceId) {
return faceService.getById(faceId); FaceEntity face = faceRepository.getFace(faceId);
return ApiResponse.success(face);
} }
@DeleteMapping("/{faceId}") @DeleteMapping("/{faceId}")
public ApiResponse<String> deleteFace(@PathVariable("faceId") Long faceId) { public ApiResponse<String> deleteFace(@PathVariable("faceId") Long faceId) {
// 添加权限检查:验证当前用户是否拥有该 face
JwtInfo worker = JwtTokenUtil.getWorker();
Long userId = worker.getUserId();
FaceEntity face = faceRepository.getFace(faceId); FaceEntity face = faceRepository.getFace(faceId);
if (face == null) { if (face == null) {
throw new BaseException("人脸数据不存在"); throw new BaseException("人脸数据不存在");
} }
if (!face.getMemberId().equals(userId)) {
throw new BaseException("无权删除此人脸");
}
return faceService.deleteFace(faceId); return faceService.deleteFace(faceId);
} }
@@ -102,9 +96,8 @@ public class AppFaceController {
// 绑定人脸 // 绑定人脸
@PostMapping("/{faceId}/bind") @PostMapping("/{faceId}/bind")
public ApiResponse<String> bind(@PathVariable Long faceId) { public ApiResponse<String> bind(@PathVariable Long faceId) {
JwtInfo worker = JwtTokenUtil.getWorker(); // dummy item
Long userId = worker.getUserId(); faceService.matchFaceId(faceId, true);
faceService.bindFace(faceId, userId);
return ApiResponse.success("OK"); return ApiResponse.success("OK");
} }

View File

@@ -53,12 +53,6 @@ public class AppGoodsController {
return ApiResponse.success(count); return ApiResponse.success(count);
} }
@PostMapping("/sourceGoodsList/preview")
public ApiResponse<List<GoodsUrlVO>> sourceGoodsListPreview(@RequestBody GoodsReqQuery query) {
List<GoodsUrlVO> goodsUrlList = goodsService.sourceGoodsListPreview(query);
return ApiResponse.success(goodsUrlList);
}
@PostMapping("/sourceGoodsList/download") @PostMapping("/sourceGoodsList/download")
public ApiResponse<List<GoodsUrlVO>> sourceGoodsListDownload(@RequestBody GoodsReqQuery query) { public ApiResponse<List<GoodsUrlVO>> sourceGoodsListDownload(@RequestBody GoodsReqQuery query) {
List<GoodsUrlVO> goodsUrlList = goodsService.sourceGoodsListDownload(query); List<GoodsUrlVO> goodsUrlList = goodsService.sourceGoodsListDownload(query);

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.controller.mobile; package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.annotation.IgnoreToken; import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.model.mobile.weChat.DTO.WeChatUserInfoDTO; import com.ycwl.basic.model.mobile.weChat.DTO.WeChatUserInfoDTO;
import com.ycwl.basic.model.mobile.weChat.DTO.WeChatUserInfoUpdateDTO; import com.ycwl.basic.model.mobile.weChat.DTO.WeChatUserInfoUpdateDTO;
import com.ycwl.basic.model.pc.member.resp.MemberRespVO; import com.ycwl.basic.model.pc.member.resp.MemberRespVO;
@@ -67,7 +68,8 @@ public class AppMemberController {
// 修改用户信息 // 修改用户信息
@PostMapping("/update") @PostMapping("/update")
public ApiResponse<?> update(@RequestBody WeChatUserInfoUpdateDTO userInfoUpdateDTO) { public ApiResponse<?> update(@RequestBody WeChatUserInfoUpdateDTO userInfoUpdateDTO) {
return memberService.update(userInfoUpdateDTO); Long userId = Long.parseLong(BaseContextHandler.getUserId());
return memberService.update(userId, userInfoUpdateDTO);
} }
// 新增或修改景区服务通知状态 // 新增或修改景区服务通知状态

View File

@@ -27,6 +27,12 @@ import com.ycwl.basic.order.dto.OrderV2PageRequest;
import com.ycwl.basic.order.dto.PaymentParamsRequest; import com.ycwl.basic.order.dto.PaymentParamsRequest;
import com.ycwl.basic.order.dto.PaymentParamsResponse; import com.ycwl.basic.order.dto.PaymentParamsResponse;
import com.ycwl.basic.order.dto.PaymentCallbackResponse; import com.ycwl.basic.order.dto.PaymentCallbackResponse;
import com.ycwl.basic.order.exception.DuplicatePurchaseException;
import com.ycwl.basic.order.factory.DuplicatePurchaseCheckerFactory;
import com.ycwl.basic.order.strategy.DuplicateCheckContext;
import com.ycwl.basic.order.strategy.IDuplicatePurchaseChecker;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import com.ycwl.basic.product.service.IProductTypeCapabilityService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
@@ -57,10 +63,11 @@ public class AppOrderV2Controller {
private final TemplateRepository templateRepository; private final TemplateRepository templateRepository;
private final VideoRepository videoRepository; private final VideoRepository videoRepository;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final IProductTypeCapabilityService productTypeCapabilityService;
private final DuplicatePurchaseCheckerFactory duplicatePurchaseCheckerFactory;
/** /**
* 移动端价格计算 * 移动端价格计算
* 包含权限验证:验证人脸所属景区与当前用户匹配
* 集成Redis缓存机制,提升查询性能 * 集成Redis缓存机制,提升查询性能
*/ */
@PostMapping("/calculate") @PostMapping("/calculate")
@@ -102,6 +109,12 @@ public class AppOrderV2Controller {
Long scenicId = face.getScenicId(); Long scenicId = face.getScenicId();
request.getProducts().forEach(product -> { request.getProducts().forEach(product -> {
// 获取商品的重复检查策略
DuplicateCheckStrategy strategy = productTypeCapabilityService
.getDuplicateCheckStrategy(product.getProductType().name());
boolean hasPurchasedFlag;
switch (product.getProductType()) { switch (product.getProductType()) {
case VLOG_VIDEO: case VLOG_VIDEO:
List<MemberVideoEntity> videoEntities = videoMapper.listRelationByFaceAndTemplate(face.getId(), Long.valueOf(product.getProductId())); List<MemberVideoEntity> videoEntities = videoMapper.listRelationByFaceAndTemplate(face.getId(), Long.valueOf(product.getProductId()));
@@ -132,6 +145,13 @@ public class AppOrderV2Controller {
log.warn("未知的商品类型,跳过重复购买检查: productType={}", product.getProductType()); log.warn("未知的商品类型,跳过重复购买检查: productType={}", product.getProductType());
break; break;
} }
// 使用 DuplicatePurchaseChecker 检查是否已购买
hasPurchasedFlag = checkIfPurchased(strategy, currentUserId, String.valueOf(scenicId),
product.getProductType().name(), product.getProductId(), face.getId());
// 设置是否已购买标识
product.setHasPurchased(hasPurchasedFlag);
}); });
// 转换为标准价格计算请求 // 转换为标准价格计算请求
@@ -140,6 +160,12 @@ public class AppOrderV2Controller {
// 执行价格计算 // 执行价格计算
PriceCalculationResult result = priceCalculationService.calculatePrice(standardRequest); PriceCalculationResult result = priceCalculationService.calculatePrice(standardRequest);
// 设置是否已购买标识(基于请求中的商品 hasPurchased 判断)
// 只要有一个商品 hasPurchased = true,则整体 isPurchased = true
boolean isPurchased = request.getProducts().stream()
.anyMatch(product -> Boolean.TRUE.equals(product.getHasPurchased()));
result.setIsPurchased(isPurchased);
// 将计算结果缓存到Redis // 将计算结果缓存到Redis
String cacheKey = priceCacheService.cachePriceResult(currentUserId, scenicId, request.getProducts(), result); String cacheKey = priceCacheService.cachePriceResult(currentUserId, scenicId, request.getProducts(), result);
@@ -355,4 +381,55 @@ public class AppOrderV2Controller {
public ApiResponse<Boolean> getDownloadableOrder(@PathVariable("orderId") Long orderId) { public ApiResponse<Boolean> getDownloadableOrder(@PathVariable("orderId") Long orderId) {
return ApiResponse.success(!redisTemplate.hasKey("order_content_not_downloadable_" + orderId)); return ApiResponse.success(!redisTemplate.hasKey("order_content_not_downloadable_" + orderId));
} }
/**
* 检查商品是否已购买
* 使用 DuplicatePurchaseChecker 通过异常捕获判断
*
* @param strategy 重复检查策略
* @param userId 用户ID
* @param scenicId 景区ID
* @param productType 商品类型
* @param productId 商品ID
* @param faceId 人脸ID
* @return true-已购买, false-未购买
*/
private boolean checkIfPurchased(DuplicateCheckStrategy strategy, Long userId, String scenicId,
String productType, String productId, Long faceId) {
// NO_CHECK 策略表示允许重复购买,直接返回 false
if (strategy == DuplicateCheckStrategy.NO_CHECK) {
return false;
}
try {
// 获取对应的检查器
IDuplicatePurchaseChecker checker = duplicatePurchaseCheckerFactory.getChecker(strategy);
// 构建检查上下文
DuplicateCheckContext context = new DuplicateCheckContext();
context.setUserId(String.valueOf(userId));
context.setScenicId(scenicId);
context.setProductType(productType);
context.setProductId(productId);
context.addParam("faceId", faceId);
// 执行检查,如果抛出异常则表示已购买
checker.check(context);
// 没有抛出异常,表示未购买
return false;
} catch (DuplicatePurchaseException e) {
// 捕获到重复购买异常,表示已购买
log.debug("检测到已购买: userId={}, scenicId={}, productType={}, productId={}",
userId, scenicId, productType, productId);
return true;
} catch (Exception e) {
// 其他异常,记录日志并返回 false(保守处理)
log.warn("检查是否已购买时发生异常: userId={}, scenicId={}, productType={}, productId={}, error={}",
userId, scenicId, productType, productId, e.getMessage(), e);
return false;
}
}
} }

View File

@@ -1,42 +1,57 @@
package com.ycwl.basic.controller.mobile; package com.ycwl.basic.controller.mobile;
import cn.hutool.core.date.DateUtil;
import com.ycwl.basic.biz.OrderBiz; import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.constant.SourceType; import com.ycwl.basic.constant.FreeStatus;
import com.ycwl.basic.image.watermark.edge.PuzzleDefaultWatermarkTemplateBuilder;
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeTaskCreator;
import com.ycwl.basic.image.watermark.edge.WatermarkRequest;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO; import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO; import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.model.pc.face.entity.FaceEntity; import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.puzzle.entity.MemberPuzzleEntity;
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
import com.ycwl.basic.pricing.dto.PriceCalculationRequest; import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
import com.ycwl.basic.pricing.dto.PriceCalculationResult; import com.ycwl.basic.pricing.dto.PriceCalculationResult;
import com.ycwl.basic.pricing.dto.ProductItem; import com.ycwl.basic.pricing.dto.ProductItem;
import com.ycwl.basic.pricing.enums.ProductType; import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.pricing.service.IPriceCalculationService; import com.ycwl.basic.pricing.service.IPriceCalculationService;
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity; import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper; import com.ycwl.basic.puzzle.mapper.MemberPuzzleMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.repository.FaceRepository; import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.service.printer.PrinterService; import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j
@RestController @RestController
@RequestMapping("/api/mobile/puzzle/v1") @RequestMapping("/api/mobile/puzzle/v1")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AppPuzzleController { public class AppPuzzleController {
private final PuzzleGenerationRecordMapper recordMapper; private final PuzzleRepository puzzleRepository;
private final FaceRepository faceRepository; private final FaceRepository faceRepository;
private final IPriceCalculationService iPriceCalculationService; private final IPriceCalculationService iPriceCalculationService;
private final PrinterService printerService; private final PrinterService printerService;
private final OrderBiz orderBiz; private final OrderBiz orderBiz;
private final MemberPuzzleMapper memberPuzzleMapper;
private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator;
private final FaceService faceService;
private final ScenicRepository scenicRepository;
/** /**
* 根据faceId查询三拼图数量 * 根据faceId查询三拼图数量
@@ -46,8 +61,9 @@ public class AppPuzzleController {
if (faceId == null) { if (faceId == null) {
return ApiResponse.fail("faceId不能为空"); return ApiResponse.fail("faceId不能为空");
} }
int count = recordMapper.countByFaceId(faceId); // 通过关联表查询数量
return ApiResponse.success(count); List<MemberPuzzleEntity> relations = memberPuzzleMapper.listByFaceId(faceId);
return ApiResponse.success(relations.size());
} }
/** /**
@@ -58,9 +74,17 @@ public class AppPuzzleController {
if (faceId == null) { if (faceId == null) {
return ApiResponse.fail("faceId不能为空"); return ApiResponse.fail("faceId不能为空");
} }
List<PuzzleGenerationRecordEntity> records = recordMapper.listByFaceId(faceId); // 通过关联表查询,获取关联的拼图记录
List<ContentPageVO> result = records.stream() List<MemberPuzzleEntity> relations = memberPuzzleMapper.listByFaceId(faceId);
.map(this::convertToContentPageVO) List<ContentPageVO> result = relations.stream()
.map(relation -> {
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(relation.getRecordId());
if (record == null) {
return null;
}
return convertToContentPageVO(record, relation);
})
.filter(vo -> vo != null)
.collect(Collectors.toList()); .collect(Collectors.toList());
return ApiResponse.success(result); return ApiResponse.success(result);
} }
@@ -73,23 +97,26 @@ public class AppPuzzleController {
if (recordId == null) { if (recordId == null) {
return ApiResponse.fail("recordId不能为空"); return ApiResponse.fail("recordId不能为空");
} }
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId); PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
if (record == null) { if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录"); return ApiResponse.fail("未找到对应的拼图记录");
} }
ContentPageVO result = convertToContentPageVO(record); // 查询关联记录
MemberPuzzleEntity relation = memberPuzzleMapper.getByFaceAndRecord(record.getFaceId(), recordId);
ContentPageVO result = convertToContentPageVO(record, relation);
return ApiResponse.success(result); return ApiResponse.success(result);
} }
/** /**
* 根据recordId下载拼图资源 * 根据recordId下载拼图资源
* 如果是免费赠送的拼图,会添加水印后返回
*/ */
@GetMapping("/download/{recordId}") @GetMapping("/download/{recordId}")
public ApiResponse<List<String>> download(@PathVariable("recordId") Long recordId) { public ApiResponse<List<String>> download(@PathVariable("recordId") Long recordId) {
if (recordId == null) { if (recordId == null) {
return ApiResponse.fail("recordId不能为空"); return ApiResponse.fail("recordId不能为空");
} }
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId); PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
if (record == null) { if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录"); return ApiResponse.fail("未找到对应的拼图记录");
} }
@@ -97,9 +124,88 @@ public class AppPuzzleController {
if (resultImageUrl == null || resultImageUrl.isEmpty()) { if (resultImageUrl == null || resultImageUrl.isEmpty()) {
return ApiResponse.fail("该拼图记录没有可用的图片URL"); return ApiResponse.fail("该拼图记录没有可用的图片URL");
} }
// 查询该拼图的关联记录,判断是否为免费赠送
Long faceId = record.getFaceId();
if (faceId != null) {
MemberPuzzleEntity memberPuzzle = memberPuzzleMapper.getByFaceAndRecord(faceId, recordId);
if (memberPuzzle != null && FreeStatus.isFree(memberPuzzle.getIsFree())) {
// 免费赠送的拼图,需要添加水印
String watermarkedUrl = addWatermarkForFreePuzzle(record);
if (watermarkedUrl != null) {
return ApiResponse.success(Collections.singletonList(watermarkedUrl));
}
// 如果水印添加失败,记录日志并返回原图
log.warn("免费拼图水印添加失败,返回原图: recordId={}", recordId);
}
}
return ApiResponse.success(Collections.singletonList(resultImageUrl)); return ApiResponse.success(Collections.singletonList(resultImageUrl));
} }
/**
* 为免费赠送的拼图添加水印
*
* @param record 拼图记录
* @return 带水印的图片URL,失败返回null
*/
private String addWatermarkForFreePuzzle(PuzzleGenerationRecordEntity record) {
try {
Long faceId = record.getFaceId();
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.warn("添加水印失败:未找到人脸信息, faceId={}", faceId);
return null;
}
// 获取景区信息
ScenicEntity scenic = scenicRepository.getScenic(face.getScenicId());
String scenicLine = scenic != null ? scenic.getName() : "";
// 获取二维码URL
String qrcodeUrl = faceService.bindWxaCode(faceId);
// 格式化日期时间
String datetimeLine = record.getCreateTime() != null
? DateUtil.format(record.getCreateTime(), "yyyy-MM-dd")
: "";
// 构建水印请求
WatermarkRequest request = WatermarkRequest.builder()
.originalImageUrl(record.getResultImageUrl())
.imageWidth(record.getResultWidth() != null ? record.getResultWidth() : 0)
.imageHeight(record.getResultHeight() != null ? record.getResultHeight() : 0)
.qrcodeUrl(qrcodeUrl)
.faceUrl(face.getFaceUrl())
.scenicLine(scenicLine)
.datetimeLine(datetimeLine)
.outputFormat("JPEG")
.outputQuality(90)
.build();
// 创建水印任务并等待结果
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
PuzzleDefaultWatermarkTemplateBuilder.STYLE,
request,
record.getId(),
faceId,
"free_puzzle_download",
30_000L // 30秒超时
);
if (result.isSuccess()) {
log.info("免费拼图水印添加成功: recordId={}, url={}", record.getId(), result.getImageUrl());
return result.getImageUrl();
} else {
log.error("免费拼图水印添加失败: recordId={}, error={}", record.getId(), result.getErrorMessage());
return null;
}
} catch (Exception e) {
log.error("免费拼图水印添加异常: recordId={}", record.getId(), e);
return null;
}
}
/** /**
* 根据recordId查询拼图价格 * 根据recordId查询拼图价格
*/ */
@@ -108,7 +214,7 @@ public class AppPuzzleController {
if (recordId == null) { if (recordId == null) {
return ApiResponse.fail("recordId不能为空"); return ApiResponse.fail("recordId不能为空");
} }
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId); PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
if (record == null) { if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录"); return ApiResponse.fail("未找到对应的拼图记录");
} }
@@ -142,14 +248,14 @@ public class AppPuzzleController {
} }
// 查询拼图记录 // 查询拼图记录
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId); PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
if (record == null) { if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录"); return ApiResponse.fail("未找到对应的拼图记录");
} }
// 检查是否有图片URL // 检查是否有图片URL
String resultImageUrl = record.getResultImageUrl(); String imageUrl = record.getResultImageUrl();
if (resultImageUrl == null || resultImageUrl.isEmpty()) { if (imageUrl == null || imageUrl.isEmpty()) {
return ApiResponse.fail("该拼图记录没有可用的图片URL"); return ApiResponse.fail("该拼图记录没有可用的图片URL");
} }
@@ -164,8 +270,8 @@ public class AppPuzzleController {
face.getMemberId(), face.getMemberId(),
face.getScenicId(), face.getScenicId(),
record.getFaceId(), record.getFaceId(),
resultImageUrl, imageUrl,
0L // 打印特有 recordId // 拼图记录ID,用于关联 puzzle_record 表
); );
if (memberPrintId == null) { if (memberPrintId == null) {
@@ -177,8 +283,11 @@ public class AppPuzzleController {
/** /**
* 将PuzzleGenerationRecordEntity转换为ContentPageVO * 将PuzzleGenerationRecordEntity转换为ContentPageVO
*
* @param record 拼图生成记录
* @param relation 会员拼图关联记录,用于获取免费状态
*/ */
private ContentPageVO convertToContentPageVO(PuzzleGenerationRecordEntity record) { private ContentPageVO convertToContentPageVO(PuzzleGenerationRecordEntity record, MemberPuzzleEntity relation) {
ContentPageVO vo = new ContentPageVO(); ContentPageVO vo = new ContentPageVO();
// 内容类型为3(拼图) // 内容类型为3(拼图)
@@ -214,21 +323,11 @@ public class AppPuzzleController {
vo.setIsBuy(1); vo.setIsBuy(1);
} else { } else {
vo.setIsBuy(0); vo.setIsBuy(0);
PriceCalculationRequest calculationRequest = new PriceCalculationRequest(); // 从关联记录读取免费状态
ProductItem productItem = new ProductItem(); if (relation != null && FreeStatus.isFree(relation.getIsFree())) {
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); vo.setFreeCount(1);
} else {
vo.setFreeCount(0);
} }
} }
} }

View File

@@ -1,120 +0,0 @@
package com.ycwl.basic.controller.mobile;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.model.mobile.scenic.ScenicAppVO;
import com.ycwl.basic.model.mobile.scenic.ScenicDeviceCountVO;
import com.ycwl.basic.model.mobile.scenic.ScenicIndexVO;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
import com.ycwl.basic.model.pc.scenic.resp.ScenicConfigResp;
import com.ycwl.basic.model.pc.scenic.resp.ScenicRespVO;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.mobile.AppScenicService;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
/**
* @Author:longbinbin
* @Date:2024/12/5 10:22
*/
@Slf4j
@RestController
@RequestMapping("/api/mobile/scenic/v1")
// 景区相关接口
public class AppScenicController {
@Autowired
private FaceService faceService;
@Autowired
private AppScenicService appScenicService;
@Autowired
private ScenicRepository scenicRepository;
private static final List<String> ENABLED_USER_IDs = new ArrayList<>(){{
add("3932535453961555968");
add("3936121342868459520");
add("3936940597855784960");
add("4049850382325780480");
}};
// 分页查询景区列表
@PostMapping("/page")
public ApiResponse<PageInfo<ScenicEntity>> pageQuery(@RequestBody ScenicReqQuery scenicReqQuery){
String userId = BaseContextHandler.getUserId();
if (ENABLED_USER_IDs.contains(userId)) {
return appScenicService.pageQuery(scenicReqQuery);
} else {
return ApiResponse.success(new PageInfo<>(new ArrayList<>()));
}
}
// 根据id查询景区详情
@IgnoreToken
@GetMapping("/{id}")
public ApiResponse<ScenicRespVO> getDetails(@PathVariable Long id){
return appScenicService.getDetails(id);
}
@GetMapping("/{id}/config")
@IgnoreToken
public ApiResponse<ScenicConfigResp> getConfig(@PathVariable Long id){
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(id);
ScenicConfigResp resp = new ScenicConfigResp();
resp.setWatermarkUrl(scenicConfig.getString("watermark_url"));
resp.setVideoStoreDay(scenicConfig.getInteger("video_store_day"));
resp.setAntiScreenRecordType(scenicConfig.getInteger("anti_screen_record_type"));
resp.setGroupingEnable(scenicConfig.getBoolean("grouping_enable", false));
resp.setVoucherEnable(scenicConfig.getBoolean("voucher_enable", false));
resp.setShowPhotoWhenWaiting(scenicConfig.getBoolean("show_photo_when_waiting", false));
resp.setImageSourcePackHint(scenicConfig.getString("image_source_pack_hint"));
resp.setVideoSourcePackHint(scenicConfig.getString("video_source_pack_hint"));
resp.setShareBeforeBuy(scenicConfig.getBoolean("share_before_buy"));
resp.setFaceSelectFirst(scenicConfig.getBoolean("face_select_first", false));
resp.setPrintEnableSource(scenicConfig.getBoolean("print_enable_source", true));
resp.setPrintForceFaceUpload(scenicConfig.getBoolean("print_force_face_upload", false));
resp.setPrintEnableManual(scenicConfig.getBoolean("print_enable_manual", true));
resp.setSceneMode(scenicConfig.getInteger("scene_mode", 0));
resp.setPrintEnable(scenicConfig.getBoolean("print_enable", false));
resp.setShowMyPagePaid(scenicConfig.getBoolean("show_my_page_paid", true));
resp.setShowMyPageUnpaid(scenicConfig.getBoolean("show_my_page_unpaid", true));
return ApiResponse.success(resp);
}
// 查询景区设备总数和拍到用户的机位数量
@GetMapping("/{scenicId}/deviceCount/")
public ApiResponse<ScenicDeviceCountVO> deviceCountByScenicId(@PathVariable Long scenicId){
return appScenicService.deviceCountByScenicId(scenicId);
}
// 景区视频源素材列表
@GetMapping("/contentList/")
public ApiResponse<List<ContentPageVO>> contentList() {
return faceService.contentListUseDefaultFace();
}
// 景区视频源素材列表
@GetMapping("/face/{faceId}/contentList")
public ApiResponse<List<ContentPageVO>> contentList(@PathVariable Long faceId) {
List<ContentPageVO> contentPageVOS = faceService.faceContentList(faceId);
return ApiResponse.success(contentPageVOS);
}
@PostMapping("/nearby")
public ApiResponse<List<ScenicAppVO>> nearby(@RequestBody ScenicIndexVO scenicIndexVO) {
List<ScenicAppVO> list = appScenicService.scenicListByLnLa(scenicIndexVO);
return ApiResponse.success(list);
}
}

View File

@@ -1,8 +1,10 @@
package com.ycwl.basic.controller.mobile; package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.mapper.TemplateMapper; import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.model.pc.template.entity.TemplateEntity; import com.ycwl.basic.model.mobile.scenic.content.ScenicTemplateContentVO;
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO; import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.repository.TemplateRepository; import com.ycwl.basic.repository.TemplateRepository;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -11,6 +13,10 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** /**
* 移动端模板接口 * 移动端模板接口
*/ */
@@ -20,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
public class AppTemplateController { public class AppTemplateController {
private final TemplateRepository templateRepository; private final TemplateRepository templateRepository;
private final PuzzleRepository puzzleRepository;
/** /**
* 根据模板ID获取封面URL * 根据模板ID获取封面URL
@@ -45,4 +52,80 @@ public class AppTemplateController {
return ApiResponse.success(coverUrl); return ApiResponse.success(coverUrl);
} }
/**
* 根据景区ID获取所有模板封面URL列表(用于前端预缓存)
*
* @param scenicId 景区ID
* @return 模板封面URL列表
*/
@GetMapping("/scenic/{scenicId}/covers")
@IgnoreToken
public ApiResponse<List<String>> getScenicTemplateCoverUrls(@PathVariable("scenicId") Long scenicId) {
if (scenicId == null) {
return ApiResponse.fail("景区ID不能为空");
}
List<String> coverUrls = new ArrayList<>();
// 获取普通模板封面
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(scenicId);
templateList.stream()
.map(TemplateRespVO::getCoverUrl)
.filter(Objects::nonNull)
.filter(url -> !url.isEmpty())
.forEach(coverUrls::add);
// 获取拼图模板封面(使用缓存)
List<PuzzleTemplateEntity> puzzleTemplateList = puzzleRepository.listTemplateByScenic(scenicId);
puzzleTemplateList.stream()
.map(PuzzleTemplateEntity::getCoverImage)
.filter(Objects::nonNull)
.filter(url -> !url.isEmpty())
.forEach(coverUrls::add);
return ApiResponse.success(coverUrls);
}
/**
* 根据景区ID获取所有模板内容列表(返回模板基础信息,与 faceId 无关)
*
* @param scenicId 景区ID
* @return 景区模板内容列表
*/
@GetMapping("/scenic/{scenicId}/contents")
@IgnoreToken
public ApiResponse<List<ScenicTemplateContentVO>> getScenicTemplateContents(@PathVariable("scenicId") Long scenicId) {
if (scenicId == null) {
return ApiResponse.fail("景区ID不能为空");
}
List<ScenicTemplateContentVO> contentList = new ArrayList<>();
// 获取普通模板
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(scenicId);
for (TemplateRespVO template : templateList) {
ScenicTemplateContentVO content = new ScenicTemplateContentVO();
content.setGoodsType(0); // 普通模板默认商品类型为 0
content.setName(template.getName());
content.setGroup(template.getGroup());
content.setTemplateId(template.getId());
content.setTemplateCoverUrl(template.getCoverUrl());
contentList.add(content);
}
// 获取拼图模板
List<PuzzleTemplateEntity> puzzleTemplateList = puzzleRepository.listTemplateByScenic(scenicId);
for (PuzzleTemplateEntity puzzleTemplate : puzzleTemplateList) {
ScenicTemplateContentVO content = new ScenicTemplateContentVO();
content.setGoodsType(3); // 拼图模板商品类型为 3
content.setName(puzzleTemplate.getName());
content.setGroup("氛围拼图"); // 拼图模板固定分组
content.setTemplateId(puzzleTemplate.getId());
content.setTemplateCoverUrl(puzzleTemplate.getCoverImage());
contentList.add(content);
}
return ApiResponse.success(contentList);
}
} }

View File

@@ -1,8 +1,10 @@
package com.ycwl.basic.controller.mobile.notify; package com.ycwl.basic.controller.mobile.notify;
import com.ycwl.basic.model.mobile.notify.req.BatchRemainingCountReq;
import com.ycwl.basic.model.mobile.notify.req.NotificationAuthRecordReq; import com.ycwl.basic.model.mobile.notify.req.NotificationAuthRecordReq;
import com.ycwl.basic.model.mobile.notify.resp.NotificationAuthRecordResp; import com.ycwl.basic.model.mobile.notify.resp.NotificationAuthRecordResp;
import com.ycwl.basic.model.mobile.notify.resp.ScenicTemplateAuthResp; import com.ycwl.basic.model.mobile.notify.resp.ScenicTemplateAuthResp;
import com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationEntity;
import com.ycwl.basic.service.UserNotificationAuthorizationService; import com.ycwl.basic.service.UserNotificationAuthorizationService;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil; import com.ycwl.basic.utils.JwtTokenUtil;
@@ -14,7 +16,9 @@ import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 用户通知授权记录Controller (移动端API) * 用户通知授权记录Controller (移动端API)
@@ -41,7 +45,8 @@ public class UserNotificationAuthController {
@PostMapping("/record") @PostMapping("/record")
public ApiResponse<NotificationAuthRecordResp> recordAuthorization( public ApiResponse<NotificationAuthRecordResp> recordAuthorization(
@RequestBody NotificationAuthRecordReq req) { @RequestBody NotificationAuthRecordReq req) {
log.debug("记录用户通知授权: templateIds={}, scenicId={}", req.getTemplateIds(), req.getScenicId()); log.debug("记录用户通知授权: templateIds={}, scenicId={}, requestId={}",
req.getTemplateIds(), req.getScenicId(), req.getRequestId());
try { try {
// 获取当前用户ID // 获取当前用户ID
@@ -50,7 +55,7 @@ public class UserNotificationAuthController {
// 调用批量授权记录方法 // 调用批量授权记录方法
List<UserNotificationAuthorizationService.AuthorizationRecord> records = List<UserNotificationAuthorizationService.AuthorizationRecord> records =
userNotificationAuthorizationService.batchRecordAuthorization( userNotificationAuthorizationService.batchRecordAuthorization(
memberId, req.getTemplateIds(), req.getScenicId()); memberId, req.getTemplateIds(), req.getScenicId(), req.getRequestId());
NotificationAuthRecordResp resp = new NotificationAuthRecordResp(); NotificationAuthRecordResp resp = new NotificationAuthRecordResp();
@@ -93,98 +98,42 @@ public class UserNotificationAuthController {
} }
/** /**
* 获取景区通知模板ID及用户授权余额 * 批量查询用户授权余额
* 复制AppWxNotifyController中的逻辑,并额外返回用户对应的授权余额 * 返回 Map<wechatTemplateId, remainingCount>
*/ */
@GetMapping("/{scenicId}/templates") @PostMapping("/batch-remaining")
public ApiResponse<ScenicTemplateAuthResp> getScenicTemplatesWithAuth(@PathVariable("scenicId") Long scenicId) { public ApiResponse<Map<String, Integer>> batchGetRemainingCount(
log.debug("获取景区通知模板ID及用户授权余额: scenicId={}", scenicId); @RequestBody BatchRemainingCountReq req) {
log.debug("批量查询用户授权余额: templateIds={}, scenicId={}",
req.getTemplateIds(), req.getScenicId());
try { try {
// 获取当前用户ID
Long memberId = JwtTokenUtil.getWorker().getUserId(); Long memberId = JwtTokenUtil.getWorker().getUserId();
if (memberId == null) {
// 获取景区的所有模板ID(复制自AppWxNotifyController的逻辑) return ApiResponse.fail("用户未登录");
List<String> templateIds = new ArrayList<>() {{
String videoGeneratedTemplateId = scenicRepository.getVideoGeneratedTemplateId(scenicId);
if (StringUtils.isNotBlank(videoGeneratedTemplateId)) {
add(videoGeneratedTemplateId);
}
String videoDownloadTemplateId = scenicRepository.getVideoDownloadTemplateId(scenicId);
if (StringUtils.isNotBlank(videoDownloadTemplateId)) {
add(videoDownloadTemplateId);
}
String videoPreExpireTemplateId = scenicRepository.getVideoPreExpireTemplateId(scenicId);
if (StringUtils.isNotBlank(videoPreExpireTemplateId)) {
add(videoPreExpireTemplateId);
}
}};
// 构建响应对象
ScenicTemplateAuthResp resp = new ScenicTemplateAuthResp();
resp.setScenicId(scenicId);
// 查询每个模板的授权余额信息
List<ScenicTemplateAuthResp.TemplateAuthInfo> templateAuthInfos = new ArrayList<>();
for (String templateId : templateIds) {
ScenicTemplateAuthResp.TemplateAuthInfo templateAuthInfo =
new ScenicTemplateAuthResp.TemplateAuthInfo();
templateAuthInfo.setTemplateId(templateId);
if (templateId.equals(scenicRepository.getVideoGeneratedTemplateId(scenicId))) {
templateAuthInfo.setTitle("视频生成通知");
templateAuthInfo.setDescription("当视频生成完成时,我们将提醒您");
} else if (templateId.equals(scenicRepository.getVideoDownloadTemplateId(scenicId))) {
templateAuthInfo.setTitle("视频下载通知");
templateAuthInfo.setDescription("当您的视频未购买时,我们将提醒您");
} else if (templateId.equals(scenicRepository.getVideoPreExpireTemplateId(scenicId))) {
templateAuthInfo.setTitle("视频即将过期通知");
templateAuthInfo.setDescription("当您的视频即将过期时,我们将提醒您及时下载");
} else {
templateAuthInfo.setTitle("未知模板类型");
templateAuthInfo.setDescription("未知的模板类型");
} }
// 获取授权详情 if (CollectionUtils.isEmpty(req.getTemplateIds())) {
try { return ApiResponse.success(new HashMap<>());
com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationEntity authEntity =
userNotificationAuthorizationService.checkAuthorization(memberId, templateId, scenicId);
if (authEntity != null) {
templateAuthInfo.setAuthorizationCount(authEntity.getAuthorizationCount());
templateAuthInfo.setConsumedCount(authEntity.getConsumedCount());
templateAuthInfo.setRemainingCount(authEntity.getRemainingCount());
templateAuthInfo.setHasAuthorization(authEntity.getRemainingCount() != null && authEntity.getRemainingCount() > 0);
} else {
// 没有授权记录
templateAuthInfo.setAuthorizationCount(0);
templateAuthInfo.setConsumedCount(0);
templateAuthInfo.setRemainingCount(0);
templateAuthInfo.setHasAuthorization(false);
} }
Map<String, UserNotificationAuthorizationEntity> authMap =
userNotificationAuthorizationService.batchCheckAuthorization(
memberId, req.getTemplateIds(), req.getScenicId());
// 转换为 templateId -> remainingCount
Map<String, Integer> result = new HashMap<>();
for (String templateId : req.getTemplateIds()) {
UserNotificationAuthorizationEntity entity = authMap.get(templateId);
int remaining = (entity != null && entity.getRemainingCount() != null)
? entity.getRemainingCount() : 0;
result.put(templateId, remaining);
}
return ApiResponse.success(result);
} catch (Exception e) { } catch (Exception e) {
log.warn("获取模板授权信息失败: templateId={}, scenicId={}, memberId={}, error={}", log.error("批量查询用户授权余额失败", e);
templateId, scenicId, memberId, e.getMessage()); return ApiResponse.fail("查询失败: " + e.getMessage());
// 获取失败时设置为无授权
templateAuthInfo.setAuthorizationCount(0);
templateAuthInfo.setConsumedCount(0);
templateAuthInfo.setRemainingCount(0);
templateAuthInfo.setHasAuthorization(false);
}
templateAuthInfos.add(templateAuthInfo);
}
resp.setTemplates(templateAuthInfos);
log.debug("成功获取景区通知模板ID及用户授权余额: scenicId={}, templateCount={}, memberId={}",
scenicId, templateIds.size(), memberId);
return ApiResponse.success(resp);
} catch (Exception e) {
log.error("获取景区通知模板ID及用户授权余额失败: scenicId={}", scenicId, e);
return ApiResponse.fail("获取授权信息失败: " + e.getMessage());
} }
} }
} }

View File

@@ -0,0 +1,109 @@
package com.ycwl.basic.controller.mobile.notify;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeAllScenesResp;
import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeSceneTemplatesResp;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
import com.ycwl.basic.service.notify.WechatSubscribeNotifyConfigService;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
import com.ycwl.basic.utils.NotificationAuthUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* 微信小程序订阅消息:场景模板查询(移动端API)
*
* @Author: System
* @Date: 2025/12/31
*/
@RestController
@RequestMapping("/api/mobile/notify/subscribe")
@Slf4j
public class WechatSubscribeNotifyController {
private final WechatSubscribeNotifyConfigService configService;
private final NotificationAuthUtils notificationAuthUtils;
public WechatSubscribeNotifyController(WechatSubscribeNotifyConfigService configService,
NotificationAuthUtils notificationAuthUtils) {
this.configService = configService;
this.notificationAuthUtils = notificationAuthUtils;
}
/**
* 获取“场景”下可申请授权的模板列表(支持按 scenicId 覆盖模板ID/开关/文案)
*/
@GetMapping("/scenic/{scenicId}/scenes/{sceneKey}/templates")
@IgnoreToken
public ApiResponse<WechatSubscribeSceneTemplatesResp> listSceneTemplates(@PathVariable("scenicId") Long scenicId,
@PathVariable("sceneKey") String sceneKey) {
if (scenicId == null) {
return ApiResponse.fail("scenicId不能为空");
}
if (StringUtils.isBlank(sceneKey)) {
return ApiResponse.fail("sceneKey不能为空");
}
Long memberId = JwtTokenUtil.getWorker().getUserId();
List<WechatSubscribeTemplateConfigEntity> configs = configService.listSceneTemplateConfigs(scenicId, sceneKey);
WechatSubscribeSceneTemplatesResp resp = new WechatSubscribeSceneTemplatesResp();
resp.setScenicId(scenicId);
resp.setSceneKey(sceneKey);
if (memberId == null) {
return ApiResponse.success(resp);
}
List<WechatSubscribeSceneTemplatesResp.TemplateInfo> templates = new ArrayList<>();
for (WechatSubscribeTemplateConfigEntity cfg : configs) {
if (cfg == null || StringUtils.isBlank(cfg.getWechatTemplateId())) {
continue;
}
String title = StringUtils.isNotBlank(cfg.getTitleTemplate())
? cfg.getTitleTemplate()
: cfg.getTemplateKey();
int remaining = notificationAuthUtils.getRemainingCount(memberId, cfg.getWechatTemplateId(), scenicId);
WechatSubscribeSceneTemplatesResp.TemplateInfo info = new WechatSubscribeSceneTemplatesResp.TemplateInfo();
info.setTemplateKey(cfg.getTemplateKey());
info.setWechatTemplateId(cfg.getWechatTemplateId());
info.setTitle(title);
info.setDescription(cfg.getDescription());
info.setRemainingCount(remaining);
info.setHasAuthorization(remaining > 0);
templates.add(info);
}
resp.setTemplates(templates);
log.debug("场景模板查询: scenicId={}, sceneKey={}, memberId={}, templateCount={}",
scenicId, sceneKey, memberId, Objects.requireNonNullElse(templates.size(), 0));
return ApiResponse.success(resp);
}
/**
* 获取景区下所有场景及其模板列表(静态配置,带缓存)
* 不含用户授权信息,用户授权信息通过 /api/mobile/notify/auth/batch-remaining 接口获取
*/
@GetMapping("/scenic/{scenicId}/scenes")
@IgnoreToken
public ApiResponse<WechatSubscribeAllScenesResp> listAllSceneTemplates(@PathVariable("scenicId") Long scenicId) {
if (scenicId == null) {
return ApiResponse.fail("scenicId不能为空");
}
WechatSubscribeAllScenesResp resp = configService.getAllScenesWithTemplatesCached(scenicId);
log.debug("所有场景模板查询: scenicId={}, sceneCount={}",
scenicId, resp.getScenes() != null ? resp.getScenes().size() : 0);
return ApiResponse.success(resp);
}
}

View File

@@ -1,70 +0,0 @@
package com.ycwl.basic.controller.pc;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.biz.PriceBiz;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.coupon.req.CouponQueryReq;
import com.ycwl.basic.model.pc.coupon.resp.CouponRespVO;
import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
import com.ycwl.basic.service.pc.CouponService;
import com.ycwl.basic.utils.ApiResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/coupon/v1")
// 优惠券管理
public class CouponController {
@Autowired
private CouponService couponService;
@Autowired
private PriceBiz priceBiz;
@GetMapping("/{scenicId}/goodsList")
public ApiResponse<List<GoodsListRespVO>> scenicGoodsList(@PathVariable Long scenicId) {
List<GoodsListRespVO> data = priceBiz.listGoodsByScenic(scenicId);
data.add(new GoodsListRespVO(-1L, "一口价", -1));
return ApiResponse.success(data);
}
// 新增优惠券
@PostMapping("/add")
public ApiResponse<Integer> add(@RequestBody CouponEntity coupon) {
return ApiResponse.success(couponService.add(coupon));
}
// 更新优惠券
@PostMapping("/update/{id}")
public ApiResponse<Boolean> update(@PathVariable Integer id, @RequestBody CouponEntity coupon) {
coupon.setId(id);
return ApiResponse.success(couponService.update(coupon));
}
@PutMapping("/updateStatus/{id}")
public ApiResponse<Boolean> updateStatus(@PathVariable Integer id) {
return ApiResponse.success(couponService.updateStatus(id));
}
// 删除优惠券
@DeleteMapping("/delete/{id}")
public ApiResponse<Boolean> delete(@PathVariable Integer id) {
return ApiResponse.success(couponService.delete(id));
}
// 根据ID查询优惠券
@GetMapping("/get/{id}")
public ApiResponse<CouponEntity> getById(@PathVariable Integer id) {
return ApiResponse.success(couponService.getById(id));
}
// 分页查询优惠券列表
@PostMapping("/page")
public ApiResponse<PageInfo<CouponRespVO>> list(@RequestBody CouponQueryReq couponQuery) {
PageHelper.startPage(couponQuery.getPageNum(), couponQuery.getPageSize());
List<CouponRespVO> list = couponService.list(couponQuery);
PageInfo<CouponRespVO> pageInfo = new PageInfo<>(list);
return ApiResponse.success(pageInfo);
}
}

View File

@@ -1,22 +0,0 @@
package com.ycwl.basic.controller.pc;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.model.pc.couponRecord.req.CouponRecordPageQueryReq;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordPageResp;
import com.ycwl.basic.service.pc.CouponRecordService;
import com.ycwl.basic.utils.ApiResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/coupon/record/v1")
public class CouponRecordController {
@Autowired
private CouponRecordService couponRecordService;
@PostMapping("/page")
public ApiResponse<PageInfo<CouponRecordPageResp>> pageQuery(@RequestBody CouponRecordPageQueryReq query) {
return couponRecordService.pageQuery(query);
}
}

View File

@@ -6,7 +6,6 @@ import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity; import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import com.ycwl.basic.model.pc.device.req.VideoContinuityReportReq; import com.ycwl.basic.model.pc.device.req.VideoContinuityReportReq;
import com.ycwl.basic.repository.DeviceRepository; import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.task.DeviceVideoContinuityCheckTask;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -37,7 +36,6 @@ public class DeviceVideoContinuityController {
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final DeviceVideoContinuityCheckTask checkTask;
private final DeviceRepository deviceRepository; private final DeviceRepository deviceRepository;
/** /**
@@ -78,15 +76,7 @@ public class DeviceVideoContinuityController {
@PostMapping("/{deviceId}/check") @PostMapping("/{deviceId}/check")
public ApiResponse<DeviceVideoContinuityCache> manualCheck(@PathVariable Long deviceId) { public ApiResponse<DeviceVideoContinuityCache> manualCheck(@PathVariable Long deviceId) {
log.info("手动触发设备 {} 的视频连续性检查", deviceId); log.info("手动触发设备 {} 的视频连续性检查", deviceId);
return ApiResponse.success(null);
try {
DeviceVideoContinuityCache result = checkTask.manualCheck(deviceId);
return ApiResponse.success(result);
} catch (Exception e) {
log.error("手动检查设备 {} 视频连续性失败", deviceId, e);
return ApiResponse.buildResponse(500, null, "检查失败: " + e.getMessage());
}
} }
/** /**

View File

@@ -64,7 +64,9 @@ public class SourceController {
Map<String, Object> result = printerService.createVirtualOrder( Map<String, Object> result = printerService.createVirtualOrder(
request.getSourceId(), request.getSourceId(),
request.getScenicId(), request.getScenicId(),
request.getPrinterId() request.getPrinterId(),
request.getNeedEnhance(),
request.getPrintImgUrl()
); );
return ApiResponse.success(result); return ApiResponse.success(result);
} catch (Exception e) { } catch (Exception e) {

View File

@@ -0,0 +1,122 @@
package com.ycwl.basic.controller.pc;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplatePageReq;
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplateSaveReq;
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplatePageReq;
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplateSaveReq;
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSendLogPageReq;
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigPageReq;
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigSaveReq;
import com.ycwl.basic.service.pc.WechatSubscribeNotifyAdminService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 微信小程序订阅消息:配置管理(管理后台)
*
* @Author: System
* @Date: 2025/12/31
*/
@Slf4j
@RestController
@RequestMapping("/api/wechatSubscribeNotify/v1")
@RequiredArgsConstructor
public class WechatSubscribeNotifyAdminController {
private final WechatSubscribeNotifyAdminService adminService;
// ========================= 模板配置 =========================
@PostMapping("/templateConfig/page")
public ApiResponse<PageInfo<WechatSubscribeTemplateConfigEntity>> pageTemplateConfig(
@RequestBody WechatSubscribeTemplateConfigPageReq req) {
return adminService.pageTemplateConfig(req);
}
@GetMapping("/templateConfig/detail/{id}")
public ApiResponse<WechatSubscribeTemplateConfigEntity> getTemplateConfig(@PathVariable("id") Long id) {
return adminService.getTemplateConfig(id);
}
@PostMapping("/templateConfig/save")
public ApiResponse<Boolean> saveTemplateConfig(@RequestBody WechatSubscribeTemplateConfigSaveReq req) {
return adminService.saveTemplateConfig(req);
}
@DeleteMapping("/templateConfig/delete/{id}")
public ApiResponse<Boolean> deleteTemplateConfig(@PathVariable("id") Long id) {
return adminService.deleteTemplateConfig(id);
}
// ========================= 场景映射 =========================
@PostMapping("/sceneTemplate/page")
public ApiResponse<PageInfo<WechatSubscribeSceneTemplateEntity>> pageSceneTemplate(
@RequestBody WechatSubscribeSceneTemplatePageReq req) {
return adminService.pageSceneTemplate(req);
}
@GetMapping("/sceneTemplate/detail/{id}")
public ApiResponse<WechatSubscribeSceneTemplateEntity> getSceneTemplate(@PathVariable("id") Long id) {
return adminService.getSceneTemplate(id);
}
@PostMapping("/sceneTemplate/save")
public ApiResponse<Boolean> saveSceneTemplate(@RequestBody WechatSubscribeSceneTemplateSaveReq req) {
return adminService.saveSceneTemplate(req);
}
@DeleteMapping("/sceneTemplate/delete/{id}")
public ApiResponse<Boolean> deleteSceneTemplate(@PathVariable("id") Long id) {
return adminService.deleteSceneTemplate(id);
}
// ========================= 事件映射 =========================
@PostMapping("/eventTemplate/page")
public ApiResponse<PageInfo<WechatSubscribeEventTemplateEntity>> pageEventTemplate(
@RequestBody WechatSubscribeEventTemplatePageReq req) {
return adminService.pageEventTemplate(req);
}
@GetMapping("/eventTemplate/detail/{id}")
public ApiResponse<WechatSubscribeEventTemplateEntity> getEventTemplate(@PathVariable("id") Long id) {
return adminService.getEventTemplate(id);
}
@PostMapping("/eventTemplate/save")
public ApiResponse<Boolean> saveEventTemplate(@RequestBody WechatSubscribeEventTemplateSaveReq req) {
return adminService.saveEventTemplate(req);
}
@DeleteMapping("/eventTemplate/delete/{id}")
public ApiResponse<Boolean> deleteEventTemplate(@PathVariable("id") Long id) {
return adminService.deleteEventTemplate(id);
}
// ========================= 发送日志 =========================
@PostMapping("/sendLog/page")
public ApiResponse<PageInfo<WechatSubscribeSendLogEntity>> pageSendLog(@RequestBody WechatSubscribeSendLogPageReq req) {
return adminService.pageSendLog(req);
}
@GetMapping("/sendLog/detail/{id}")
public ApiResponse<WechatSubscribeSendLogEntity> getSendLog(@PathVariable("id") Long id) {
return adminService.getSendLog(id);
}
}

View File

@@ -4,18 +4,16 @@ package com.ycwl.basic.controller.printer;
import com.ycwl.basic.annotation.IgnoreToken; import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO; import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO; import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp; import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.pc.face.entity.FaceEntity; import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.mp.MpConfigEntity; import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery; import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity; import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.repository.DeviceRepository; import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.FaceRepository; import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.ScenicRepository; import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.repository.SourceRepository;
import com.ycwl.basic.service.pc.FaceService; import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.WxMpUtil; import com.ycwl.basic.utils.WxMpUtil;
@@ -32,7 +30,6 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.util.Collections;
import java.util.List; import java.util.List;
@IgnoreToken @IgnoreToken
@@ -46,8 +43,7 @@ public class PrinterTvController {
private final ScenicRepository scenicRepository; private final ScenicRepository scenicRepository;
private final FaceRepository faceRepository; private final FaceRepository faceRepository;
private final FaceService pcFaceService; private final FaceService pcFaceService;
private final MemberRelationRepository memberRelationRepository; private final SourceMapper sourceMapper;
private final SourceRepository sourceRepository;
/** /**
* 获取景区列表 * 获取景区列表
@@ -124,61 +120,21 @@ public class PrinterTvController {
*/ */
@GetMapping("/face/{faceId}/qrcode") @GetMapping("/face/{faceId}/qrcode")
public void getFaceQrcode(@PathVariable("faceId") Long faceId, HttpServletResponse response) throws Exception { public void getFaceQrcode(@PathVariable("faceId") Long faceId, HttpServletResponse response) throws Exception {
File qrcode = new File("qrcode_face_" + faceId + ".jpg"); String url = pcFaceService.bindWxaCode(faceId);
try { response.sendRedirect(url);
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
response.setStatus(404);
return;
}
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(face.getScenicId());
if (scenicMpConfig == null) {
response.setStatus(500);
return;
}
WxMpUtil.generateUnlimitedWXAQRCode(
scenicMpConfig.getAppId(),
scenicMpConfig.getAppSecret(),
"pages/videoSynthesis/bind_face",
faceId.toString(),
qrcode
);
// 设置响应头
response.setContentType("image/jpeg");
response.setHeader("Content-Disposition", "inline; filename=\"" + qrcode.getName() + "\"");
// 将二维码文件写入响应输出流
try (FileInputStream fis = new FileInputStream(qrcode);
OutputStream os = response.getOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.flush();
}
} finally {
// 删除临时文件
if (qrcode.exists()) {
qrcode.delete();
}
}
} }
/** /**
* 根据人脸样本ID查询图像素材 * 根据人脸ID查询图像素材
* *
* @param faceId 人脸样本ID * @param faceId 人脸ID
* @param type 素材类型(默认为2-图片)
* @return 匹配的source记录 * @return 匹配的source记录
*/ */
@GetMapping("/{faceId}/source") @GetMapping("/{faceId}/source")
public ApiResponse<List<SourceEntity>> getSourceByFaceId(@PathVariable Long faceId, @RequestParam(name = "type", required = false, defaultValue = "2") Integer type) { public ApiResponse<List<SourceEntity>> getSourceByFaceId(@PathVariable Long faceId, @RequestParam(name = "type", required = false, defaultValue = "2") Integer type) {
List<MemberSourceEntity> source = memberRelationRepository.listSourceByFaceRelation(faceId, type); List<SourceEntity> sources = sourceMapper.listSourceByFaceRelation(faceId, type);
if (source == null) { return ApiResponse.success(sources);
return ApiResponse.success(Collections.emptyList());
}
return ApiResponse.success(source.stream().map(item -> sourceRepository.getSource(item.getSourceId())).toList());
} }
/** /**
@@ -203,5 +159,36 @@ public class PrinterTvController {
return ApiResponse.success(resp); return ApiResponse.success(resp);
} }
/**
* 通过人脸样本ID重定向到人脸图片URL
*
* @param faceSampleId 人脸样本ID
* @param response HTTP响应
*/
@GetMapping("/faceSample/{faceSampleId}/url")
public void redirectToFaceSampleUrl(@PathVariable Long faceSampleId, HttpServletResponse response) throws Exception {
FaceSampleEntity faceSample = faceRepository.getFaceSample(faceSampleId);
if (faceSample == null || faceSample.getFaceUrl() == null) {
response.setStatus(404);
return;
}
response.sendRedirect(faceSample.getFaceUrl());
}
/**
* 通过人脸ID重定向到人脸图片URL
*
* @param faceId 人脸ID
* @param response HTTP响应
*/
@GetMapping("/face/{faceId}/url")
public void redirectToFaceUrl(@PathVariable Long faceId, HttpServletResponse response) throws Exception {
FaceEntity face = faceRepository.getFace(faceId);
if (face == null || face.getFaceUrl() == null) {
response.setStatus(404);
return;
}
response.sendRedirect(face.getFaceUrl());
}
} }

View File

@@ -102,7 +102,7 @@ public class PuzzleGenerationOrchestrator {
} catch (Exception e) { } catch (Exception e) {
// 异步任务失败不影响主流程,仅记录日志 // 异步任务失败不影响主流程,仅记录日志
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e); log.error("异步生成拼图模板失败: scenicId={}, faceId={}, e={}", scenicId, faceId, e.getMessage());
} }
}, "PuzzleTemplateGenerator-" + scenicId + "-" + faceId).start(); }, "PuzzleTemplateGenerator-" + scenicId + "-" + faceId).start();
} }
@@ -142,8 +142,8 @@ public class PuzzleGenerationOrchestrator {
generateRequest.setFaceId(faceId); generateRequest.setFaceId(faceId);
generateRequest.setBusinessType("face_matching"); generateRequest.setBusinessType("face_matching");
generateRequest.setTemplateCode(template.getCode()); generateRequest.setTemplateCode(template.getCode());
generateRequest.setOutputFormat("PNG"); generateRequest.setOutputFormat("JPEG");
generateRequest.setQuality(90); generateRequest.setQuality(80);
generateRequest.setDynamicData(new HashMap<>(baseDynamicData)); generateRequest.setDynamicData(new HashMap<>(baseDynamicData));
generateRequest.setRequireRuleMatch(true); generateRequest.setRequireRuleMatch(true);

View File

@@ -39,6 +39,9 @@ public class DeleteOldRelationsStage extends AbstractPipelineStage<FaceMatchingC
@Autowired @Autowired
private MemberRelationRepository memberRelationRepository; private MemberRelationRepository memberRelationRepository;
@Autowired
private com.ycwl.basic.biz.FaceStatusManager faceStatusManager;
@Override @Override
public String getName() { public String getName() {
return "DeleteOldRelations"; return "DeleteOldRelations";
@@ -60,6 +63,7 @@ public class DeleteOldRelationsStage extends AbstractPipelineStage<FaceMatchingC
// 3. 清除缓存 // 3. 清除缓存
memberRelationRepository.clearSCacheByFace(faceId); memberRelationRepository.clearSCacheByFace(faceId);
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
log.debug("人脸旧关系数据删除完成:faceId={}", faceId); log.debug("人脸旧关系数据删除完成:faceId={}", faceId);

View File

@@ -39,6 +39,9 @@ public class PersistRelationsStage extends AbstractPipelineStage<FaceMatchingCon
@Autowired @Autowired
private MemberRelationRepository memberRelationRepository; private MemberRelationRepository memberRelationRepository;
@Autowired
private com.ycwl.basic.biz.FaceStatusManager faceStatusManager;
@Override @Override
public String getName() { public String getName() {
return "PersistRelations"; return "PersistRelations";
@@ -87,6 +90,7 @@ public class PersistRelationsStage extends AbstractPipelineStage<FaceMatchingCon
// 4. 清除缓存 // 4. 清除缓存
memberRelationRepository.clearSCacheByFace(faceId); memberRelationRepository.clearSCacheByFace(faceId);
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
return StageResult.success(String.format("持久化了%d条关联关系", validFiltered.size())); return StageResult.success(String.format("持久化了%d条关联关系", validFiltered.size()));

View File

@@ -162,13 +162,12 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
return resp; return resp;
} else if (errorCode == 222204) { } else if (errorCode == 222204) {
// error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试 // error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试
log.warn("无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl); // log.warn("无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
String base64Image = downloadImageAsBase64(faceUrl); String base64Image = downloadImageAsBase64(faceUrl);
if (base64Image != null) { if (base64Image != null) {
// 重试时也不需要限流,由外层调度器控制 // 重试时也不需要限流,由外层调度器控制
JSONObject retryResponse = client.addUser(base64Image, "BASE64", dbName, entityId, options); JSONObject retryResponse = client.addUser(base64Image, "BASE64", dbName, entityId, options);
if (retryResponse.getInt("error_code") == 0) { if (retryResponse.getInt("error_code") == 0) {
log.info("使用base64重试添加人脸成功,entityId: {}", entityId);
AddFaceResp resp = new AddFaceResp(); AddFaceResp resp = new AddFaceResp();
resp.setScore(100f); resp.setScore(100f);
return resp; return resp;
@@ -338,7 +337,7 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
return resp; return resp;
} else if (errorCode == 222204) { } else if (errorCode == 222204) {
// error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试 // error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试
log.warn("搜索人脸时无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl); // log.warn("搜索人脸时无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
String base64Image = downloadImageAsBase64(faceUrl); String base64Image = downloadImageAsBase64(faceUrl);
if (base64Image != null) { if (base64Image != null) {
try { try {

View File

@@ -1,6 +1,8 @@
package com.ycwl.basic.image.pipeline.stages; package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeService;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum; import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -41,4 +43,28 @@ public class WatermarkConfig {
*/ */
@Builder.Default @Builder.Default
private final Double scale = 1.0; private final Double scale = 1.0;
/**
* 边缘端水印服务(可选)
* 如果设置,将优先尝试使用边缘端处理
*/
private final WatermarkEdgeService edgeService;
/**
* 存储适配器(边缘端处理时需要)
* 用于上传原图和二维码到临时位置
*/
private final IStorageAdapter storageAdapter;
/**
* 是否启用边缘端处理
*/
@Builder.Default
private final boolean edgeEnabled = true;
/**
* 边缘端处理超时时间(毫秒)
*/
@Builder.Default
private final long edgeTimeoutMs = 10_000L;
} }

View File

@@ -3,6 +3,7 @@ package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageType; import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.watermark.ImageWatermarkFactory; import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeService;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo; import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum; import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.image.watermark.operator.IOperator; import com.ycwl.basic.image.watermark.operator.IOperator;
@@ -10,6 +11,7 @@ import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage; import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult; import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode; import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -21,6 +23,7 @@ import java.util.List;
/** /**
* 水印处理Stage * 水印处理Stage
* 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印 * 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印
* 支持边缘端渲染(可选)
*/ */
@Slf4j @Slf4j
@StageConfig( @StageConfig(
@@ -127,6 +130,19 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
File watermarkedFile = context.getTempFileManager() File watermarkedFile = context.getTempFileManager()
.createTempFile("watermark_" + type.getType(), "." + fileExt); .createTempFile("watermark_" + type.getType(), "." + fileExt);
// 尝试边缘端处理
if (shouldUseEdgeProcessing(type)) {
File edgeResult = tryEdgeProcessing(context, type, currentFile, watermarkedFile);
if (edgeResult != null && edgeResult.exists()) {
context.updateProcessedFile(edgeResult);
log.info("边缘端水印应用成功: type={}, size={}KB", type.getType(), edgeResult.length() / 1024);
return StageResult.success(String.format("水印(边缘端): %s (%dKB)",
type.getType(), edgeResult.length() / 1024));
}
log.warn("边缘端水印处理失败,降级到本地处理: type={}", type.getType());
}
// 本地处理(降级或直接使用)
WatermarkInfo watermarkInfo = buildWatermarkInfo(context, currentFile, watermarkedFile, type); WatermarkInfo watermarkInfo = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
IOperator operator = ImageWatermarkFactory.get(type); IOperator operator = ImageWatermarkFactory.get(type);
@@ -143,6 +159,46 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
type.getType(), result.length() / 1024)); type.getType(), result.length() / 1024));
} }
/**
* 判断是否应使用边缘端处理
*/
private boolean shouldUseEdgeProcessing(ImageWatermarkOperatorEnum type) {
if (!config.isEdgeEnabled()) {
return false;
}
WatermarkEdgeService edgeService = config.getEdgeService();
if (edgeService == null) {
return false;
}
IStorageAdapter storageAdapter = config.getStorageAdapter();
return storageAdapter != null;
}
/**
* 尝试使用边缘端处理
*
* @return 处理后的文件,失败返回 null
*/
private File tryEdgeProcessing(PhotoProcessContext context,
ImageWatermarkOperatorEnum type,
File currentFile,
File watermarkedFile) {
try {
WatermarkEdgeService edgeService = config.getEdgeService();
IStorageAdapter storageAdapter = config.getStorageAdapter();
// 构建水印信息用于边缘端处理
WatermarkInfo info = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
// 调用边缘端服务处理,传递 processId 作为 recordId
return edgeService.processWatermarkFromFile(info, type, storageAdapter, context.getProcessId());
} catch (Exception e) {
log.error("边缘端水印处理异常: type={}, error={}", type.getType(), e.getMessage(), e);
return null;
}
}
/** /**
* 构建水印参数 * 构建水印参数
*/ */

View File

@@ -3,7 +3,6 @@ package com.ycwl.basic.image.watermark;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum; import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.image.watermark.exception.ImageWatermarkUnsupportedException; import com.ycwl.basic.image.watermark.exception.ImageWatermarkUnsupportedException;
import com.ycwl.basic.image.watermark.operator.IOperator; import com.ycwl.basic.image.watermark.operator.IOperator;
import com.ycwl.basic.image.watermark.operator.DefaultImageWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.LeicaWatermarkOperator; import com.ycwl.basic.image.watermark.operator.LeicaWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.NormalWatermarkOperator; import com.ycwl.basic.image.watermark.operator.NormalWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.PrinterDefaultWatermarkOperator; import com.ycwl.basic.image.watermark.operator.PrinterDefaultWatermarkOperator;
@@ -18,11 +17,11 @@ public class ImageWatermarkFactory {
} }
public static IOperator get(ImageWatermarkOperatorEnum type) { public static IOperator get(ImageWatermarkOperatorEnum type) {
return switch (type) { return switch (type) {
case WATERMARK -> new DefaultImageWatermarkOperator();
case NORMAL -> new NormalWatermarkOperator(); case NORMAL -> new NormalWatermarkOperator();
case LEICA -> new LeicaWatermarkOperator(); case LEICA -> new LeicaWatermarkOperator();
case PRINTER_DEFAULT -> new PrinterDefaultWatermarkOperator(); case PRINTER_DEFAULT -> new PrinterDefaultWatermarkOperator();
default -> throw new ImageWatermarkUnsupportedException("不支持的类型" + type.name()); case PUZZLE_PRINT -> throw new ImageWatermarkUnsupportedException(
"PUZZLE_PRINT 仅支持边缘端处理,请使用 WatermarkEdgeService");
}; };
} }
} }

View File

@@ -0,0 +1,163 @@
package com.ycwl.basic.image.watermark.edge;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.utils.JacksonUtil;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
/**
* 水印模板构建器基类
* 提供构建元素的工具方法
*/
public abstract class AbstractWatermarkTemplateBuilder implements IWatermarkTemplateBuilder {
// 虚拟模板ID(运行时使用,不存储)
private static final AtomicLong VIRTUAL_TEMPLATE_ID = new AtomicLong(-1);
private static final AtomicLong VIRTUAL_ELEMENT_ID = new AtomicLong(-1);
/**
* 元素类型常量
*/
protected static final String ELEMENT_TYPE_IMAGE = "IMAGE";
protected static final String ELEMENT_TYPE_TEXT = "TEXT";
/**
* 图片适配模式
*/
protected static final String FIT_MODE_COVER = "COVER";
protected static final String FIT_MODE_CONTAIN = "CONTAIN";
/**
* 文本对齐方式
*/
protected static final String TEXT_ALIGN_LEFT = "LEFT";
protected static final String TEXT_ALIGN_RIGHT = "RIGHT";
protected static final String TEXT_ALIGN_CENTER = "CENTER";
/**
* 创建虚拟模板
*/
protected PuzzleTemplateEntity createTemplate(String code, int width, int height, String backgroundImage) {
PuzzleTemplateEntity template = new PuzzleTemplateEntity();
template.setId(VIRTUAL_TEMPLATE_ID.decrementAndGet());
template.setCode(code);
template.setName("水印模板-" + getStyle());
template.setCanvasWidth(width);
template.setCanvasHeight(height);
template.setBackgroundType(1); // 图片背景
template.setBackgroundImage(backgroundImage);
template.setStatus(1);
return template;
}
/**
* 创建纯色背景模板
*/
protected PuzzleTemplateEntity createTemplateWithColor(String code, int width, int height, String backgroundColor) {
PuzzleTemplateEntity template = new PuzzleTemplateEntity();
template.setId(VIRTUAL_TEMPLATE_ID.decrementAndGet());
template.setCode(code);
template.setName("水印模板-" + getStyle());
template.setCanvasWidth(width);
template.setCanvasHeight(height);
template.setBackgroundType(0); // 纯色背景
template.setBackgroundColor(backgroundColor);
template.setStatus(1);
return template;
}
/**
* 创建图片元素
*/
protected PuzzleElementEntity createImageElement(String key, String name, int x, int y, int width, int height, int zIndex,
String fitMode, Integer borderRadius, Integer opacity) {
PuzzleElementEntity element = new PuzzleElementEntity();
element.setId(VIRTUAL_ELEMENT_ID.decrementAndGet());
element.setElementType(ELEMENT_TYPE_IMAGE);
element.setElementKey(key);
element.setElementName(name);
element.setXPosition(x);
element.setYPosition(y);
element.setWidth(width);
element.setHeight(height);
element.setZIndex(zIndex);
element.setOpacity(opacity != null ? opacity : 100);
// 构建配置JSON
Map<String, Object> config = new HashMap<>();
config.put("imageFitMode", fitMode != null ? fitMode : FIT_MODE_COVER);
if (borderRadius != null && borderRadius > 0) {
config.put("borderRadius", borderRadius);
}
element.setConfig(JacksonUtil.toJson(config));
return element;
}
/**
* 创建圆形图片元素
*/
protected PuzzleElementEntity createCircleImageElement(String key, String name, int x, int y, int diameter, int zIndex) {
// 圆形 = borderRadius 为直径的一半
return createImageElement(key, name, x, y, diameter, diameter, zIndex, FIT_MODE_COVER, diameter / 2, null);
}
/**
* 创建文字元素
*/
protected PuzzleElementEntity createTextElement(String key, String name, int x, int y, int width, int height, int zIndex,
String fontFamily, int fontSize, String fontColor,
String fontWeight, String textAlign) {
PuzzleElementEntity element = new PuzzleElementEntity();
element.setId(VIRTUAL_ELEMENT_ID.decrementAndGet());
element.setElementType(ELEMENT_TYPE_TEXT);
element.setElementKey(key);
element.setElementName(name);
element.setXPosition(x);
element.setYPosition(y);
element.setWidth(width);
element.setHeight(height);
element.setZIndex(zIndex);
element.setOpacity(100);
// 构建配置JSON
Map<String, Object> config = new HashMap<>();
config.put("fontFamily", fontFamily != null ? fontFamily : "PingFang SC");
config.put("fontSize", fontSize);
config.put("fontColor", fontColor != null ? fontColor : "#FFFFFF");
config.put("fontWeight", fontWeight != null ? fontWeight : "NORMAL");
config.put("textAlign", textAlign != null ? textAlign : TEXT_ALIGN_LEFT);
element.setConfig(JacksonUtil.toJson(config));
return element;
}
/**
* 创建构建结果
*/
protected WatermarkTemplateResult createResult(PuzzleTemplateEntity template,
List<PuzzleElementEntity> elements,
Map<String, String> dynamicData) {
WatermarkTemplateResult result = new WatermarkTemplateResult();
result.setTemplate(template);
result.setElements(elements);
result.setDynamicData(dynamicData);
return result;
}
/**
* 创建空的元素列表和动态数据
*/
protected List<PuzzleElementEntity> newElementList() {
return new ArrayList<>();
}
protected Map<String, String> newDynamicData() {
return new HashMap<>();
}
}

View File

@@ -0,0 +1,21 @@
package com.ycwl.basic.image.watermark.edge;
/**
* 水印模板构建器接口
* 将水印参数转换为拼图模板+元素的形式,用于发送给边缘渲染任务
*/
public interface IWatermarkTemplateBuilder {
/**
* 构建水印模板
*
* @param request 水印请求参数
* @return 模板构建结果
*/
WatermarkTemplateResult build(WatermarkRequest request);
/**
* 获取水印风格标识
*/
String getStyle();
}

View File

@@ -0,0 +1,201 @@
package com.ycwl.basic.image.watermark.edge;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* 徕卡风格水印模板构建器
* 对应 LeicaWatermarkOperator
*
* 布局说明(百分比基于1920x1080量化,精度0.5%):
* - 画布大小 = 原图大小(不扩展)
* - 原图收缩放在画布上半部分,底部留出空间
* - 底部白色区域左侧:帧途 Logo + "帧途" 文字
* - 底部白色区域右侧:二维码(含头像)+ 景区名 + 日期时间
*/
@Component
public class LeicaWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
public static final String STYLE = "leica";
// 百分比常量配置(基于1920x1080量化,精度0.5%)
/** 底部额外区域占高度百分比 */
private static final double EXTRA_BOTTOM_PERCENT = 0.13; // 13%
/** Logo大小占高度百分比 */
private static final double LOGO_SIZE_PERCENT = 0.045; // 4.5%
/** Logo额外边距占高度百分比 */
private static final double LOGO_EXTRA_BORDER_PERCENT = 0.02; // 2%
/** Logo字体大小占高度百分比 */
private static final double LOGO_FONT_SIZE_PERCENT = 0.035; // 3.5%
/** 二维码大小占高度百分比 */
private static final double QRCODE_SIZE_PERCENT = 0.11; // 11%
/** 二维码X偏移占宽度百分比 */
private static final double QRCODE_OFFSET_X_PERCENT = 0.005; // 0.5%
/** 二维码Y偏移占高度百分比 */
private static final double QRCODE_OFFSET_Y_PERCENT = 0.02; // 2%
/** 左右边距占宽度百分比 */
private static final double OFFSET_X_PERCENT = 0.04; // 4%
/** 上下边距占高度百分比 */
private static final double OFFSET_Y_PERCENT = 0.03; // 3%
/** 景区名字体大小占高度百分比 */
private static final double SCENIC_FONT_SIZE_PERCENT = 0.03; // 3%
/** 日期时间字体大小占高度百分比 */
private static final double DATETIME_FONT_SIZE_PERCENT = 0.025; // 2.5%
private static final String LOGO_TEXT_COLOR = "#333333";
private static final String SCENIC_COLOR = "#333333";
private static final String DATETIME_COLOR = "#999999";
/**
* Logo 图片 URL(需要预先上传到 OSS)
*/
private static final String LOGO_URL = "https://oss.zhentuai.com/zt/zt-logo.png";
@Override
public String getStyle() {
return STYLE;
}
@Override
public WatermarkTemplateResult build(WatermarkRequest request) {
int imageWidth = request.getImageWidth();
int imageHeight = request.getImageHeight();
// 根据百分比计算实际像素值
int extraBottom = (int) (imageHeight * EXTRA_BOTTOM_PERCENT);
int logoSize = (int) (imageHeight * LOGO_SIZE_PERCENT);
int logoExtraBorder = (int) (imageHeight * LOGO_EXTRA_BORDER_PERCENT);
int logoFontSize = (int) (imageHeight * LOGO_FONT_SIZE_PERCENT);
int qrcodeSize = (int) (imageHeight * QRCODE_SIZE_PERCENT);
int qrcodeOffsetX = (int) (imageWidth * QRCODE_OFFSET_X_PERCENT);
int qrcodeOffsetY = (int) (imageHeight * QRCODE_OFFSET_Y_PERCENT);
int offsetX = (int) (imageWidth * OFFSET_X_PERCENT);
int offsetY = (int) (imageHeight * OFFSET_Y_PERCENT);
int scenicFontSize = (int) (imageHeight * SCENIC_FONT_SIZE_PERCENT);
int datetimeFontSize = (int) (imageHeight * DATETIME_FONT_SIZE_PERCENT);
// 画布大小 = 原图大小(不扩展)
int canvasWidth = imageWidth;
int canvasHeight = imageHeight;
// 原图收缩后的区域高度
int shrunkImageHeight = imageHeight - extraBottom;
// 底部区域起始 Y 坐标
int bottomAreaY = shrunkImageHeight;
// 创建模板(白色背景)
PuzzleTemplateEntity template = createTemplateWithColor(
"watermark_leica_" + System.currentTimeMillis(),
canvasWidth,
canvasHeight,
"#FFFFFF"
);
List<PuzzleElementEntity> elements = newElementList();
Map<String, String> dynamicData = newDynamicData();
// 1. 原图元素(收缩放在画布上半部分)
PuzzleElementEntity originalImageElement = createImageElement(
"originalImage", "原图",
0, 0,
imageWidth, shrunkImageHeight, 1,
FIT_MODE_COVER, null, null
);
elements.add(originalImageElement);
dynamicData.put("originalImage", request.getOriginalImageUrl());
// 2. Logo 元素(底部左侧)
int logoY = bottomAreaY + offsetY + logoExtraBorder;
PuzzleElementEntity logoElement = createImageElement(
"logo", "Logo",
offsetX, logoY - (int)(logoSize * 0.24),
logoSize, logoSize, 10,
FIT_MODE_CONTAIN, null, null
);
elements.add(logoElement);
dynamicData.put("logo", LOGO_URL);
// 3. "帧途" 文字(Logo 右边)
int logoTextX = offsetX + logoSize + (int)(imageWidth * 0.005);
int logoTextY = bottomAreaY + offsetY + logoExtraBorder;
PuzzleElementEntity logoTextElement = createTextElement(
"logoText", "帧途文字",
logoTextX, logoTextY,
(int)(imageWidth * 0.05), logoSize, 10,
"PingFang SC", logoFontSize, LOGO_TEXT_COLOR,
"NORMAL", TEXT_ALIGN_LEFT
);
elements.add(logoTextElement);
dynamicData.put("logoText", "帧途");
// 4. 计算右侧区域位置
// 估算文字宽度(使用景区名和日期的较大者)
int estimatedTextWidth = Math.max(
(request.getScenicLine() != null ? request.getScenicLine().length() : 0) * scenicFontSize / 2,
(request.getDatetimeLine() != null ? request.getDatetimeLine().length() : 0) * datetimeFontSize / 2
);
int qrcodeX = canvasWidth - offsetX - qrcodeSize - qrcodeOffsetX - estimatedTextWidth;
int qrcodeY = bottomAreaY + offsetY - qrcodeOffsetY;
// 5. 二维码元素
PuzzleElementEntity qrcodeElement = createImageElement(
"qrcode", "二维码",
qrcodeX, qrcodeY,
qrcodeSize, qrcodeSize, 10,
FIT_MODE_CONTAIN, null, null
);
elements.add(qrcodeElement);
dynamicData.put("qrcode", request.getQrcodeUrl());
// 6. 头像元素(二维码中央,可选)
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
int avatarDiameter = (int) (qrcodeSize * 0.45);
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
PuzzleElementEntity faceElement = createCircleImageElement(
"face", "头像",
avatarX, avatarY,
avatarDiameter, 20
);
elements.add(faceElement);
dynamicData.put("face", request.getFaceUrl());
}
// 7. 计算文字位置(与二维码垂直居中)
int qrcodeCenter = qrcodeY + qrcodeSize / 2;
int totalTextHeight = scenicFontSize + datetimeFontSize + (int)(imageHeight * 0.01);
int textY = qrcodeCenter - totalTextHeight / 2;
int textX = canvasWidth - offsetX - estimatedTextWidth;
// 8. 景区名文字
PuzzleElementEntity scenicTextElement = createTextElement(
"scenicLine", "景区名",
textX, textY,
estimatedTextWidth, scenicFontSize + (int)(imageHeight * 0.01), 30,
"PingFang SC", scenicFontSize, SCENIC_COLOR,
"NORMAL", TEXT_ALIGN_LEFT
);
elements.add(scenicTextElement);
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
// 9. 日期时间文字
int datetimeY = textY + scenicFontSize + (int)(imageHeight * 0.005);
PuzzleElementEntity datetimeTextElement = createTextElement(
"datetimeLine", "日期时间",
textX, datetimeY,
estimatedTextWidth, datetimeFontSize + (int)(imageHeight * 0.01), 30,
"PingFang SC", datetimeFontSize, DATETIME_COLOR,
"NORMAL", TEXT_ALIGN_LEFT
);
elements.add(datetimeTextElement);
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
return createResult(template, elements, dynamicData);
}
}

View File

@@ -0,0 +1,144 @@
package com.ycwl.basic.image.watermark.edge;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* Normal 风格水印模板构建器
* 对应 NormalWatermarkOperator
*
* 布局说明(百分比基于1920x1080量化,精度0.5%):
* - 白色背景 + 原图元素(COVER模式)
* - 左下角:圆形二维码(右边界在宽度45%位置)
* - 二维码中央:圆形头像(可选)
* - 二维码右侧:景区名 + 日期时间 两行文字(白色,左对齐)
*/
@Component
public class NormalWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
public static final String STYLE = "normal";
// 百分比常量配置(基于1920x1080量化,精度0.5%)
/** 底部距离占高度百分比 */
private static final double BOTTOM_OFFSET_PERCENT = 0.085; // 8.5%
/** 二维码大小占宽度百分比 */
private static final double QRCODE_SIZE_PERCENT = 0.08; // 8%
/** 二维码右边界占宽度百分比 */
private static final double QRCODE_RIGHT_PERCENT = 0.45; // 45%
/** 二维码Y方向偏移(向上)占高度百分比 */
private static final double QRCODE_OFFSET_Y_PERCENT = 0.02; // 2%
/** 文字区域起始X位置占宽度百分比 */
private static final double TEXT_START_X_PERCENT = 0.455; // 45.5%
/** 字体大小占高度百分比 */
private static final double FONT_SIZE_PERCENT = 0.04; // 4%
/** 文字行间距占高度百分比 */
private static final double LINE_SPACING_PERCENT = 0.005; // 0.5%
/** 文字区域右边距占宽度百分比 */
private static final double TEXT_RIGHT_MARGIN_PERCENT = 0.01; // 1%
private static final String FONT_COLOR = "#FFFFFF";
@Override
public String getStyle() {
return STYLE;
}
@Override
public WatermarkTemplateResult build(WatermarkRequest request) {
int imageWidth = request.getImageWidth();
int imageHeight = request.getImageHeight();
// 根据百分比计算实际像素值
int bottomOffset = (int) (imageHeight * BOTTOM_OFFSET_PERCENT);
int qrcodeSize = (int) (imageWidth * QRCODE_SIZE_PERCENT);
int qrcodeRightX = (int) (imageWidth * QRCODE_RIGHT_PERCENT);
int qrcodeOffsetY = (int) (imageHeight * QRCODE_OFFSET_Y_PERCENT);
int textStartX = (int) (imageWidth * TEXT_START_X_PERCENT);
int fontSize = (int) (imageHeight * FONT_SIZE_PERCENT);
int lineSpacing = (int) (imageHeight * LINE_SPACING_PERCENT);
int textRightMargin = (int) (imageWidth * TEXT_RIGHT_MARGIN_PERCENT);
// 创建模板(白色背景,原图作为元素实现 COVER 模式)
PuzzleTemplateEntity template = createTemplateWithColor(
"watermark_normal_" + System.currentTimeMillis(),
imageWidth,
imageHeight,
"#FFFFFF"
);
List<PuzzleElementEntity> elements = newElementList();
Map<String, String> dynamicData = newDynamicData();
// 0. 原图元素(z-index=1,最底层,COVER模式)
PuzzleElementEntity originalImageElement = createImageElement(
"originalImage", "原图",
0, 0,
imageWidth, imageHeight, 1,
FIT_MODE_COVER, null, null
);
elements.add(originalImageElement);
dynamicData.put("originalImage", request.getOriginalImageUrl());
// 计算二维码位置(右边界在45%位置,向左推算左边界)
int qrcodeX = qrcodeRightX - qrcodeSize;
int qrcodeY = imageHeight - bottomOffset - qrcodeSize - qrcodeOffsetY;
// 1. 二维码元素(圆形裁切)
PuzzleElementEntity qrcodeElement = createCircleImageElement(
"qrcode", "二维码",
qrcodeX, qrcodeY,
qrcodeSize, 10
);
elements.add(qrcodeElement);
dynamicData.put("qrcode", request.getQrcodeUrl());
// 2. 头像元素(圆形,二维码中央,可选)
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
int avatarDiameter = (int) (qrcodeSize * 0.45);
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
PuzzleElementEntity faceElement = createCircleImageElement(
"face", "头像",
avatarX, avatarY,
avatarDiameter, 20
);
elements.add(faceElement);
dynamicData.put("face", request.getFaceUrl());
}
// 3. 景区名文字(在二维码右侧,从45.5%位置开始,左对齐)
// 文字垂直居中于二维码区域
int textAreaHeight = fontSize * 2 + lineSpacing;
int textY = qrcodeY + (qrcodeSize - textAreaHeight) / 2;
PuzzleElementEntity scenicTextElement = createTextElement(
"scenicLine", "景区名",
textStartX, textY,
imageWidth - textStartX - textRightMargin, fontSize + lineSpacing, 30,
"PingFang SC", fontSize, FONT_COLOR,
"NORMAL", TEXT_ALIGN_LEFT
);
elements.add(scenicTextElement);
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
// 4. 日期时间文字(在景区名下方,左对齐)
int datetimeY = textY + fontSize + lineSpacing;
PuzzleElementEntity datetimeTextElement = createTextElement(
"datetimeLine", "日期时间",
textStartX, datetimeY,
imageWidth - textStartX - textRightMargin, fontSize + lineSpacing, 30,
"PingFang SC", fontSize, FONT_COLOR,
"NORMAL", TEXT_ALIGN_LEFT
);
elements.add(datetimeTextElement);
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
return createResult(template, elements, dynamicData);
}
}

View File

@@ -0,0 +1,147 @@
package com.ycwl.basic.image.watermark.edge;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* 打印专用水印模板构建器
* 对应 PrinterDefaultWatermarkOperator
*
* 布局说明:
* - 白色背景 + 原图元素(COVER模式)
* - 左下角:圆形二维码(带白色圆形背景)
* - 二维码中央:圆形头像(可选)
* - 右下角:景区名 + 日期时间 两行文字(白色,右对齐)
* - 支持缩放和四边偏移
*/
@Component
public class PrinterDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
public static final String STYLE = "pDefault";
// 常量配置(与 PrinterDefaultWatermarkOperator 保持一致)
private static final int OFFSET_Y = 15;
private static final int QRCODE_SIZE = 150;
private static final double QRCODE_LEFT_MARGIN_RATIO = 0.05;
private static final int QRCODE_OFFSET_Y = -35;
private static final int SCENIC_FONT_SIZE = 42;
private static final int DATETIME_FONT_SIZE = 42;
private static final String FONT_COLOR = "#FFFFFF";
private static final double TEXT_RIGHT_MARGIN_RATIO = 0.05;
@Override
public String getStyle() {
return STYLE;
}
@Override
public WatermarkTemplateResult build(WatermarkRequest request) {
int imageWidth = request.getImageWidth();
int imageHeight = request.getImageHeight();
double scale = request.getScaleValue();
// 应用缩放
int scaledOffsetY = (int) (OFFSET_Y * scale);
int scaledQrcodeSize = (int) (QRCODE_SIZE * scale);
int scaledQrcodeOffsetY = (int) (QRCODE_OFFSET_Y * scale);
int scaledScenicFontSize = (int) (SCENIC_FONT_SIZE * scale);
int scaledDatetimeFontSize = (int) (DATETIME_FONT_SIZE * scale);
// 获取偏移值
int offsetLeft = (int) (request.getOffsetLeftValue() * scale);
int offsetRight = (int) (request.getOffsetRightValue() * scale);
int offsetBottom = (int) (request.getOffsetBottomValue() * scale);
// 创建模板(白色背景,原图作为元素实现 COVER 模式)
PuzzleTemplateEntity template = createTemplateWithColor(
"watermark_printer_" + System.currentTimeMillis(),
imageWidth,
imageHeight,
"#FFFFFF"
);
List<PuzzleElementEntity> elements = newElementList();
Map<String, String> dynamicData = newDynamicData();
// 0. 原图元素(z-index=1,最底层,COVER模式)
PuzzleElementEntity originalImageElement = createImageElement(
"originalImage", "原图",
0, 0,
imageWidth, imageHeight, 1,
FIT_MODE_COVER, null, null
);
elements.add(originalImageElement);
dynamicData.put("originalImage", request.getOriginalImageUrl());
// 计算二维码位置
int qrcodeWidth = scaledQrcodeSize;
int qrcodeHeight = scaledQrcodeSize;
int qrcodeX = (int) (imageWidth * QRCODE_LEFT_MARGIN_RATIO) + offsetLeft;
int qrcodeY = imageHeight - scaledOffsetY - qrcodeHeight - offsetBottom;
// 1. 二维码元素(圆形裁切)
PuzzleElementEntity qrcodeElement = createCircleImageElement(
"qrcode", "二维码",
qrcodeX, qrcodeY + scaledQrcodeOffsetY,
qrcodeHeight, 10
);
elements.add(qrcodeElement);
dynamicData.put("qrcode", request.getQrcodeUrl());
// 2. 头像元素(圆形,二维码中央,可选)
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
int avatarDiameter = (int) (qrcodeHeight * 0.45);
int avatarX = qrcodeX + (qrcodeWidth - avatarDiameter) / 2;
int avatarY = qrcodeY + scaledQrcodeOffsetY + (qrcodeHeight - avatarDiameter) / 2;
PuzzleElementEntity faceElement = createCircleImageElement(
"face", "头像",
avatarX, avatarY,
avatarDiameter, 20
);
elements.add(faceElement);
dynamicData.put("face", request.getFaceUrl());
}
// 3. 计算文字位置(右对齐)
int textRightX = imageWidth - (int) (imageWidth * TEXT_RIGHT_MARGIN_RATIO) - offsetRight;
int textWidth = textRightX - qrcodeX - qrcodeWidth - 20;
// 计算垂直居中
int qrcodeTop = qrcodeY + scaledQrcodeOffsetY;
int qrcodeBottom = qrcodeTop + qrcodeHeight;
int qrcodeCenter = (qrcodeTop + qrcodeBottom) / 2;
int totalTextHeight = scaledScenicFontSize + scaledDatetimeFontSize + 10;
int textY = qrcodeCenter - totalTextHeight / 2;
// 4. 景区名文字(右对齐)
PuzzleElementEntity scenicTextElement = createTextElement(
"scenicLine", "景区名",
textRightX - textWidth, textY,
textWidth, scaledScenicFontSize + 10, 30,
"PingFang SC", scaledScenicFontSize, FONT_COLOR,
"BOLD", TEXT_ALIGN_RIGHT
);
elements.add(scenicTextElement);
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
// 5. 日期时间文字(右对齐)
int datetimeY = textY + scaledScenicFontSize + 5;
PuzzleElementEntity datetimeTextElement = createTextElement(
"datetimeLine", "日期时间",
textRightX - textWidth, datetimeY,
textWidth, scaledDatetimeFontSize + 10, 30,
"PingFang SC", scaledDatetimeFontSize, FONT_COLOR,
"BOLD", TEXT_ALIGN_RIGHT
);
elements.add(datetimeTextElement);
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
return createResult(template, elements, dynamicData);
}
}

View File

@@ -0,0 +1,143 @@
package com.ycwl.basic.image.watermark.edge;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* 拼图默认水印模板构建器
*
* 布局说明:
* - 白色背景
* - 顶部100%为原图区域(COVER模式,保持原图完整尺寸)
* - 底部扩展10%为信息区域:
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
*/
@Component
public class PuzzleDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
public static final String STYLE = "puzzle_default";
// 布局比例配置
private static final double BOTTOM_EXTEND_RATIO = 0.10; // 底部扩展为原图高度的10%
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
// 文字配置
private static final int SCENIC_FONT_SIZE = 52;
private static final int DATETIME_FONT_SIZE = 42;
private static final String SCENIC_COLOR = "#333333";
private static final String DATETIME_COLOR = "#999999";
@Override
public String getStyle() {
return STYLE;
}
@Override
public WatermarkTemplateResult build(WatermarkRequest request) {
int imageWidth = request.getImageWidth();
int imageHeight = request.getImageHeight();
// 底部扩展区域高度
int bottomAreaHeight = (int) (imageHeight * BOTTOM_EXTEND_RATIO);
// 画布尺寸 = 原图尺寸 + 底部扩展
int canvasWidth = imageWidth;
int canvasHeight = imageHeight + bottomAreaHeight;
// 原图区域保持完整高度
int originalImageHeight = imageHeight;
// 创建模板(白色背景)
PuzzleTemplateEntity template = createTemplateWithColor(
"watermark_puzzle_default_" + System.currentTimeMillis(),
canvasWidth,
canvasHeight,
"#FFFFFF"
);
List<PuzzleElementEntity> elements = newElementList();
Map<String, String> dynamicData = newDynamicData();
// 1. 原图元素(顶部90%区域,COVER模式)
PuzzleElementEntity originalImageElement = createImageElement(
"originalImage", "原图",
0, 0,
canvasWidth, originalImageHeight, 1,
FIT_MODE_COVER, null, null
);
elements.add(originalImageElement);
dynamicData.put("originalImage", request.getOriginalImageUrl());
// 2. 计算底部区域元素位置
int marginX = (int) (canvasWidth * MARGIN_X_RATIO);
int qrcodeSize = (int) (canvasHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
// 二维码垂直居中于底部区域
int qrcodeX = marginX;
int qrcodeY = originalImageHeight + (bottomAreaHeight - qrcodeSize) / 2;
// 3. 二维码元素
PuzzleElementEntity qrcodeElement = createImageElement(
"qrcode", "二维码",
qrcodeX, qrcodeY,
qrcodeSize, qrcodeSize, 10,
FIT_MODE_CONTAIN, null, null
);
elements.add(qrcodeElement);
dynamicData.put("qrcode", request.getQrcodeUrl());
// 4. 头像元素(二维码中央,可选)
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
int avatarDiameter = (int) (qrcodeSize * 0.45);
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
PuzzleElementEntity faceElement = createCircleImageElement(
"face", "头像",
avatarX, avatarY,
avatarDiameter, 20
);
elements.add(faceElement);
dynamicData.put("face", request.getFaceUrl());
}
// 5. 计算右侧文字区域
int textRightX = canvasWidth - marginX;
int textWidth = textRightX - qrcodeX - qrcodeSize - marginX;
// 文字与二维码垂直居中
int totalTextHeight = SCENIC_FONT_SIZE + DATETIME_FONT_SIZE + 5;
int textY = originalImageHeight + (bottomAreaHeight - totalTextHeight) / 2;
// 6. 景区名文字(右对齐)
PuzzleElementEntity scenicTextElement = createTextElement(
"scenicLine", "景区名",
qrcodeX + qrcodeSize + marginX, textY,
textWidth, SCENIC_FONT_SIZE + 10, 30,
"PingFang SC", SCENIC_FONT_SIZE, SCENIC_COLOR,
"NORMAL", TEXT_ALIGN_RIGHT
);
elements.add(scenicTextElement);
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
// 7. 日期时间文字(右对齐)
int datetimeY = textY + SCENIC_FONT_SIZE + 5;
PuzzleElementEntity datetimeTextElement = createTextElement(
"datetimeLine", "日期时间",
qrcodeX + qrcodeSize + marginX, datetimeY,
textWidth, DATETIME_FONT_SIZE + 10, 30,
"PingFang SC", DATETIME_FONT_SIZE, DATETIME_COLOR,
"NORMAL", TEXT_ALIGN_RIGHT
);
elements.add(datetimeTextElement);
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
return createResult(template, elements, dynamicData);
}
}

View File

@@ -0,0 +1,157 @@
package com.ycwl.basic.image.watermark.edge;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* 拼图打印水印模板构建器
*
* 布局说明:
* - 白色背景
* - 四周留1%白边
* - 内部区域:顶部100%为原图区域(COVER模式,保持原图完整尺寸)
* - 底部扩展10%为信息区域:
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
*/
@Component
public class PuzzlePrintWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
public static final String STYLE = "puzzle_print";
// 布局比例配置
private static final double BORDER_RATIO = 0.01; // 四周白边为1%
private static final double BOTTOM_EXTEND_RATIO = 0.10; // 底部扩展为原图高度的10%
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
// 文字配置
private static final int SCENIC_FONT_SIZE = 52;
private static final int DATETIME_FONT_SIZE = 42;
private static final String SCENIC_COLOR = "#333333";
private static final String DATETIME_COLOR = "#999999";
@Override
public String getStyle() {
return STYLE;
}
@Override
public WatermarkTemplateResult build(WatermarkRequest request) {
int imageWidth = request.getImageWidth();
int imageHeight = request.getImageHeight();
// 计算白边尺寸(基于原图尺寸的1%)
int borderX = (int) (imageWidth * BORDER_RATIO);
int borderY = (int) (imageHeight * BORDER_RATIO);
// 底部扩展区域高度
int bottomAreaHeight = (int) (imageHeight * BOTTOM_EXTEND_RATIO);
// 内容区高度 = 原图高度 + 扩展区域(扩展区域在白边内部)
int contentHeight = imageHeight + bottomAreaHeight;
// 画布尺寸 = 内容区尺寸 + 四周白边
int canvasWidth = imageWidth + borderX * 2;
int canvasHeight = contentHeight + borderY * 2;
// 内容区起始位置(白边内)
int contentStartX = borderX;
int contentStartY = borderY;
// 内容区宽度 = 原图宽度,原图区域保持完整高度
int contentWidth = imageWidth;
int originalImageHeight = imageHeight;
// 创建模板(白色背景)
PuzzleTemplateEntity template = createTemplateWithColor(
"watermark_puzzle_print_" + System.currentTimeMillis(),
canvasWidth,
canvasHeight,
"#FFFFFF"
);
List<PuzzleElementEntity> elements = newElementList();
Map<String, String> dynamicData = newDynamicData();
// 1. 原图元素(内容区顶部90%,COVER模式)
PuzzleElementEntity originalImageElement = createImageElement(
"originalImage", "原图",
contentStartX, contentStartY,
contentWidth, originalImageHeight, 1,
FIT_MODE_COVER, null, null
);
elements.add(originalImageElement);
dynamicData.put("originalImage", request.getOriginalImageUrl());
// 2. 计算底部区域元素位置(相对于内容区)
int marginX = (int) (contentWidth * MARGIN_X_RATIO);
int qrcodeSize = (int) (imageHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
// 二维码垂直居中于底部区域
int qrcodeX = contentStartX + marginX;
int qrcodeY = contentStartY + originalImageHeight + (bottomAreaHeight - qrcodeSize) / 2;
// 3. 二维码元素
PuzzleElementEntity qrcodeElement = createImageElement(
"qrcode", "二维码",
qrcodeX, qrcodeY,
qrcodeSize, qrcodeSize, 10,
FIT_MODE_CONTAIN, null, null
);
elements.add(qrcodeElement);
dynamicData.put("qrcode", request.getQrcodeUrl());
// 4. 头像元素(二维码中央,可选)
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
int avatarDiameter = (int) (qrcodeSize * 0.45);
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
PuzzleElementEntity faceElement = createCircleImageElement(
"face", "头像",
avatarX, avatarY,
avatarDiameter, 20
);
elements.add(faceElement);
dynamicData.put("face", request.getFaceUrl());
}
// 5. 计算右侧文字区域
int textRightX = contentStartX + contentWidth - marginX;
int textWidth = textRightX - qrcodeX - qrcodeSize - marginX;
// 文字与二维码垂直居中
int totalTextHeight = SCENIC_FONT_SIZE + DATETIME_FONT_SIZE + 5;
int textY = contentStartY + originalImageHeight + (bottomAreaHeight - totalTextHeight) / 2;
// 6. 景区名文字(右对齐)
PuzzleElementEntity scenicTextElement = createTextElement(
"scenicLine", "景区名",
qrcodeX + qrcodeSize + marginX, textY,
textWidth, SCENIC_FONT_SIZE + 10, 30,
"PingFang SC", SCENIC_FONT_SIZE, SCENIC_COLOR,
"NORMAL", TEXT_ALIGN_RIGHT
);
elements.add(scenicTextElement);
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
// 7. 日期时间文字(右对齐)
int datetimeY = textY + SCENIC_FONT_SIZE + 5;
PuzzleElementEntity datetimeTextElement = createTextElement(
"datetimeLine", "日期时间",
qrcodeX + qrcodeSize + marginX, datetimeY,
textWidth, DATETIME_FONT_SIZE + 10, 30,
"PingFang SC", DATETIME_FONT_SIZE, DATETIME_COLOR,
"NORMAL", TEXT_ALIGN_RIGHT
);
elements.add(datetimeTextElement);
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
return createResult(template, elements, dynamicData);
}
}

View File

@@ -0,0 +1,330 @@
package com.ycwl.basic.image.watermark.edge;
import cn.hutool.core.date.DateUtil;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.constant.StorageConstant;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.storage.enums.StorageAcl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Date;
import java.util.UUID;
/**
* 水印边缘端处理服务
* 将原有的 IOperator 本地处理迁移到边缘端渲染
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WatermarkEdgeService {
private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator;
/**
* 默认等待超时时间(毫秒)
*/
private static final long DEFAULT_TIMEOUT_MS = 30_000L;
/**
* 使用边缘端处理水印(适用于 GoodsServiceImpl 场景)
* 直接传入 URL,不需要本地文件
*
* @param type 水印类型
* @param originalUrl 原图URL
* @param qrcodeUrl 二维码URL
* @param faceUrl 头像URL(可选)
* @param scenicLine 景区名称
* @param datetime 日期时间
* @param dtFormat 日期格式
* @param sourceId 关联的sourceId(用于记录追踪)
* @param faceId 人脸ID(可选)
* @return 带水印的图片URL,处理失败返回null
*/
public String processWatermark(ImageWatermarkOperatorEnum type,
String originalUrl,
String qrcodeUrl,
String faceUrl,
String scenicLine,
Date datetime,
String dtFormat,
Long sourceId,
Long faceId) {
return processWatermark(type, originalUrl, qrcodeUrl, faceUrl, scenicLine, datetime, dtFormat,
sourceId, faceId, null, null, null, null, null);
}
/**
* 使用边缘端处理水印(完整参数版本)
*
* @param type 水印类型
* @param originalUrl 原图URL
* @param qrcodeUrl 二维码URL
* @param faceUrl 头像URL(可选)
* @param scenicLine 景区名称
* @param datetime 日期时间
* @param dtFormat 日期格式
* @param sourceId 关联的sourceId(用于记录追踪)
* @param faceId 人脸ID(可选)
* @param scale 缩放倍数(可选)
* @param offsetLeft 左偏移(可选)
* @param offsetRight 右偏移(可选)
* @param offsetTop 上偏移(可选)
* @param offsetBottom 下偏移(可选)
* @return 带水印的图片URL,处理失败返回null
*/
public String processWatermark(ImageWatermarkOperatorEnum type,
String originalUrl,
String qrcodeUrl,
String faceUrl,
String scenicLine,
Date datetime,
String dtFormat,
Long sourceId,
Long faceId,
Double scale,
Integer offsetLeft,
Integer offsetRight,
Integer offsetTop,
Integer offsetBottom) {
// 将 ImageWatermarkOperatorEnum 映射到边缘端风格
String style = mapTypeToStyle(type);
// 检查边缘端是否支持该风格
if (!watermarkEdgeTaskCreator.isStyleSupported(style)) {
log.warn("边缘端不支持水印风格: {}", style);
return null;
}
try {
// 获取图片尺寸
int[] dimensions = getImageDimensions(originalUrl);
if (dimensions == null) {
log.error("无法获取图片尺寸: {}", originalUrl);
return null;
}
// 构建日期时间行
String datetimeLine = datetime != null && dtFormat != null
? DateUtil.format(datetime, dtFormat)
: null;
// 构建水印请求
WatermarkRequest request = WatermarkRequest.builder()
.originalImageUrl(originalUrl)
.imageWidth(dimensions[0])
.imageHeight(dimensions[1])
.qrcodeUrl(qrcodeUrl)
.faceUrl(faceUrl)
.scenicLine(scenicLine)
.datetimeLine(datetimeLine)
.scale(scale)
.offsetLeft(offsetLeft)
.offsetRight(offsetRight)
.offsetTop(offsetTop)
.offsetBottom(offsetBottom)
.outputFormat(type.getPreferFileType().equalsIgnoreCase("png") ? "PNG" : "JPEG")
.outputQuality(90)
.build();
// 创建边缘任务并等待结果
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
style,
request,
sourceId, // recordId
faceId,
type.getType(), // watermarkType
DEFAULT_TIMEOUT_MS
);
if (result.isSuccess()) {
log.info("边缘端水印处理成功: sourceId={}, type={}, url={}", sourceId, type, result.getImageUrl());
return result.getImageUrl();
} else {
log.error("边缘端水印处理失败: sourceId={}, type={}, error={}", sourceId, type, result.getErrorMessage());
return null;
}
} catch (Exception e) {
log.error("边缘端水印处理异常: sourceId={}, type={}", sourceId, type, e);
return null;
}
}
/**
* 使用边缘端处理水印(适用于 WatermarkStage / Pipeline 场景)
* 从本地文件处理,需要先上传原图和二维码
*
* @param info 水印信息(包含本地文件)
* @param type 水印类型
* @param adapter 存储适配器
* @param recordId 记录ID(用于边缘端任务追踪,不能为空)
* @return 处理后的本地文件,失败返回null
*/
public File processWatermarkFromFile(WatermarkInfo info,
ImageWatermarkOperatorEnum type,
IStorageAdapter adapter,
String recordId) {
// 将 ImageWatermarkOperatorEnum 映射到边缘端风格
String style = mapTypeToStyle(type);
// 检查边缘端是否支持该风格
if (!watermarkEdgeTaskCreator.isStyleSupported(style)) {
log.warn("边缘端不支持水印风格: {}", style);
return null;
}
String uploadedOriginalUrl = null;
String uploadedQrcodeUrl = null;
String uploadedFaceUrl = null;
try {
// 1. 获取图片尺寸
BufferedImage originalImage = ImageIO.read(info.getOriginalFile());
if (originalImage == null) {
log.error("无法读取原图文件: {}", info.getOriginalFile());
return null;
}
int imageWidth = originalImage.getWidth();
int imageHeight = originalImage.getHeight();
originalImage.flush();
// 2. 上传原图到临时位置
String originalFileName = "temp_watermark_" + UUID.randomUUID() + ".jpg";
uploadedOriginalUrl = adapter.uploadFile(null, info.getOriginalFile(),
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", originalFileName);
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", originalFileName);
// 3. 上传二维码(如果有)
if (info.getQrcodeFile() != null && info.getQrcodeFile().exists()) {
String qrcodeFileName = "temp_qrcode_" + UUID.randomUUID() + ".jpg";
uploadedQrcodeUrl = adapter.uploadFile(null, info.getQrcodeFile(),
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", qrcodeFileName);
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", qrcodeFileName);
}
// 4. 上传头像(如果有)
if (info.getFaceFile() != null && info.getFaceFile().exists()) {
String faceFileName = "temp_face_" + UUID.randomUUID() + ".jpg";
uploadedFaceUrl = adapter.uploadFile(null, info.getFaceFile(),
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", faceFileName);
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", faceFileName);
}
// 5. 构建水印请求
WatermarkRequest request = WatermarkRequest.builder()
.originalImageUrl(uploadedOriginalUrl)
.imageWidth(imageWidth)
.imageHeight(imageHeight)
.qrcodeUrl(uploadedQrcodeUrl)
.faceUrl(uploadedFaceUrl)
.scenicLine(info.getScenicLine())
.datetimeLine(info.getDatetimeLine())
.scale(info.getScale())
.offsetLeft(info.getOffsetLeft())
.offsetRight(info.getOffsetRight())
.offsetTop(info.getOffsetTop())
.offsetBottom(info.getOffsetBottom())
.outputFormat(type.getPreferFileType().equalsIgnoreCase("png") ? "PNG" : "JPEG")
.outputQuality(90)
.build();
// 6. 创建边缘任务并等待结果(使用传入的 recordId)
// recordId 转换为 Long,如果无法转换则使用哈希值
Long recordIdLong;
try {
recordIdLong = Long.parseLong(recordId);
} catch (NumberFormatException e) {
// 如果 recordId 不是数字(如 UUID),使用其哈希值的绝对值
recordIdLong = (long) Math.abs(recordId.hashCode());
}
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
style,
request,
recordIdLong, // recordId
null, // faceId
type.getType(), // watermarkType
DEFAULT_TIMEOUT_MS
);
if (!result.isSuccess()) {
log.error("边缘端水印处理失败: recordId={}, error={}", recordId, result.getErrorMessage());
return null;
}
// 7. 下载结果到目标文件
String resultUrl = result.getImageUrl();
File outputFile = info.getWatermarkedFile();
downloadFile(resultUrl, outputFile);
log.info("边缘端水印处理成功: recordId={}, type={}, outputFile={}", recordId, type, outputFile);
return outputFile;
} catch (Exception e) {
log.error("边缘端水印处理异常: recordId={}, type={}", recordId, type, e);
return null;
}
}
/**
* 将 ImageWatermarkOperatorEnum 映射到边缘端风格
*/
private String mapTypeToStyle(ImageWatermarkOperatorEnum type) {
if (type == null) {
return null;
}
return switch (type) {
case NORMAL -> NormalWatermarkTemplateBuilder.STYLE;
case LEICA -> LeicaWatermarkTemplateBuilder.STYLE;
case PRINTER_DEFAULT -> PrinterDefaultWatermarkTemplateBuilder.STYLE;
case PUZZLE_PRINT -> PuzzlePrintWatermarkTemplateBuilder.STYLE;
};
}
/**
* 获取图片尺寸
*
* @param imageUrl 图片URL
* @return [width, height],失败返回null
*/
private int[] getImageDimensions(String imageUrl) {
try {
// 替换内网域名
String url = imageUrl.replace("oss.zhentuai.com",
"frametour-assets.oss-cn-shanghai-internal.aliyuncs.com");
BufferedImage image = ImageIO.read(new URL(url));
if (image == null) {
return null;
}
int[] dimensions = new int[]{image.getWidth(), image.getHeight()};
image.flush();
return dimensions;
} catch (IOException e) {
log.error("获取图片尺寸失败: {}", imageUrl, e);
return null;
}
}
/**
* 下载文件
*/
private void downloadFile(String url, File targetFile) throws IOException {
// 替换内网域名
String downloadUrl = url.replace("oss.zhentuai.com",
"frametour-assets.oss-cn-shanghai-internal.aliyuncs.com");
HttpUtil.downloadFile(downloadUrl, targetFile);
}
}

View File

@@ -0,0 +1,111 @@
package com.ycwl.basic.image.watermark.edge;
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 水印边缘任务创建服务
* 将水印请求转换为边缘渲染任务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WatermarkEdgeTaskCreator {
private final PuzzleEdgeRenderTaskService edgeRenderTaskService;
private final List<IWatermarkTemplateBuilder> builders;
private final Map<String, IWatermarkTemplateBuilder> builderMap = new HashMap<>();
@PostConstruct
public void init() {
for (IWatermarkTemplateBuilder builder : builders) {
builderMap.put(builder.getStyle(), builder);
log.info("注册水印模板构建器: {}", builder.getStyle());
}
}
/**
* 创建水印渲染任务
*
* @param style 水印风格(normal/leica/printer_default)
* @param request 水印请求参数
* @param recordId 原始拼图记录ID(用于关联)
* @param faceId 人脸ID(可选)
* @param watermarkType 水印类型标识(如 print、free_download)
* @return 任务ID
*/
public Long createTask(String style,
WatermarkRequest request,
Long recordId,
Long faceId,
String watermarkType) {
IWatermarkTemplateBuilder builder = builderMap.get(style);
if (builder == null) {
throw new IllegalArgumentException("未知的水印风格: " + style);
}
// 构建水印模板
WatermarkTemplateResult result = builder.build(request);
// 创建边缘渲染任务
Long taskId = edgeRenderTaskService.createWatermarkRenderTask(
recordId,
faceId,
watermarkType,
result.getTemplate(),
result.getElements(),
result.getDynamicData(),
request.getOutputFormat(),
request.getOutputQuality()
);
log.info("创建水印边缘渲染任务: style={}, taskId={}, recordId={}, watermarkType={}",
style, taskId, recordId, watermarkType);
return taskId;
}
/**
* 创建水印渲染任务并等待结果
*
* @param style 水印风格
* @param request 水印请求参数
* @param recordId 原始拼图记录ID
* @param faceId 人脸ID
* @param watermarkType 水印类型
* @param timeoutMs 超时时间(毫秒)
* @return 任务结果
*/
public PuzzleEdgeRenderTaskService.TaskWaitResult createAndWait(String style,
WatermarkRequest request,
Long recordId,
Long faceId,
String watermarkType,
long timeoutMs) {
Long taskId = createTask(style, request, recordId, faceId, watermarkType);
edgeRenderTaskService.registerWait(taskId);
return edgeRenderTaskService.waitForTask(taskId, timeoutMs);
}
/**
* 获取支持的水印风格列表
*/
public List<String> getSupportedStyles() {
return List.copyOf(builderMap.keySet());
}
/**
* 检查是否支持指定的水印风格
*/
public boolean isStyleSupported(String style) {
return builderMap.containsKey(style);
}
}

View File

@@ -0,0 +1,92 @@
package com.ycwl.basic.image.watermark.edge;
import lombok.Builder;
import lombok.Data;
/**
* 水印请求参数
* 将原有的 WatermarkInfo(基于文件)转换为边缘渲染所需的格式(基于URL)
*/
@Data
@Builder
public class WatermarkRequest {
/**
* 原图URL
*/
private String originalImageUrl;
/**
* 原图宽度(像素)
*/
private int imageWidth;
/**
* 原图高度(像素)
*/
private int imageHeight;
/**
* 二维码URL
*/
private String qrcodeUrl;
/**
* 头像URL(可选)
*/
private String faceUrl;
/**
* 景区名称
*/
private String scenicLine;
/**
* 日期时间行
*/
private String datetimeLine;
/**
* 四边偏移(像素),正数表示向内偏移
*/
private Integer offsetTop;
private Integer offsetBottom;
private Integer offsetLeft;
private Integer offsetRight;
/**
* 缩放倍数,默认1.0
*/
private Double scale;
/**
* 输出格式:PNG / JPEG
*/
@Builder.Default
private String outputFormat = "JPEG";
/**
* 输出质量(0-100)
*/
@Builder.Default
private Integer outputQuality = 75;
public double getScaleValue() {
return scale != null ? scale : 1.0;
}
public int getOffsetTopValue() {
return offsetTop != null ? offsetTop : 0;
}
public int getOffsetBottomValue() {
return offsetBottom != null ? offsetBottom : 0;
}
public int getOffsetLeftValue() {
return offsetLeft != null ? offsetLeft : 0;
}
public int getOffsetRightValue() {
return offsetRight != null ? offsetRight : 0;
}
}

View File

@@ -0,0 +1,30 @@
package com.ycwl.basic.image.watermark.edge;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 水印模板构建结果
* 包含虚拟模板、元素列表和动态数据,用于发送给边缘渲染任务
*/
@Data
public class WatermarkTemplateResult {
/**
* 虚拟模板(运行时构造,不存储到数据库)
*/
private PuzzleTemplateEntity template;
/**
* 元素列表(按z-index排序)
*/
private List<PuzzleElementEntity> elements;
/**
* 动态数据(elementKey -> 实际值URL或文本)
*/
private Map<String, String> dynamicData;
}

View File

@@ -0,0 +1,272 @@
package com.ycwl.basic.image.watermark.edge.controller;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeTaskCreator;
import com.ycwl.basic.image.watermark.edge.WatermarkRequest;
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 水印边缘渲染测试控制器
* 用于测试水印边缘渲染功能
*/
@Slf4j
@IgnoreToken
@RestController
@RequestMapping("/test/watermark/edge")
@RequiredArgsConstructor
public class WatermarkEdgeTestController {
private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator;
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
/**
* 获取支持的水印风格列表
*/
@GetMapping("/styles")
public ApiResponse<List<String>> getSupportedStyles() {
return ApiResponse.success(watermarkEdgeTaskCreator.getSupportedStyles());
}
/**
* 创建水印渲染任务(异步)
* 任务创建后由边缘端拉取执行
*/
@PostMapping("/create")
public ApiResponse<CreateTaskResponse> createTask(@RequestBody CreateTaskRequest req) {
// 参数校验
if (req.getStyle() == null || req.getStyle().isEmpty()) {
return ApiResponse.fail("水印风格(style)不能为空");
}
if (!watermarkEdgeTaskCreator.isStyleSupported(req.getStyle())) {
return ApiResponse.fail("不支持的水印风格: " + req.getStyle() +
",支持的风格: " + watermarkEdgeTaskCreator.getSupportedStyles());
}
if (req.getOriginalImageUrl() == null || req.getOriginalImageUrl().isEmpty()) {
return ApiResponse.fail("原图URL(originalImageUrl)不能为空");
}
if (req.getImageWidth() <= 0 || req.getImageHeight() <= 0) {
return ApiResponse.fail("图片宽高必须大于0");
}
// 构建请求
WatermarkRequest watermarkRequest = WatermarkRequest.builder()
.originalImageUrl(req.getOriginalImageUrl())
.imageWidth(req.getImageWidth())
.imageHeight(req.getImageHeight())
.qrcodeUrl(req.getQrcodeUrl())
.faceUrl(req.getFaceUrl())
.scenicLine(req.getScenicLine())
.datetimeLine(req.getDatetimeLine())
.offsetTop(req.getOffsetTop())
.offsetBottom(req.getOffsetBottom())
.offsetLeft(req.getOffsetLeft())
.offsetRight(req.getOffsetRight())
.scale(req.getScale())
.outputFormat(req.getOutputFormat() != null ? req.getOutputFormat() : "JPEG")
.outputQuality(req.getOutputQuality() != null ? req.getOutputQuality() : 75)
.build();
// 创建任务
Long taskId = watermarkEdgeTaskCreator.createTask(
req.getStyle(),
watermarkRequest,
req.getRecordId() != null ? req.getRecordId() : 0L, // 测试用默认值
req.getFaceId(),
req.getWatermarkType() != null ? req.getWatermarkType() : "test"
);
CreateTaskResponse response = new CreateTaskResponse();
response.setTaskId(taskId);
response.setMessage("任务已创建,等待边缘端拉取执行");
log.info("测试创建水印任务: style={}, taskId={}", req.getStyle(), taskId);
return ApiResponse.success(response);
}
/**
* 创建水印渲染任务并等待结果(同步)
* 注意:此接口会阻塞直到任务完成或超时
*/
@PostMapping("/createAndWait")
public ApiResponse<CreateAndWaitResponse> createAndWait(@RequestBody CreateTaskRequest req) {
// 参数校验
if (req.getStyle() == null || req.getStyle().isEmpty()) {
return ApiResponse.fail("水印风格(style)不能为空");
}
if (!watermarkEdgeTaskCreator.isStyleSupported(req.getStyle())) {
return ApiResponse.fail("不支持的水印风格: " + req.getStyle() +
",支持的风格: " + watermarkEdgeTaskCreator.getSupportedStyles());
}
if (req.getOriginalImageUrl() == null || req.getOriginalImageUrl().isEmpty()) {
return ApiResponse.fail("原图URL(originalImageUrl)不能为空");
}
if (req.getImageWidth() <= 0 || req.getImageHeight() <= 0) {
return ApiResponse.fail("图片宽高必须大于0");
}
// 构建请求
WatermarkRequest watermarkRequest = WatermarkRequest.builder()
.originalImageUrl(req.getOriginalImageUrl())
.imageWidth(req.getImageWidth())
.imageHeight(req.getImageHeight())
.qrcodeUrl(req.getQrcodeUrl())
.faceUrl(req.getFaceUrl())
.scenicLine(req.getScenicLine())
.datetimeLine(req.getDatetimeLine())
.offsetTop(req.getOffsetTop())
.offsetBottom(req.getOffsetBottom())
.offsetLeft(req.getOffsetLeft())
.offsetRight(req.getOffsetRight())
.scale(req.getScale())
.outputFormat(req.getOutputFormat() != null ? req.getOutputFormat() : "JPEG")
.outputQuality(req.getOutputQuality() != null ? req.getOutputQuality() : 75)
.build();
// 超时时间,默认30秒
long timeoutMs = req.getTimeoutMs() != null ? req.getTimeoutMs() : 30000L;
// 先创建任务获取 taskId
Long taskId = watermarkEdgeTaskCreator.createTask(
req.getStyle(),
watermarkRequest,
req.getRecordId() != null ? req.getRecordId() : 0L,
req.getFaceId(),
req.getWatermarkType() != null ? req.getWatermarkType() : "test"
);
// 注册等待并等待结果
puzzleEdgeRenderTaskService.registerWait(taskId);
PuzzleEdgeRenderTaskService.TaskWaitResult result = puzzleEdgeRenderTaskService.waitForTask(taskId, timeoutMs);
CreateAndWaitResponse response = new CreateAndWaitResponse();
response.setTaskId(taskId);
response.setSuccess(result.isSuccess());
response.setImageUrl(result.getImageUrl());
response.setErrorMessage(result.getErrorMessage());
log.info("测试水印任务完成: style={}, taskId={}, success={}, imageUrl={}",
req.getStyle(), taskId, result.isSuccess(), result.getImageUrl());
return ApiResponse.success(response);
}
/**
* 创建任务请求
*/
@Data
public static class CreateTaskRequest {
/**
* 水印风格:normal / leica / printer_default
*/
private String style;
/**
* 原图URL
*/
private String originalImageUrl;
/**
* 原图宽度
*/
private int imageWidth;
/**
* 原图高度
*/
private int imageHeight;
/**
* 二维码URL
*/
private String qrcodeUrl;
/**
* 头像URL(可选)
*/
private String faceUrl;
/**
* 景区名称
*/
private String scenicLine;
/**
* 日期时间行
*/
private String datetimeLine;
/**
* 四边偏移(像素)
*/
private Integer offsetTop;
private Integer offsetBottom;
private Integer offsetLeft;
private Integer offsetRight;
/**
* 缩放倍数
*/
private Double scale;
/**
* 输出格式:PNG / JPEG
*/
private String outputFormat;
/**
* 输出质量(0-100)
*/
private Integer outputQuality;
/**
* 关联的拼图记录ID(测试用)
*/
private Long recordId;
/**
* 人脸ID(可选)
*/
private Long faceId;
/**
* 水印类型标识
*/
private String watermarkType;
/**
* 等待超时时间(毫秒),仅用于 createAndWait
*/
private Long timeoutMs;
}
/**
* 创建任务响应
*/
@Data
public static class CreateTaskResponse {
private Long taskId;
private String message;
}
/**
* 创建并等待响应
*/
@Data
public static class CreateAndWaitResponse {
private Long taskId;
private boolean success;
private String imageUrl;
private String errorMessage;
}
}

View File

@@ -4,10 +4,10 @@ import lombok.Getter;
@Getter @Getter
public enum ImageWatermarkOperatorEnum { public enum ImageWatermarkOperatorEnum {
WATERMARK("defW", "jpg"), LEICA("leica", "jpg"),
LEICA("leica", "png"), NORMAL("normal", "jpg"),
NORMAL("normal", "png"), PRINTER_DEFAULT("pDefault", "jpg"),
PRINTER_DEFAULT("pDefault", "png"); PUZZLE_PRINT("puzzle_print", "jpg");
private final String type; private final String type;
private final String preferFileType; private final String preferFileType;

View File

@@ -1,76 +0,0 @@
package com.ycwl.basic.image.watermark.operator;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.exception.ImageWatermarkException;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@Slf4j
public class DefaultImageWatermarkOperator implements IOperator {
@Override
public File process(WatermarkInfo info) throws ImageWatermarkException {
BufferedImage baseImage;
BufferedImage watermarkImage;
InputStream logoInputStream = getClass().getResourceAsStream("/watermark.png");
if (logoInputStream == null) {
throw new ImageWatermarkException("无法找到 watermark.png 资源文件");
}
try {
baseImage = ImageIO.read(info.getOriginalFile());
watermarkImage = ImageIO.read(logoInputStream);
} catch (IOException e) {
throw new ImageWatermarkException("图片打开失败");
}
// 新图像画布
BufferedImage newImage = new BufferedImage(baseImage.getWidth(), baseImage.getHeight(), BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = newImage.createGraphics();
g2d.drawImage(baseImage, 0, 0, null);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.5f));
g2d.drawImage(watermarkImage, 0, 0, baseImage.getWidth(), baseImage.getHeight(), null);
String fileName = info.getWatermarkedFile().getName();
String formatName = "jpg"; // 默认格式为 jpg
if (fileName.endsWith(".png")) {
formatName = "png";
} else if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) {
formatName = "jpg";
}
ImageWriter writer = ImageIO.getImageWritersByFormatName(formatName).next();
ImageOutputStream ios;
try {
ios = ImageIO.createImageOutputStream(info.getWatermarkedFile());
} catch (IOException e) {
throw new ImageWatermarkException("图片保存失败,目标文件无法写入");
}
writer.setOutput(ios);
try {
// 使用 ImageWriter 设置写入质量
ImageWriteParam writeParam = writer.getDefaultWriteParam();
if (writeParam.canWriteCompressed()) {
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
writeParam.setCompressionQuality(0.8f); // 设置写入质量为 80%
}
writer.write(null, new javax.imageio.IIOImage(newImage, null, null), writeParam);
} catch (IOException e) {
throw new ImageWatermarkException("图片保存失败");
}
finally {
g2d.dispose();
try {
ios.close();
} catch (IOException ignore) {
}
writer.dispose();
}
return info.getWatermarkedFile();
}
}

View File

@@ -1161,6 +1161,228 @@ fallbackService.clearAllFallbackCache("zt-render-worker");
- **Active (isActive=1)**: Worker is available for tasks - **Active (isActive=1)**: Worker is available for tasks
- **Inactive (isActive=0)**: Worker is disabled - **Inactive (isActive=0)**: Worker is disabled
## Render Template Integration (ZT-Render-Worker Microservice)
### Key Components
#### Feign Clients
- **RenderTemplateV2Client**: Template CRUD operations, segment management
#### Services
- **RenderTemplateIntegrationService**: High-level template operations (with automatic fallback for queries)
### Usage Examples
#### Basic Template Operations
```java
@Autowired
private RenderTemplateIntegrationService templateService;
// Create template (direct operation, fails immediately on error)
CreateTemplateRequest createRequest = new CreateTemplateRequest();
createRequest.setScenicId(1001L);
createRequest.setName("新年贺卡模板");
createRequest.setDescription("用于新年祝福的模板");
createRequest.setDefaultDurationMs(10000L);
OutputSpecDTO outputSpec = new OutputSpecDTO();
outputSpec.setWidth(1080);
outputSpec.setHeight(1920);
outputSpec.setFps(30);
createRequest.setOutputSpec(outputSpec);
TemplateV2DTO template = templateService.createTemplate(createRequest);
// Get template details (automatically falls back to cache on failure)
TemplateV2DTO templateInfo = templateService.getTemplate(templateId);
// Get template with segments (automatically falls back to cache on failure)
TemplateV2WithSegmentsDTO templateWithSegments = templateService.getTemplateWithSegments(templateId);
// List templates (no fallback for list operations)
PageResponse<TemplateV2DTO> templates = templateService.listTemplates(1, 10, scenicId, 1, null);
// Update template (direct operation, fails immediately on error)
UpdateTemplateRequest updateRequest = new UpdateTemplateRequest();
updateRequest.setName("更新后的模板名称");
templateService.updateTemplate(templateId, updateRequest);
// Publish template (direct operation, fails immediately on error)
templateService.publishTemplate(templateId);
// Create new version (direct operation, fails immediately on error)
TemplateV2DTO newVersion = templateService.createTemplateVersion(templateId);
// Delete template (direct operation, fails immediately on error)
templateService.deleteTemplate(templateId);
```
#### Segment Management
```java
// Get template segments (automatically falls back to cache on failure)
List<TemplateV2SegmentDTO> segments = templateService.getTemplateSegments(templateId);
// Create segment (direct operation, fails immediately on error)
CreateSegmentRequest segmentRequest = new CreateSegmentRequest();
segmentRequest.setSegmentIndex(0);
segmentRequest.setSegmentType("RENDER");
segmentRequest.setSourceType("SLOT");
segmentRequest.setSourceRef("slot1");
segmentRequest.setDurationMs(2000L);
segmentRequest.setTransitionType("fade");
segmentRequest.setTransitionMs(500);
RenderSpecDTO renderSpec = new RenderSpecDTO();
renderSpec.setCropEnable(true);
renderSpec.setSpeed("1.0");
segmentRequest.setRenderSpec(renderSpec);
TemplateV2SegmentDTO segment = templateService.createSegment(templateId, segmentRequest);
// Update segment (direct operation, fails immediately on error)
UpdateSegmentRequest updateSegmentRequest = new UpdateSegmentRequest();
updateSegmentRequest.setDurationMs(3000L);
templateService.updateSegment(templateId, segmentId, updateSegmentRequest);
// Delete segment (direct operation, fails immediately on error)
templateService.deleteSegment(templateId, segmentId);
// Replace all segments (direct operation, fails immediately on error)
ReplaceSegmentsRequest replaceRequest = new ReplaceSegmentsRequest();
replaceRequest.setSegments(Arrays.asList(segmentRequest1, segmentRequest2));
templateService.replaceSegments(templateId, replaceRequest);
```
### Template Status
- **0**: Draft - Template is being edited
- **1**: Published - Template is live and available for rendering
### Segment Types
- **FIXED**: Fixed asset segment
- **RENDER**: Segment that needs to be rendered with user materials
### Source Types
- **ASSET**: Fixed asset resource
- **PLACEHOLDER_VIDEO**: Video placeholder slot
- **PLACEHOLDER_IMAGE**: Image placeholder slot
- **SLOT**: Material slot
## Render Job Integration (ZT-Render-Worker Microservice)
### Key Components
#### Feign Clients
- **RenderJobV2Client**: Job creation, status queries, admin operations
#### Services
- **RenderJobIntegrationService**: High-level job operations (with automatic fallback for queries)
### Usage Examples
#### Creating and Managing Render Jobs
```java
@Autowired
private RenderJobIntegrationService jobService;
// Create preview job (direct operation, fails immediately on error)
CreatePreviewRequest previewRequest = new CreatePreviewRequest();
previewRequest.setTemplateId(123L);
previewRequest.setScenicId(456L);
previewRequest.setFaceId(789L);
previewRequest.setMemberId(101L);
// Set materials by slot
Map<String, List<MaterialDTO>> materialsBySlot = new HashMap<>();
MaterialDTO material = new MaterialDTO();
material.setUrl("https://example.com/video.mp4");
material.setType("video");
material.setDuration(5000L);
materialsBySlot.put("slot1", Arrays.asList(material));
previewRequest.setMaterialsBySlot(materialsBySlot);
CreatePreviewResponse previewResponse = jobService.createPreview(previewRequest);
log.info("作业创建成功, jobId: {}, playUrl: {}", previewResponse.getJobId(), previewResponse.getPlayUrl());
// Get job status (automatically falls back to cache on failure)
JobStatusResponse status = jobService.getJobStatus(jobId);
log.info("作业状态: {}, 进度: {}%", status.getStatus(), status.getProgress());
// Get playlist info (automatically falls back to cache on failure)
PlaylistInfoDTO playlistInfo = jobService.getPlaylistInfo(jobId);
log.info("总片段: {}, 已发布: {}", playlistInfo.getSegmentCount(), playlistInfo.getPublishedCount());
// Get HLS playlist (direct call, returns M3U8 content)
String hlsPlaylist = jobService.getHlsPlaylist(jobId);
// Cancel job (direct operation, fails immediately on error)
jobService.cancelJob(jobId);
```
#### Admin Operations
```java
// List jobs (no fallback for list operations)
PageResponse<RenderJobV2DTO> jobs = jobService.listJobs(scenicId, templateId, "RUNNING", "PREVIEW", 1, 20);
log.info("查询到 {} 条作业", jobs.getTotal());
// Get job detail (automatically falls back to cache on failure)
RenderJobV2DTO jobDetail = jobService.getJobDetail(jobId);
log.info("作业详情: templateId={}, status={}", jobDetail.getTemplateId(), jobDetail.getStatus());
// Get job segments (automatically falls back to cache on failure)
List<RenderJobSegmentV2DTO> jobSegments = jobService.getJobSegments(jobId);
for (RenderJobSegmentV2DTO segment : jobSegments) {
log.info("片段 {}: status={}, tsUrl={}", segment.getPlanSegmentIndex(), segment.getStatus(), segment.getTsUrl());
}
```
### Job Status
- **PENDING**: Job is waiting to be processed
- **RUNNING**: Job is currently being processed
- **SUCCESS**: Job completed successfully
- **FAILED**: Job failed
- **CANCELED**: Job was canceled
### Job Types
- **PREVIEW**: Preview job (HLS streaming)
- **PREVIEW_HLS**: HLS preview job
- **FINAL_MP4**: Final MP4 export job
### Segment Status
- **PENDING**: Segment is waiting to be processed
- **RENDERING**: Segment is being rendered
- **VIDEO_READY**: Video is ready
- **PACKAGING**: Segment is being packaged
- **TS_READY**: TS file is ready
- **PUBLISHED**: Segment is published and available
- **FAILED**: Segment processing failed
### Fallback Cache Management for Templates and Jobs
```java
@Autowired
private IntegrationFallbackService fallbackService;
// Check fallback cache status for templates
boolean hasTemplateCache = fallbackService.hasFallbackCache("zt-render-worker", "template:1001");
boolean hasTemplateSegmentsCache = fallbackService.hasFallbackCache("zt-render-worker", "template:segments:1001");
// Check fallback cache status for jobs
boolean hasJobStatusCache = fallbackService.hasFallbackCache("zt-render-worker", "job:status:2001");
boolean hasJobDetailCache = fallbackService.hasFallbackCache("zt-render-worker", "job:detail:2001");
// Get cache statistics
IntegrationFallbackService.FallbackCacheStats stats =
fallbackService.getFallbackCacheStats("zt-render-worker");
log.info("Render fallback cache: {} items, TTL: {} days",
stats.getTotalCacheCount(), stats.getFallbackTtlDays());
// Clear specific cache
fallbackService.clearFallbackCache("zt-render-worker", "template:1001");
fallbackService.clearFallbackCache("zt-render-worker", "job:status:2001");
// Clear all render worker caches
fallbackService.clearAllFallbackCache("zt-render-worker");
```
## ZT-Message Integration (Kafka Producer) ## ZT-Message Integration (Kafka Producer)
### Overview ### Overview

View File

@@ -12,6 +12,7 @@ import io.reactivex.rxjava3.core.Flowable;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList; import java.util.ArrayList;
@@ -23,6 +24,7 @@ import java.util.function.Consumer;
*/ */
@Slf4j @Slf4j
@Component @Component
@Lazy
public class GlmClientImpl implements GlmClient { public class GlmClientImpl implements GlmClient {
private static final String DEFAULT_MODEL = "glm-4.5-airx"; private static final String DEFAULT_MODEL = "glm-4.5-airx";

View File

@@ -0,0 +1,75 @@
package com.ycwl.basic.integration.render.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.render.dto.job.*;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 渲染作业V2客户端
*/
@FeignClient(name = "zt-render-worker", contextId = "render-job-v2", path = "/api/render/v2")
public interface RenderJobV2Client {
// ==================== 小程序侧接口 ====================
/**
* 创建预览作业
*/
@PostMapping("/preview")
CommonResponse<CreatePreviewResponse> createPreview(@RequestBody CreatePreviewRequest request);
/**
* 获取作业状态
*/
@GetMapping("/jobs/{jobId}")
CommonResponse<JobStatusResponse> getJobStatus(@PathVariable("jobId") Long jobId);
/**
* 获取HLS播放列表
* 返回M3U8格式的文本内容
*/
@GetMapping("/jobs/{jobId}/index.m3u8")
String getHlsPlaylist(@PathVariable("jobId") Long jobId);
/**
* 获取播放列表信息
*/
@GetMapping("/jobs/{jobId}/playlist-info")
CommonResponse<PlaylistInfoDTO> getPlaylistInfo(@PathVariable("jobId") Long jobId);
/**
* 取消作业
*/
@PostMapping("/jobs/{jobId}/cancel")
CommonResponse<Void> cancelJob(@PathVariable("jobId") Long jobId);
// ==================== 管理端接口 ====================
/**
* 获取作业列表
*/
@GetMapping("/admin/jobs")
CommonResponse<PageResponse<RenderJobV2DTO>> listJobs(
@RequestParam(required = false) Long scenicId,
@RequestParam(required = false) Long templateId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String jobType,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize);
/**
* 获取作业详情
*/
@GetMapping("/admin/jobs/{jobId}")
CommonResponse<RenderJobV2DTO> getJobDetail(@PathVariable("jobId") Long jobId);
/**
* 获取作业片段列表
*/
@GetMapping("/admin/jobs/{jobId}/segments")
CommonResponse<List<RenderJobSegmentV2DTO>> getJobSegments(@PathVariable("jobId") Long jobId);
}

View File

@@ -0,0 +1,111 @@
package com.ycwl.basic.integration.render.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.render.dto.template.*;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 渲染模板V2客户端
*/
@FeignClient(name = "zt-render-worker", contextId = "render-template-v2", path = "/api/render/template/v2")
public interface RenderTemplateV2Client {
// ==================== Template CRUD Operations ====================
/**
* 创建模板
*/
@PostMapping
CommonResponse<TemplateV2DTO> createTemplate(@RequestBody CreateTemplateRequest request);
/**
* 获取模板列表
*/
@GetMapping
CommonResponse<PageResponse<TemplateV2DTO>> listTemplates(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Long scenicId,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String name);
/**
* 获取模板详情
*/
@GetMapping("/{id}")
CommonResponse<TemplateV2DTO> getTemplate(@PathVariable("id") Long id);
/**
* 获取模板及其片段
*/
@GetMapping("/{id}/with-segments")
CommonResponse<TemplateV2WithSegmentsDTO> getTemplateWithSegments(@PathVariable("id") Long id);
/**
* 更新模板
*/
@PutMapping("/{id}")
CommonResponse<Void> updateTemplate(@PathVariable("id") Long id,
@RequestBody UpdateTemplateRequest request);
/**
* 删除模板
*/
@DeleteMapping("/{id}")
CommonResponse<Void> deleteTemplate(@PathVariable("id") Long id);
// ==================== Template Operations ====================
/**
* 发布模板
*/
@PostMapping("/{id}/publish")
CommonResponse<Void> publishTemplate(@PathVariable("id") Long id);
/**
* 创建新版本
*/
@PostMapping("/{id}/version")
CommonResponse<TemplateV2DTO> createTemplateVersion(@PathVariable("id") Long id);
// ==================== Segment Management ====================
/**
* 获取模板片段列表
*/
@GetMapping("/{id}/segments")
CommonResponse<List<TemplateV2SegmentDTO>> getTemplateSegments(@PathVariable("id") Long id);
/**
* 创建片段
*/
@PostMapping("/{id}/segments")
CommonResponse<TemplateV2SegmentDTO> createSegment(@PathVariable("id") Long id,
@RequestBody CreateSegmentRequest request);
/**
* 更新片段
*/
@PutMapping("/{id}/segments/{segmentId}")
CommonResponse<Void> updateSegment(@PathVariable("id") Long id,
@PathVariable("segmentId") Long segmentId,
@RequestBody UpdateSegmentRequest request);
/**
* 删除片段
*/
@DeleteMapping("/{id}/segments/{segmentId}")
CommonResponse<Void> deleteSegment(@PathVariable("id") Long id,
@PathVariable("segmentId") Long segmentId);
/**
* 替换所有片段
*/
@PostMapping("/{id}/segments/replace")
CommonResponse<Void> replaceSegments(@PathVariable("id") Long id,
@RequestBody ReplaceSegmentsRequest request);
}

View File

@@ -0,0 +1,45 @@
package com.ycwl.basic.integration.render.dto.common;
import lombok.Data;
/**
* 音频参数DTO
*/
@Data
public class AudioSpecDTO {
/**
* 音频素材URL
*/
private String audioUrl;
/**
* 音量 (0.0-1.0)
*/
private Double volume;
/**
* 淡入时长(毫秒)
*/
private Integer fadeInMs;
/**
* 淡出时长(毫秒)
*/
private Integer fadeOutMs;
/**
* 音频开始位置(毫秒)
*/
private Integer startMs;
/**
* 延迟播放(毫秒)
*/
private Integer delayMs;
/**
* 是否循环
*/
private Boolean loopEnable;
}

View File

@@ -0,0 +1,50 @@
package com.ycwl.basic.integration.render.dto.common;
import lombok.Data;
/**
* 输出规格DTO
*/
@Data
public class OutputSpecDTO {
/**
* 宽度
*/
private Integer width;
/**
* 高度
*/
private Integer height;
/**
* 帧率
*/
private Integer fps;
/**
* 比特率
*/
private Integer bitrate;
/**
* 视频编解码器 (默认h264)
*/
private String codec;
/**
* 音频编解码器 (默认aac)
*/
private String audioCodec;
/**
* 采样率 (默认48000)
*/
private Integer sampleRate;
/**
* 声道数 (默认2)
*/
private Integer channels;
}

View File

@@ -0,0 +1,60 @@
package com.ycwl.basic.integration.render.dto.common;
import lombok.Data;
/**
* 渲染参数DTO
*/
@Data
public class RenderSpecDTO {
/**
* 是否启用人脸裁切
*/
private Boolean cropEnable;
/**
* 裁切后大小
*/
private String cropSize;
/**
* 倍速
*/
private String speed;
/**
* 调色LUT文件URL
*/
private String lutUrl;
/**
* 叠加蒙版URL
*/
private String overlayUrl;
/**
* 特效配置
*/
private String effects;
/**
* 是否缩放裁切
*/
private Boolean zoomCut;
/**
* 竖屏切割位置
*/
private String videoCrop;
/**
* 人脸位置参数
*/
private String facePos;
/**
* 转场效果
*/
private String transitions;
}

View File

@@ -0,0 +1,46 @@
package com.ycwl.basic.integration.render.dto.job;
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 创建预览请求
*/
@Data
public class CreatePreviewRequest {
/**
* 模板ID (必填)
*/
private Long templateId;
/**
* 景区ID (必填)
*/
private Long scenicId;
/**
* 人脸ID (可选)
*/
private Long faceId;
/**
* 会员ID (可选)
*/
private Long memberId;
/**
* 素材槽映射 (可选)
* key: 槽位键
* value: 素材列表
*/
private Map<String, List<MaterialDTO>> materialsBySlot;
/**
* 自定义输出规格 (可选)
*/
private OutputSpecDTO outputSpec;
}

View File

@@ -0,0 +1,25 @@
package com.ycwl.basic.integration.render.dto.job;
import lombok.Data;
/**
* 创建预览响应
*/
@Data
public class CreatePreviewResponse {
/**
* 作业ID
*/
private Long jobId;
/**
* 状态
*/
private String status;
/**
* 播放URL
*/
private String playUrl;
}

View File

@@ -0,0 +1,55 @@
package com.ycwl.basic.integration.render.dto.job;
import lombok.Data;
/**
* 作业状态响应
*/
@Data
public class JobStatusResponse {
/**
* 作业ID
*/
private Long jobId;
/**
* 状态 (PENDING, RUNNING, SUCCESS, FAILED, CANCELED)
*/
private String status;
/**
* 进度 (0.0 - 100.0)
*/
private Double progress;
/**
* 总片段数
*/
private Integer segmentCount;
/**
* 已发布片段数
*/
private Integer publishedCount;
/**
* 播放URL
*/
private String playUrl;
/**
* MP4下载URL
*/
private String mp4Url;
/**
* 错误码
*/
private String errorCode;
/**
* 错误信息
*/
private String errorMessage;
}

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.integration.render.dto.job;
import lombok.Data;
/**
* 素材信息DTO
*/
@Data
public class MaterialDTO {
/**
* 素材URL
*/
private String url;
/**
* 类型 (video, image)
*/
private String type;
/**
* 时长(毫秒)
*/
private Long duration;
/**
* 人脸分数
*/
private String score;
/**
* 人脸位置
*/
private String facePos;
}

View File

@@ -0,0 +1,25 @@
package com.ycwl.basic.integration.render.dto.job;
import lombok.Data;
/**
* 播放列表信息DTO
*/
@Data
public class PlaylistInfoDTO {
/**
* 总片段数
*/
private Integer segmentCount;
/**
* 进度 (0.0 - 100.0)
*/
private Double progress;
/**
* 已发布片段数
*/
private Integer publishedCount;
}

View File

@@ -0,0 +1,112 @@
package com.ycwl.basic.integration.render.dto.job;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
import lombok.Data;
import java.util.Date;
/**
* 渲染作业片段V2 DTO
*/
@Data
public class RenderJobSegmentV2DTO {
/**
* 片段ID
*/
private Long id;
/**
* 作业ID
*/
private Long jobId;
/**
* 模板片段索引
*/
private Integer templateSegmentIndex;
/**
* 计划片段索引
*/
private Integer planSegmentIndex;
/**
* 开始时间(毫秒)
*/
private Long startTimeMs;
/**
* 时长(毫秒)
*/
private Long durationMs;
/**
* 片段类型 (RENDER, FIXED)
*/
private String segmentType;
/**
* 状态 (PENDING, RENDERING, VIDEO_READY, PACKAGING, TS_READY, PUBLISHED, FAILED)
*/
private String status;
/**
* 素材来源类型
*/
private String sourceType;
/**
* 素材来源引用
*/
private String sourceRef;
/**
* 绑定素材URL
*/
private String boundMaterialUrl;
/**
* 渲染参数JSON
*/
@JsonProperty("renderSpecJson")
private RenderSpecDTO renderSpecJson;
/**
* 音频参数JSON
*/
@JsonProperty("audioSpecJson")
private AudioSpecDTO audioSpecJson;
/**
* 视频URL
*/
private String videoUrl;
/**
* TS文件URL
*/
private String tsUrl;
/**
* TS时长(秒)
*/
private Double tsDuration;
/**
* 创建时间
*/
@JsonProperty("createTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
/**
* 更新时间
*/
@JsonProperty("updateTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
}

View File

@@ -0,0 +1,144 @@
package com.ycwl.basic.integration.render.dto.job;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Date;
import java.util.Map;
/**
* 渲染作业V2 DTO
*/
@Data
public class RenderJobV2DTO {
/**
* 作业ID
*/
private Long id;
/**
* 作业类型 (PREVIEW, PREVIEW_HLS, FINAL_MP4)
*/
private String jobType;
/**
* 状态 (PENDING, RUNNING, SUCCESS, FAILED, CANCELED)
*/
private String status;
/**
* 景区ID
*/
private Long scenicId;
/**
* 模板ID
*/
private Long templateId;
/**
* 模板版本
*/
private Integer templateVersion;
/**
* 人脸ID
*/
private Long faceId;
/**
* 会员ID
*/
private Long memberId;
/**
* 总时长(毫秒)
*/
private Long totalDurationMs;
/**
* 输出规格JSON
*/
@JsonProperty("outputSpecJson")
private Map<String, Object> outputSpecJson;
/**
* 渲染计划JSON
*/
private String planJson;
/**
* 总片段数
*/
private Integer segmentCount;
/**
* 已发布片段数
*/
private Integer publishedCount;
/**
* 已完成片段数
*/
private Integer completedCount;
/**
* M3U8播放地址
*/
private String m3u8Url;
/**
* 音频URL
*/
private String audioUrl;
/**
* MP4下载地址
*/
private String mp4Url;
/**
* 错误码
*/
private String errorCode;
/**
* 错误信息
*/
private String errorMessage;
/**
* 幂等键
*/
private String idempotencyKey;
/**
* 开始时间
*/
@JsonProperty("startTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date startTime;
/**
* 完成时间
*/
@JsonProperty("finishTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date finishTime;
/**
* 创建时间
*/
@JsonProperty("createTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
/**
* 更新时间
*/
@JsonProperty("updateTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
}

View File

@@ -0,0 +1,84 @@
package com.ycwl.basic.integration.render.dto.template;
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
import lombok.Data;
import java.util.Map;
/**
* 创建片段请求
*/
@Data
public class CreateSegmentRequest {
/**
* 片段索引
*/
private Integer segmentIndex;
/**
* 片段类型 (RENDER, FIXED)
*/
private String segmentType;
/**
* 是否可缓存
*/
private Boolean cacheable;
/**
* 位置是否固定
*/
private Boolean positionFixed;
/**
* 素材来源类型 (ASSET, PLACEHOLDER_VIDEO, PLACEHOLDER_IMAGE, SLOT)
*/
private String sourceType;
/**
* 素材来源引用
*/
private String sourceRef;
/**
* 时长(毫秒)
*/
private Long durationMs;
/**
* 转场类型
*/
private String transitionType;
/**
* 转场时长(毫秒)
*/
private Integer transitionMs;
/**
* 素材槽位键
*/
private String slotKey;
/**
* 条件表达式
*/
private Map<String, Object> onlyIfExpr;
/**
* 渲染参数
*/
private RenderSpecDTO renderSpec;
/**
* 音频参数
*/
private AudioSpecDTO audioSpec;
/**
* 扩展属性
*/
private Map<String, Object> extendedProps;
}

View File

@@ -0,0 +1,46 @@
package com.ycwl.basic.integration.render.dto.template;
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
import lombok.Data;
/**
* 创建模板请求
*/
@Data
public class CreateTemplateRequest {
/**
* 景区ID
*/
private Long scenicId;
/**
* 模板名称
*/
private String name;
/**
* 描述
*/
private String description;
/**
* 缩略图URL
*/
private String thumbnailUrl;
/**
* 默认时长(毫秒)
*/
private Long defaultDurationMs;
/**
* 输出规格
*/
private OutputSpecDTO outputSpec;
/**
* 背景音乐URL
*/
private String bgmUrl;
}

View File

@@ -0,0 +1,17 @@
package com.ycwl.basic.integration.render.dto.template;
import lombok.Data;
import java.util.List;
/**
* 替换所有片段请求
*/
@Data
public class ReplaceSegmentsRequest {
/**
* 片段列表
*/
private List<CreateSegmentRequest> segments;
}

View File

@@ -0,0 +1,116 @@
package com.ycwl.basic.integration.render.dto.template;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
import lombok.Data;
import java.util.Date;
/**
* 渲染模板V2 DTO
*/
@Data
public class TemplateV2DTO {
/**
* 模板ID
*/
private Long id;
/**
* 景区ID
*/
private Long scenicId;
/**
* 模板名称
*/
private String name;
/**
* 版本号
*/
private Integer version;
/**
* 状态 (0-草稿, 1-已发布)
*/
private Integer status;
/**
* 描述
*/
private String description;
/**
* 缩略图URL
*/
private String thumbnailUrl;
/**
* 默认时长(毫秒)
*/
private Long defaultDurationMs;
/**
* 输出规格
*/
private OutputSpecDTO outputSpec;
/**
* 输出宽度
*/
private Integer outputWidth;
/**
* 输出高度
*/
private Integer outputHeight;
/**
* 输出帧率
*/
private Integer outputFps;
/**
* 背景音乐URL
*/
private String bgmUrl;
/**
* 背景音乐是否固定
*/
private Boolean bgmFixed;
/**
* 封面URL
*/
private String coverUrl;
/**
* 创建时间
*/
@JsonProperty("createTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
/**
* 更新时间
*/
@JsonProperty("updateTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
/**
* 删除标记
*/
private Integer deleted;
/**
* 删除时间
*/
@JsonProperty("deletedAt")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date deletedAt;
}

View File

@@ -0,0 +1,115 @@
package com.ycwl.basic.integration.render.dto.template;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
import lombok.Data;
import java.util.Date;
import java.util.Map;
/**
* 模板片段V2 DTO
*/
@Data
public class TemplateV2SegmentDTO {
/**
* 片段ID
*/
private Long id;
/**
* 模板ID
*/
private Long templateId;
/**
* 片段索引
*/
private Integer segmentIndex;
/**
* 片段类型 (RENDER, FIXED)
*/
private String segmentType;
/**
* 是否可缓存
*/
private Boolean cacheable;
/**
* 位置是否固定
*/
private Boolean positionFixed;
/**
* 时长(毫秒)
*/
private Long durationMs;
/**
* 转场类型
*/
private String transitionType;
/**
* 转场时长(毫秒)
*/
private Integer transitionMs;
/**
* 素材槽位键
*/
private String slotKey;
/**
* 条件表达式JSON
*/
@JsonProperty("onlyIfExprJson")
private Map<String, Object> onlyIfExprJson;
/**
* 素材来源类型 (ASSET, PLACEHOLDER_VIDEO, PLACEHOLDER_IMAGE, SLOT)
*/
private String sourceType;
/**
* 素材来源引用
*/
private String sourceRef;
/**
* 渲染参数JSON
*/
@JsonProperty("renderSpecJson")
private RenderSpecDTO renderSpecJson;
/**
* 音频参数JSON
*/
@JsonProperty("audioSpecJson")
private AudioSpecDTO audioSpecJson;
/**
* 扩展属性JSON
*/
@JsonProperty("extendedPropsJson")
private Map<String, Object> extendedPropsJson;
/**
* 创建时间
*/
@JsonProperty("createTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
/**
* 更新时间
*/
@JsonProperty("updateTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
}

View File

@@ -0,0 +1,22 @@
package com.ycwl.basic.integration.render.dto.template;
import lombok.Data;
import java.util.List;
/**
* 模板及其片段响应DTO
*/
@Data
public class TemplateV2WithSegmentsDTO {
/**
* 模板信息
*/
private TemplateV2DTO template;
/**
* 片段列表
*/
private List<TemplateV2SegmentDTO> segments;
}

View File

@@ -0,0 +1,79 @@
package com.ycwl.basic.integration.render.dto.template;
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
import lombok.Data;
import java.util.Map;
/**
* 更新片段请求
*/
@Data
public class UpdateSegmentRequest {
/**
* 片段索引
*/
private Integer segmentIndex;
/**
* 片段类型 (RENDER, FIXED)
*/
private String segmentType;
/**
* 是否可缓存
*/
private Boolean cacheable;
/**
* 素材来源类型 (ASSET, PLACEHOLDER_VIDEO, PLACEHOLDER_IMAGE, SLOT)
*/
private String sourceType;
/**
* 素材来源引用
*/
private String sourceRef;
/**
* 时长(毫秒)
*/
private Long durationMs;
/**
* 转场类型
*/
private String transitionType;
/**
* 转场时长(毫秒)
*/
private Integer transitionMs;
/**
* 素材槽位键
*/
private String slotKey;
/**
* 条件表达式
*/
private Map<String, Object> onlyIfExpr;
/**
* 渲染参数
*/
private RenderSpecDTO renderSpec;
/**
* 音频参数
*/
private AudioSpecDTO audioSpec;
/**
* 扩展属性
*/
private Map<String, Object> extendedProps;
}

View File

@@ -0,0 +1,46 @@
package com.ycwl.basic.integration.render.dto.template;
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
import lombok.Data;
/**
* 更新模板请求
*/
@Data
public class UpdateTemplateRequest {
/**
* 模板名称
*/
private String name;
/**
* 描述
*/
private String description;
/**
* 缩略图URL
*/
private String thumbnailUrl;
/**
* 默认时长(毫秒)
*/
private Long defaultDurationMs;
/**
* 输出规格
*/
private OutputSpecDTO outputSpec;
/**
* 背景音乐URL
*/
private String bgmUrl;
/**
* 背景音乐是否固定
*/
private Boolean bgmFixed;
}

View File

@@ -0,0 +1,168 @@
package com.ycwl.basic.integration.render.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.render.client.RenderJobV2Client;
import com.ycwl.basic.integration.render.dto.job.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 渲染作业集成服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RenderJobIntegrationService {
private final RenderJobV2Client renderJobV2Client;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-render-worker";
// ==================== 小程序侧接口 ====================
/**
* 创建预览作业(直接调用,不降级)
*/
public CreatePreviewResponse createPreview(CreatePreviewRequest request) {
log.debug("创建预览作业, templateId: {}, scenicId: {}", request.getTemplateId(), request.getScenicId());
CommonResponse<CreatePreviewResponse> response = renderJobV2Client.createPreview(request);
return handleResponse(response, "创建预览作业失败");
}
/**
* 获取作业状态(带降级)
*/
public JobStatusResponse getJobStatus(Long jobId) {
log.debug("获取作业状态, jobId: {}", jobId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"job:status:" + jobId,
() -> {
CommonResponse<JobStatusResponse> response = renderJobV2Client.getJobStatus(jobId);
return handleResponse(response, "获取作业状态失败");
},
JobStatusResponse.class
);
}
/**
* 获取HLS播放列表
* 返回M3U8格式的文本内容(不降级)
*/
public String getHlsPlaylist(Long jobId) {
log.debug("获取HLS播放列表, jobId: {}", jobId);
try {
return renderJobV2Client.getHlsPlaylist(jobId);
} catch (Exception e) {
log.error("获取HLS播放列表失败, jobId: {}", jobId, e);
throw new IntegrationException(5000, "获取HLS播放列表失败: " + e.getMessage(), SERVICE_NAME);
}
}
/**
* 获取播放列表信息(带降级)
*/
public PlaylistInfoDTO getPlaylistInfo(Long jobId) {
log.debug("获取播放列表信息, jobId: {}", jobId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"job:playlist-info:" + jobId,
() -> {
CommonResponse<PlaylistInfoDTO> response = renderJobV2Client.getPlaylistInfo(jobId);
return handleResponse(response, "获取播放列表信息失败");
},
PlaylistInfoDTO.class
);
}
/**
* 取消作业(直接调用,不降级)
*/
public void cancelJob(Long jobId) {
log.debug("取消作业, jobId: {}", jobId);
CommonResponse<Void> response = renderJobV2Client.cancelJob(jobId);
handleVoidResponse(response, "取消作业失败");
}
// ==================== 管理端接口 ====================
/**
* 获取作业列表(不降级)
*/
public PageResponse<RenderJobV2DTO> listJobs(Long scenicId, Long templateId, String status,
String jobType, Integer page, Integer pageSize) {
log.debug("查询作业列表, scenicId: {}, templateId: {}, status: {}, jobType: {}, page: {}, pageSize: {}",
scenicId, templateId, status, jobType, page, pageSize);
CommonResponse<PageResponse<RenderJobV2DTO>> response =
renderJobV2Client.listJobs(scenicId, templateId, status, jobType, page, pageSize);
return handleResponse(response, "查询作业列表失败");
}
/**
* 获取作业详情(带降级)
*/
public RenderJobV2DTO getJobDetail(Long jobId) {
log.debug("获取作业详情, jobId: {}", jobId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"job:detail:" + jobId,
() -> {
CommonResponse<RenderJobV2DTO> response = renderJobV2Client.getJobDetail(jobId);
return handleResponse(response, "获取作业详情失败");
},
RenderJobV2DTO.class
);
}
/**
* 获取作业片段列表(带降级)
*/
@SuppressWarnings("unchecked")
public List<RenderJobSegmentV2DTO> getJobSegments(Long jobId) {
log.debug("获取作业片段列表, jobId: {}", jobId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"job:segments:" + jobId,
() -> {
CommonResponse<List<RenderJobSegmentV2DTO>> response =
renderJobV2Client.getJobSegments(jobId);
return handleResponse(response, "获取作业片段列表失败");
},
(Class<List<RenderJobSegmentV2DTO>>) (Class<?>) List.class
);
}
// ==================== Helper Methods ====================
/**
* 处理通用响应
*/
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
if (response == null || !response.getSuccess()) {
String msg = response != null && response.getMessage() != null ?
response.getMessage() : errorMessage;
Integer code = response != null ? response.getCode() : 5000;
throw new IntegrationException(code, msg, SERVICE_NAME);
}
return response.getData();
}
/**
* 处理空响应
*/
private void handleVoidResponse(CommonResponse<Void> response, String errorMessage) {
if (response == null || !response.getSuccess()) {
String msg = response != null && response.getMessage() != null ?
response.getMessage() : errorMessage;
Integer code = response != null ? response.getCode() : 5000;
throw new IntegrationException(code, msg, SERVICE_NAME);
}
}
}

View File

@@ -0,0 +1,207 @@
package com.ycwl.basic.integration.render.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.render.client.RenderTemplateV2Client;
import com.ycwl.basic.integration.render.dto.template.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 渲染模板集成服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RenderTemplateIntegrationService {
private final RenderTemplateV2Client renderTemplateV2Client;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-render-worker";
// ==================== Template CRUD Operations ====================
/**
* 创建模板(直接调用,不降级)
*/
public TemplateV2DTO createTemplate(CreateTemplateRequest request) {
log.debug("创建渲染模板, scenicId: {}, name: {}", request.getScenicId(), request.getName());
CommonResponse<TemplateV2DTO> response = renderTemplateV2Client.createTemplate(request);
return handleResponse(response, "创建渲染模板失败");
}
/**
* 获取模板列表(不降级)
*/
public PageResponse<TemplateV2DTO> listTemplates(Integer page, Integer pageSize, Long scenicId,
Integer status, String name) {
log.debug("查询渲染模板列表, page: {}, pageSize: {}, scenicId: {}, status: {}, name: {}",
page, pageSize, scenicId, status, name);
CommonResponse<PageResponse<TemplateV2DTO>> response =
renderTemplateV2Client.listTemplates(page, pageSize, scenicId, status, name);
return handleResponse(response, "查询渲染模板列表失败");
}
/**
* 获取模板详情(带降级)
*/
public TemplateV2DTO getTemplate(Long id) {
log.debug("获取渲染模板信息, id: {}", id);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"template:" + id,
() -> {
CommonResponse<TemplateV2DTO> response = renderTemplateV2Client.getTemplate(id);
return handleResponse(response, "获取渲染模板信息失败");
},
TemplateV2DTO.class
);
}
/**
* 获取模板及其片段(带降级)
*/
public TemplateV2WithSegmentsDTO getTemplateWithSegments(Long id) {
log.debug("获取渲染模板及片段, id: {}", id);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"template:with-segments:" + id,
() -> {
CommonResponse<TemplateV2WithSegmentsDTO> response =
renderTemplateV2Client.getTemplateWithSegments(id);
return handleResponse(response, "获取渲染模板及片段失败");
},
TemplateV2WithSegmentsDTO.class
);
}
/**
* 更新模板(直接调用,不降级)
*/
public void updateTemplate(Long id, UpdateTemplateRequest request) {
log.debug("更新渲染模板, id: {}, name: {}", id, request.getName());
CommonResponse<Void> response = renderTemplateV2Client.updateTemplate(id, request);
handleVoidResponse(response, "更新渲染模板失败");
}
/**
* 删除模板(直接调用,不降级)
*/
public void deleteTemplate(Long id) {
log.debug("删除渲染模板, id: {}", id);
CommonResponse<Void> response = renderTemplateV2Client.deleteTemplate(id);
handleVoidResponse(response, "删除渲染模板失败");
}
// ==================== Template Operations ====================
/**
* 发布模板(直接调用,不降级)
*/
public void publishTemplate(Long id) {
log.debug("发布渲染模板, id: {}", id);
CommonResponse<Void> response = renderTemplateV2Client.publishTemplate(id);
handleVoidResponse(response, "发布渲染模板失败");
}
/**
* 创建新版本(直接调用,不降级)
*/
public TemplateV2DTO createTemplateVersion(Long id) {
log.debug("创建渲染模板新版本, id: {}", id);
CommonResponse<TemplateV2DTO> response = renderTemplateV2Client.createTemplateVersion(id);
return handleResponse(response, "创建渲染模板新版本失败");
}
// ==================== Segment Management ====================
/**
* 获取模板片段列表(带降级)
*/
@SuppressWarnings("unchecked")
public List<TemplateV2SegmentDTO> getTemplateSegments(Long id) {
log.debug("获取渲染模板片段列表, templateId: {}", id);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"template:segments:" + id,
() -> {
CommonResponse<List<TemplateV2SegmentDTO>> response =
renderTemplateV2Client.getTemplateSegments(id);
return handleResponse(response, "获取渲染模板片段列表失败");
},
(Class<List<TemplateV2SegmentDTO>>) (Class<?>) List.class
);
}
/**
* 创建片段(直接调用,不降级)
*/
public TemplateV2SegmentDTO createSegment(Long templateId, CreateSegmentRequest request) {
log.debug("创建模板片段, templateId: {}, segmentIndex: {}", templateId, request.getSegmentIndex());
CommonResponse<TemplateV2SegmentDTO> response =
renderTemplateV2Client.createSegment(templateId, request);
return handleResponse(response, "创建模板片段失败");
}
/**
* 更新片段(直接调用,不降级)
*/
public void updateSegment(Long templateId, Long segmentId, UpdateSegmentRequest request) {
log.debug("更新模板片段, templateId: {}, segmentId: {}", templateId, segmentId);
CommonResponse<Void> response =
renderTemplateV2Client.updateSegment(templateId, segmentId, request);
handleVoidResponse(response, "更新模板片段失败");
}
/**
* 删除片段(直接调用,不降级)
*/
public void deleteSegment(Long templateId, Long segmentId) {
log.debug("删除模板片段, templateId: {}, segmentId: {}", templateId, segmentId);
CommonResponse<Void> response = renderTemplateV2Client.deleteSegment(templateId, segmentId);
handleVoidResponse(response, "删除模板片段失败");
}
/**
* 替换所有片段(直接调用,不降级)
*/
public void replaceSegments(Long templateId, ReplaceSegmentsRequest request) {
log.debug("替换所有模板片段, templateId: {}, segmentCount: {}",
templateId, request.getSegments() != null ? request.getSegments().size() : 0);
CommonResponse<Void> response = renderTemplateV2Client.replaceSegments(templateId, request);
handleVoidResponse(response, "替换所有模板片段失败");
}
// ==================== Helper Methods ====================
/**
* 处理通用响应
*/
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
if (response == null || !response.getSuccess()) {
String msg = response != null && response.getMessage() != null ?
response.getMessage() : errorMessage;
Integer code = response != null ? response.getCode() : 5000;
throw new IntegrationException(code, msg, SERVICE_NAME);
}
return response.getData();
}
/**
* 处理空响应
*/
private void handleVoidResponse(CommonResponse<Void> response, String errorMessage) {
if (response == null || !response.getSuccess()) {
String msg = response != null && response.getMessage() != null ?
response.getMessage() : errorMessage;
Integer code = response != null ? response.getCode() : 5000;
throw new IntegrationException(code, msg, SERVICE_NAME);
}
}
}

View File

@@ -6,7 +6,10 @@ import com.ycwl.basic.model.pc.broker.resp.BrokerRecordRespVO;
import com.ycwl.basic.model.pc.broker.resp.DailySummaryRespVO; import com.ycwl.basic.model.pc.broker.resp.DailySummaryRespVO;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.Date; import java.util.Date;
import java.util.HashMap;
import java.util.List; import java.util.List;
/** /**
@@ -28,4 +31,11 @@ public interface BrokerRecordMapper {
int update(BrokerRecord brokerRecord); int update(BrokerRecord brokerRecord);
List<DailySummaryRespVO> getDailySummaryByBrokerId(Long brokerId, Date startTime, Date endTime); List<DailySummaryRespVO> getDailySummaryByBrokerId(Long brokerId, Date startTime, Date endTime);
/**
* 按日期统计分销员订单数据(不含扫码统计,已迁移到 ClickHouse)
*/
List<HashMap<String, Object>> getDailyOrderStats(@Param("brokerId") Long brokerId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
} }

View File

@@ -1,28 +0,0 @@
package com.ycwl.basic.mapper;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.coupon.req.CouponQueryReq;
import com.ycwl.basic.model.pc.coupon.resp.CouponRespVO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface CouponMapper {
List<CouponRespVO> selectByQuery(CouponQueryReq query);
int updateStatus(Integer id);
CouponEntity getById(Integer couponId);
int insert(CouponEntity coupon);
int updateById(CouponEntity coupon);
int deleteById(Integer id);
List<CouponEntity> selectList();
CouponEntity selectById(Integer id);
CouponEntity selectByScenicIdAndTypeAndStatus(Long scenicId, Integer type, Integer status);
}

View File

@@ -1,20 +0,0 @@
package com.ycwl.basic.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
import com.ycwl.basic.model.pc.couponRecord.req.CouponRecordPageQueryReq;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordPageResp;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface CouponRecordMapper extends BaseMapper<CouponRecordEntity> {
List<CouponRecordEntity> queryByUserWithGoodsId(Long scenicId, Long memberId, String goodsId);
List<CouponRecordEntity> queryByMemberIdAndFaceId(Long memberId, Long faceId);
CouponRecordEntity queryByMemberIdAndFaceIdAndType(Long memberId, Long faceId, Integer type);
List<CouponRecordPageResp> selectByPageQuery(CouponRecordPageQueryReq query);
}

View File

@@ -173,4 +173,12 @@ public interface SourceMapper {
* @return 免费记录数 * @return 免费记录数
*/ */
int countFreeRelationsByFaceIdAndType(Long faceId, Integer type); int countFreeRelationsByFaceIdAndType(Long faceId, Integer type);
/**
* 根据faceId和type直接查询关联的source列表(避免N+1查询)
* @param faceId 人脸ID
* @param type 素材类型
* @return source实体列表
*/
List<SourceEntity> listSourceByFaceRelation(Long faceId, Integer type);
} }

View File

@@ -4,6 +4,7 @@ import com.ycwl.basic.model.mobile.statistic.req.CommonQueryReq;
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq; import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
import com.ycwl.basic.model.mobile.statistic.resp.AppStatisticsFunnelVO; import com.ycwl.basic.model.mobile.statistic.resp.AppStatisticsFunnelVO;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Date; import java.util.Date;
@@ -104,10 +105,48 @@ public interface StatisticsMapper {
List<HashMap<String, String>> orderChartByHour(CommonQueryReq query); List<HashMap<String, String>> orderChartByHour(CommonQueryReq query);
/**
* 按小时统计扫码人数(仅统计数据,不含订单)
*/
List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query); List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query);
/**
* 按日期统计扫码人数(仅统计数据,不含订单)
*/
List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query); List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query);
/**
* 按小时统计访问打印样片页面人数
*/
List<HashMap<String, String>> printerFromSampleChartByHour(CommonQueryReq query);
/**
* 按日期统计访问打印样片页面人数
*/
List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query);
/**
* 按小时统计订单数据
*/
List<HashMap<String, String>> orderChartByHourForMerge(CommonQueryReq query);
/**
* 按日期统计订单数据
*/
List<HashMap<String, String>> orderChartByDateForMerge(CommonQueryReq query);
/**
* 统计分销员扫码次数
*/
Integer countBrokerScanCount(Long brokerId);
/**
* 按日期统计分销员扫码数据
*/
List<HashMap<String, Object>> getDailyScanStats(@Param("brokerId") Long brokerId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/** /**
* 统计订单数量和金额(包含推送订单和现场订单) * 统计订单数量和金额(包含推送订单和现场订单)
* @param query * @param query

View File

@@ -59,4 +59,16 @@ public interface TaskMapper {
List<TaskEntity> selectAllFailed(); List<TaskEntity> selectAllFailed();
TaskEntity listLastFaceTemplateTask(Long faceId, Long templateId); TaskEntity listLastFaceTemplateTask(Long faceId, Long templateId);
/**
* 根据 face_id 列表统计已完成任务的用户数
* 用于 ClickHouse 迁移后的跨库统计
*/
Integer countCompletedTaskMembersByFaceIds(@Param("faceIds") List<String> faceIds);
/**
* 根据 face_id 列表统计已完成任务数
* 用于 ClickHouse 迁移后的跨库统计
*/
Integer countCompletedTasksByFaceIds(@Param("faceIds") List<String> faceIds);
} }

View File

@@ -89,4 +89,18 @@ public interface UserNotificationAuthorizationMapper extends BaseMapper<UserNoti
@Param("templateId") String templateId, @Param("templateId") String templateId,
@Param("scenicId") Long scenicId @Param("scenicId") Long scenicId
); );
/**
* 批量查询用户对多个模板的授权记录
*
* @param memberId 用户ID
* @param templateIds 模板ID列表
* @param scenicId 景区ID
* @return 授权记录列表
*/
List<UserNotificationAuthorizationEntity> selectBatchByTemplateIds(
@Param("memberId") Long memberId,
@Param("templateIds") List<String> templateIds,
@Param("scenicId") Long scenicId
);
} }

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationRecordEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户订阅消息授权明细Mapper(幂等)
*
* @Author: System
* @Date: 2025/12/31
*/
@Mapper
public interface UserNotificationAuthorizationRecordMapper extends BaseMapper<UserNotificationAuthorizationRecordEntity> {
}

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 微信订阅消息事件模板映射Mapper
*
* @Author: System
* @Date: 2025/12/31
*/
@Mapper
public interface WechatSubscribeEventTemplateMapper extends BaseMapper<WechatSubscribeEventTemplateEntity> {
}

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 微信订阅消息场景模板映射Mapper
*
* @Author: System
* @Date: 2025/12/31
*/
@Mapper
public interface WechatSubscribeSceneTemplateMapper extends BaseMapper<WechatSubscribeSceneTemplateEntity> {
}

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 微信订阅消息发送日志Mapper
*
* @Author: System
* @Date: 2025/12/31
*/
@Mapper
public interface WechatSubscribeSendLogMapper extends BaseMapper<WechatSubscribeSendLogEntity> {
}

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 微信订阅消息模板配置Mapper
*
* @Author: System
* @Date: 2025/12/31
*/
@Mapper
public interface WechatSubscribeTemplateConfigMapper extends BaseMapper<WechatSubscribeTemplateConfigEntity> {
}

View File

@@ -0,0 +1,29 @@
package com.ycwl.basic.model.mobile.notify.req;
import lombok.Data;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/**
* 批量查询用户授权余额请求
*
* @Author: System
* @Date: 2026/01/10
*/
@Data
public class BatchRemainingCountReq {
/**
* 通知模板ID列表(微信 wechatTemplateId)
*/
@NotEmpty(message = "模板ID列表不能为空")
private List<String> templateIds;
/**
* 景区ID
*/
@NotNull(message = "景区ID不能为空")
private Long scenicId;
}

View File

@@ -26,4 +26,13 @@ public class NotificationAuthRecordReq {
*/ */
@NotNull(message = "景区ID不能为空") @NotNull(message = "景区ID不能为空")
private Long scenicId; private Long scenicId;
/**
* 前端幂等ID(可选)
* <p>
* 目的:避免前端重试导致授权次数虚增。
* 同一次用户授权动作(一次 requestSubscribeMessage)建议复用同一个 requestId。
* </p>
*/
private String requestId;
} }

View File

@@ -0,0 +1,59 @@
package com.ycwl.basic.model.mobile.notify.resp;
import lombok.Data;
import java.util.List;
/**
* 景区所有场景及其订阅消息模板列表(静态配置,不含用户授权信息)
* 用户授权信息通过 /api/mobile/notify/auth/batch-remaining 接口获取
*
* @Author: System
* @Date: 2026/01/10
*/
@Data
public class WechatSubscribeAllScenesResp {
private Long scenicId;
private List<SceneWithTemplates> scenes;
@Data
public static class SceneWithTemplates {
/**
* 场景标识
*/
private String sceneKey;
/**
* 该场景下的模板列表
*/
private List<StaticTemplateInfo> templates;
}
/**
* 静态模板信息(不含用户授权信息,可缓存)
*/
@Data
public static class StaticTemplateInfo {
/**
* 逻辑模板键(业务固定)
*/
private String templateKey;
/**
* 微信订阅消息模板ID(tmplId)
*/
private String wechatTemplateId;
/**
* 前端展示标题
*/
private String title;
/**
* 前端展示描述
*/
private String description;
}
}

View File

@@ -0,0 +1,55 @@
package com.ycwl.basic.model.mobile.notify.resp;
import lombok.Data;
import java.util.List;
/**
* 场景可申请的订阅消息模板列表(含用户授权余额)
*
* @Author: System
* @Date: 2025/12/31
*/
@Data
public class WechatSubscribeSceneTemplatesResp {
private Long scenicId;
private String sceneKey;
private List<TemplateInfo> templates;
@Data
public static class TemplateInfo {
/**
* 逻辑模板键(业务固定)
*/
private String templateKey;
/**
* 微信订阅消息模板ID(tmplId)
*/
private String wechatTemplateId;
/**
* 前端展示标题
*/
private String title;
/**
* 前端展示描述
*/
private String description;
/**
* 用户剩余授权次数
*/
private Integer remainingCount;
/**
* 是否有授权(remainingCount > 0)
*/
private Boolean hasAuthorization;
}
}

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.model.mobile.scenic.content;
import lombok.Data;
/**
* 景区模板内容响应对象
* 用于返回景区内的模板基础信息(与 faceId 无关)
*/
@Data
public class ScenicTemplateContentVO {
/**
* 商品类型
*/
private Integer goodsType;
/**
* 模板名称
*/
private String name;
/**
* 模板分组
*/
private String group;
/**
* 模板ID
*/
private Long templateId;
/**
* 模板封面URL
*/
private String templateCoverUrl;
}

View File

@@ -1,85 +0,0 @@
package com.ycwl.basic.model.pc.coupon.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Date;
@Data
@TableName("coupon")
public class CouponEntity {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private Long scenicId;
// 新增优惠券名称字段
private String name;
// 优惠券描述
private String description;
// 倒计时字段(仅用于展示)
private String countdown;
// 广播字段,仅用于展示
private String broadcast;
/**
* 优惠券类别,0:普通优惠券;1:第一次推送;2:第二次;3:第三次
*/
private Integer type;
/**
* 价格配置ID,逗号分隔字符串
*/
private String configIds;
/**
* 0降价,1打折
*/
private Integer discountType;
private BigDecimal discountPrice;
/**
* 状态:0不开启;1开启
*/
private Integer status;
private Date createAt;
private Integer deleted;
private Date deletedAt;
public BigDecimal calculateDiscountPrice(BigDecimal originalPrice) {
if (originalPrice == null) {
return BigDecimal.ZERO;
}
if (discountType == 0) {
return discountPrice;
} else {
return originalPrice.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_DOWN).multiply(discountPrice);
}
}
public BigDecimal calculateDiscountPrice(String originalPrice) {
if (originalPrice == null) {
return BigDecimal.ZERO;
}
BigDecimal priceObj = new BigDecimal(originalPrice);
if (discountType == 0) {
return discountPrice;
} else {
return priceObj.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_DOWN).multiply(discountPrice);
}
}
public String calculateDiscountedPrice(String originalPrice) {
if (originalPrice == null) {
return "0.00";
}
BigDecimal priceObj = new BigDecimal(originalPrice);
if (discountType == 0) {
return priceObj.subtract(discountPrice).setScale(2, RoundingMode.HALF_DOWN).toString();
} else {
return priceObj.subtract(priceObj.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_DOWN).multiply(discountPrice)).setScale(2, RoundingMode.HALF_DOWN).toString();
}
}
}

View File

@@ -1,31 +0,0 @@
package com.ycwl.basic.model.pc.coupon.req;
import lombok.Data;
import com.ycwl.basic.model.common.BaseQueryParameterReq;
import lombok.EqualsAndHashCode;
import java.util.Date;
@EqualsAndHashCode(callSuper = true)
@Data
// 优惠券查询请求参数
public class CouponQueryReq extends BaseQueryParameterReq {
// 景区ID
private Long scenicId;
private String name;
// 优惠券类型:0普通/1首次推送/2二次/3三次
private Integer type;
// 折扣类型:0降价/1打折
private Integer discountType;
// 状态:0关闭/1开启
private Integer status;
// 创建时间起始
private Date createAtStart;
// 创建时间结束
private Date createAtEnd;
}

View File

@@ -1,43 +0,0 @@
package com.ycwl.basic.model.pc.coupon.resp;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
public class CouponRespVO {
private Integer id;
private Long scenicId;
private String scenicName;
// 新增优惠券名称字段
private String name;
// 优惠券描述
private String description;
// 倒计时字段(仅用于展示)
private String countdown;
// 通知展示字段,仅用于展示
private String broadcast;
/**
* 优惠券类别,0:普通优惠券;1:第一次推送;2:第二次;3:第三次
*/
private Integer type;
/**
* 价格配置ID,逗号分隔字符串
*/
private String configIds;
/**
* 0降价,1打折
*/
private Integer discountType;
private BigDecimal discountPrice;
/**
* 状态:0不开启;1开启
*/
private Integer status;
private Date createAt;
}

View File

@@ -1,25 +0,0 @@
package com.ycwl.basic.model.pc.couponRecord.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
@Data
@TableName("coupon_record")
public class CouponRecordEntity {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private Integer couponId;
private Long memberId;
private Long faceId;
private Integer status;
private Date createTime;
private Date usedTime;
private Long usedOrderId;
private Integer deleted;
private Date deletedAt;
}

View File

@@ -1,13 +0,0 @@
package com.ycwl.basic.model.pc.couponRecord.req;
import lombok.Data;
@Data
public class CouponRecordPageQueryReq {
private Integer pageNum = 1;
private Integer pageSize = 10;
private Long scenicId;
private String couponName;
private Integer couponType;
private Integer status;
}

View File

@@ -1,11 +0,0 @@
package com.ycwl.basic.model.pc.couponRecord.req;
import lombok.Data;
@Data
public class CouponRecordUserQueryReq {
private Long scenicId;
private Long memberId;
private Long faceId;
private Integer couponType;
}

View File

@@ -1,23 +0,0 @@
package com.ycwl.basic.model.pc.couponRecord.resp;
import lombok.Data;
import java.util.Date;
@Data
public class CouponRecordPageResp {
private Integer id;
private Integer couponId;
private String couponName;
private Integer couponType;
private String couponTypeName;
private Long scenicId;
private String scenicName;
private Long memberId;
private Long faceId;
private Integer status;
private String statusName;
private Date createTime;
private Date usedTime;
private Long usedOrderId;
}

View File

@@ -1,26 +0,0 @@
package com.ycwl.basic.model.pc.couponRecord.resp;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
public class CouponRecordQueryResp {
private boolean exist = false;
private Integer id;
private Long scenicId;
private Integer couponId;
private Long memberId;
private Long faceId;
private Integer status;
private Date createTime;
private Date usedTime;
private Long usedOrderId;
private CouponEntity coupon;
public boolean isUsable() {
return Integer.valueOf(0).equals(status);
}
}

View File

@@ -0,0 +1,38 @@
package com.ycwl.basic.model.pc.notify.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 用户订阅消息授权明细(幂等)
*
* @Author: System
* @Date: 2025/12/31
*/
@Data
@TableName("user_notification_authorization_record")
public class UserNotificationAuthorizationRecordEntity {
@TableId
private Long id;
private Long memberId;
private Long scenicId;
/**
* 微信订阅消息模板ID(tmplId)
*/
private String templateId;
/**
* 前端幂等ID(同一次用户授权动作复用)
*/
private String requestId;
private Date createTime;
}

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