Compare commits

..

65 Commits

Author SHA1 Message Date
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
f1a2958251 feat(notification): 添加微信订阅消息配置管理及幂等授权功能
- 新增微信订阅消息配置管理控制器,支持模板、场景、事件映射配置
- 实现用户通知授权服务的幂等控制,避免前端重试导致授权次数虚增
- 添加微信订阅消息发送日志记录,用于幂等与排障
- 新增视频生成完成时的订阅消息触发功能
- 实现场景模板查询接口,返回用户授权余额信息
- 添加模板V2相关数据表映射器和实体类
- 集成微信订阅消息触发服务到任务完成流程中
2026-01-01 17:53:59 +08:00
144 changed files with 7831 additions and 2164 deletions

View File

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

View File

@@ -42,6 +42,13 @@ public class FaceStatusManager {
*/
private final Cache<String, Integer> templateRenderCache;
/**
* 拼图素材版本缓存
* 键:faceId:puzzleTemplateId -> 当时的图片源数量
* 用于判断拼图模板的素材是否发生变化,避免重复生成
*/
private final Cache<String, Integer> puzzleSourceVersionCache;
@Autowired
private TaskMapper taskMapper;
@@ -61,6 +68,11 @@ public class FaceStatusManager {
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
.maximumSize(10000)
.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);
}
}
// ==================== 拼图素材版本相关方法 ====================
/**
* 标记拼图素材版本(记录当前的图片源数量)
* 在拼图生成成功后调用,用于后续判断素材是否变化
*
* @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;
import com.ycwl.basic.clickhouse.service.StatsQueryService;
import com.ycwl.basic.enums.StatisticEnum;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.OrderMapper;
@@ -66,8 +67,10 @@ public class OrderBiz {
private PrinterService printerService;
@Autowired
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.setGoodsType(goodsType);
priceObj.setGoodsId(goodsId);
@@ -99,8 +102,10 @@ public class OrderBiz {
vlogProductItem.setQuantity(videoTaskRepository.getTaskLensNum(video.getTaskId()));
vlogProductItem.setScenicId(scenicId.toString());
vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem));
vlogCalculationRequest.setUserId(memberId);
vlogCalculationRequest.setFaceId(priceObj.getFaceId());
vlogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
vlogCalculationRequest.setAutoUseCoupon(true);
PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest);
priceObj.setPrice(vlogCalculationResult.getFinalAmount());
priceObj.setSlashPrice(vlogCalculationResult.getOriginalAmount());
@@ -120,13 +125,33 @@ public class OrderBiz {
if (face != null) {
calculationRequest.setUserId(face.getMemberId());
}
calculationRequest.setUserId(memberId);
calculationRequest.setFaceId(goodsId);
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
calculationRequest.setAutoUseCoupon(true);
PriceCalculationResult priceCalculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
priceObj.setPrice(priceCalculationResult.getFinalAmount());
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
priceObj.setFaceId(goodsId);
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:
PriceCalculationRequest aiCamCalculationRequest = new PriceCalculationRequest();
ProductItem aiCamProductItem = new ProductItem();
@@ -135,7 +160,10 @@ public class OrderBiz {
aiCamProductItem.setPurchaseCount(1);
aiCamProductItem.setScenicId(scenicId.toString());
aiCamCalculationRequest.setProducts(Collections.singletonList(aiCamProductItem));
aiCamCalculationRequest.setUserId(memberId);
aiCamCalculationRequest.setFaceId(goodsId);
aiCamCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
aiCamCalculationRequest.setAutoUseCoupon(true);
PriceCalculationResult aiCamPriceCalculationResult = iPriceCalculationService.calculatePrice(aiCamCalculationRequest);
priceObj.setPrice(aiCamPriceCalculationResult.getFinalAmount());
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) {
return respVO;
}
@@ -229,7 +257,7 @@ public class OrderBiz {
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
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)){//
statisticsRecordAddReq.setType(StatisticEnum.ON_SITE_PAYMENT.code);
}else {

View File

@@ -15,6 +15,7 @@ import com.ycwl.basic.product.capability.ProductTypeCapability;
import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.OrderRepository;
@@ -50,6 +51,8 @@ public class PriceBiz {
@Autowired
private PuzzleTemplateMapper puzzleTemplateMapper;
@Autowired
private PuzzleRepository puzzleRepository;
@Autowired
private IProductTypeCapabilityManagementService productTypeCapabilityManagementService;
@Autowired
private OrderRepository orderRepository;
@@ -74,8 +77,8 @@ public class PriceBiz {
goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
}
}
// 拼图
puzzleTemplateMapper.list(scenicId, null, 1).forEach(puzzleTemplate -> {
// 拼图(使用缓存)
puzzleRepository.listTemplateByScenic(scenicId).forEach(puzzleTemplate -> {
GoodsListRespVO goods = new GoodsListRespVO();
goods.setGoodsId(puzzleTemplate.getId());
goods.setGoodsName(puzzleTemplate.getName());
@@ -131,7 +134,7 @@ public class PriceBiz {
case "PHOTO_LOG":
// 从 template 表查询pLog模板
List<PuzzleTemplateEntity> puzzleList = puzzleTemplateMapper.list(scenicId, null, null);
List<PuzzleTemplateEntity> puzzleList = puzzleRepository.listTemplateByScenic(scenicId);
puzzleList.stream()
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
.forEach(goodsList::add);

View File

@@ -79,12 +79,26 @@ public interface StatsQueryService {
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

@@ -106,7 +106,9 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
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 JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
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(" ");
@@ -167,7 +169,9 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
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 JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
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(" ");
}
@@ -183,7 +187,9 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
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 JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
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(" ");
}
@@ -357,8 +363,6 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
sql.append(") ");
sql.append("AND r.action = 'LAUNCH' ");
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
sql.append("AND s.create_time BETWEEN ").append(formatDateTime(query.getStartTime()));
sql.append(" AND ").append(formatDateTime(query.getEndTime())).append(" ");
sql.append("GROUP BY toStartOfHour(s.create_time) ");
sql.append("ORDER BY toStartOfHour(s.create_time)");
@@ -382,8 +386,64 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
sql.append(") ");
sql.append("AND r.action = 'LAUNCH' ");
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
sql.append("AND s.create_time BETWEEN ").append(formatDateTime(query.getStartTime()));
sql.append(" AND ").append(formatDateTime(query.getEndTime())).append(" ");
sql.append("GROUP BY toStartOfDay(s.create_time) ");
sql.append("ORDER BY toStartOfDay(s.create_time)");
return 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;
});
}
@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)");
return 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;
});
}
@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)");

View File

@@ -98,4 +98,14 @@ public class MySqlStatsQueryServiceImpl implements StatsQueryService {
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

@@ -96,9 +96,8 @@ public class AppFaceController {
// 绑定人脸
@PostMapping("/{faceId}/bind")
public ApiResponse<String> bind(@PathVariable Long faceId) {
JwtInfo worker = JwtTokenUtil.getWorker();
Long userId = worker.getUserId();
faceService.bindFace(faceId, userId);
// dummy item
faceService.matchFaceId(faceId, true);
return ApiResponse.success("OK");
}

View File

@@ -53,12 +53,6 @@ public class AppGoodsController {
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")
public ApiResponse<List<GoodsUrlVO>> sourceGoodsListDownload(@RequestBody GoodsReqQuery query) {
List<GoodsUrlVO> goodsUrlList = goodsService.sourceGoodsListDownload(query);

View File

@@ -1,7 +1,6 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.constant.SourceType;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
@@ -11,7 +10,7 @@ import com.ycwl.basic.pricing.dto.ProductItem;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.pricing.service.IPriceCalculationService;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.utils.ApiResponse;
@@ -32,7 +31,7 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class AppPuzzleController {
private final PuzzleGenerationRecordMapper recordMapper;
private final PuzzleRepository puzzleRepository;
private final FaceRepository faceRepository;
private final IPriceCalculationService iPriceCalculationService;
private final PrinterService printerService;
@@ -46,7 +45,7 @@ public class AppPuzzleController {
if (faceId == null) {
return ApiResponse.fail("faceId不能为空");
}
int count = recordMapper.countByFaceId(faceId);
int count = puzzleRepository.countRecordsByFaceId(faceId);
return ApiResponse.success(count);
}
@@ -58,7 +57,7 @@ public class AppPuzzleController {
if (faceId == null) {
return ApiResponse.fail("faceId不能为空");
}
List<PuzzleGenerationRecordEntity> records = recordMapper.listByFaceId(faceId);
List<PuzzleGenerationRecordEntity> records = puzzleRepository.getRecordsByFaceId(faceId);
List<ContentPageVO> result = records.stream()
.map(this::convertToContentPageVO)
.collect(Collectors.toList());
@@ -73,7 +72,7 @@ public class AppPuzzleController {
if (recordId == null) {
return ApiResponse.fail("recordId不能为空");
}
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录");
}
@@ -89,7 +88,7 @@ public class AppPuzzleController {
if (recordId == null) {
return ApiResponse.fail("recordId不能为空");
}
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录");
}
@@ -108,7 +107,7 @@ public class AppPuzzleController {
if (recordId == null) {
return ApiResponse.fail("recordId不能为空");
}
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录");
}
@@ -142,14 +141,14 @@ public class AppPuzzleController {
}
// 查询拼图记录
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
if (record == null) {
return ApiResponse.fail("未找到对应的拼图记录");
}
// 检查是否有图片URL
String resultImageUrl = record.getResultImageUrl();
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
String imageUrl = record.getResultImageUrl();
if (imageUrl == null || imageUrl.isEmpty()) {
return ApiResponse.fail("该拼图记录没有可用的图片URL");
}
@@ -164,8 +163,8 @@ public class AppPuzzleController {
face.getMemberId(),
face.getScenicId(),
record.getFaceId(),
resultImageUrl,
0L // 打印特有
imageUrl,
recordId // 拼图记录ID,用于关联 puzzle_record 表
);
if (memberPrintId == null) {

View File

@@ -1,8 +1,9 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.mapper.TemplateMapper;
import com.ycwl.basic.model.pc.template.entity.TemplateEntity;
import com.ycwl.basic.annotation.IgnoreToken;
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.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
@@ -11,6 +12,10 @@ 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;
/**
* 移动端模板接口
*/
@@ -20,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
public class AppTemplateController {
private final TemplateRepository templateRepository;
private final PuzzleRepository puzzleRepository;
/**
* 根据模板ID获取封面URL
@@ -45,4 +51,38 @@ public class AppTemplateController {
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);
}
}

View File

@@ -1,8 +1,10 @@
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.resp.NotificationAuthRecordResp;
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.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
@@ -14,7 +16,9 @@ import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 用户通知授权记录Controller (移动端API)
@@ -41,7 +45,8 @@ public class UserNotificationAuthController {
@PostMapping("/record")
public ApiResponse<NotificationAuthRecordResp> recordAuthorization(
@RequestBody NotificationAuthRecordReq req) {
log.debug("记录用户通知授权: templateIds={}, scenicId={}", req.getTemplateIds(), req.getScenicId());
log.debug("记录用户通知授权: templateIds={}, scenicId={}, requestId={}",
req.getTemplateIds(), req.getScenicId(), req.getRequestId());
try {
// 获取当前用户ID
@@ -50,7 +55,7 @@ public class UserNotificationAuthController {
// 调用批量授权记录方法
List<UserNotificationAuthorizationService.AuthorizationRecord> records =
userNotificationAuthorizationService.batchRecordAuthorization(
memberId, req.getTemplateIds(), req.getScenicId());
memberId, req.getTemplateIds(), req.getScenicId(), req.getRequestId());
NotificationAuthRecordResp resp = new NotificationAuthRecordResp();
@@ -93,98 +98,42 @@ public class UserNotificationAuthController {
}
/**
* 获取景区通知模板ID及用户授权余额
* 复制AppWxNotifyController中的逻辑,并额外返回用户对应的授权余额
* 批量查询用户授权余额
* 返回 Map<wechatTemplateId, remainingCount>
*/
@GetMapping("/{scenicId}/templates")
public ApiResponse<ScenicTemplateAuthResp> getScenicTemplatesWithAuth(@PathVariable("scenicId") Long scenicId) {
log.debug("获取景区通知模板ID及用户授权余额: scenicId={}", scenicId);
@PostMapping("/batch-remaining")
public ApiResponse<Map<String, Integer>> batchGetRemainingCount(
@RequestBody BatchRemainingCountReq req) {
log.debug("批量查询用户授权余额: templateIds={}, scenicId={}",
req.getTemplateIds(), req.getScenicId());
try {
// 获取当前用户ID
Long memberId = JwtTokenUtil.getWorker().getUserId();
// 获取景区的所有模板ID(复制自AppWxNotifyController的逻辑)
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 (memberId == null) {
return ApiResponse.fail("用户未登录");
}
// 获取授权详情
try {
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);
if (CollectionUtils.isEmpty(req.getTemplateIds())) {
return ApiResponse.success(new HashMap<>());
}
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) {
log.warn("获取模板授权信息失败: templateId={}, scenicId={}, memberId={}, error={}",
templateId, scenicId, memberId, 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());
log.error("批量查询用户授权余额失败", 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

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

View File

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

@@ -120,46 +120,8 @@ public class PrinterTvController {
*/
@GetMapping("/face/{faceId}/qrcode")
public void getFaceQrcode(@PathVariable("faceId") Long faceId, HttpServletResponse response) throws Exception {
File qrcode = new File("qrcode_face_" + faceId + ".jpg");
try {
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
response.setStatus(404);
return;
}
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(face.getScenicId());
if (scenicMpConfig == null) {
response.setStatus(500);
return;
}
WxMpUtil.generateUnlimitedWXAQRCode(
scenicMpConfig.getAppId(),
scenicMpConfig.getAppSecret(),
"pages/videoSynthesis/bind_face",
faceId.toString(),
qrcode
);
// 设置响应头
response.setContentType("image/jpeg");
response.setHeader("Content-Disposition", "inline; filename=\"" + qrcode.getName() + "\"");
// 将二维码文件写入响应输出流
try (FileInputStream fis = new FileInputStream(qrcode);
OutputStream os = response.getOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.flush();
}
} finally {
// 删除临时文件
if (qrcode.exists()) {
qrcode.delete();
}
}
String url = pcFaceService.bindWxaCode(faceId);
response.sendRedirect(url);
}
/**
@@ -197,5 +159,36 @@ public class PrinterTvController {
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) {
// 异步任务失败不影响主流程,仅记录日志
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
log.error("异步生成拼图模板失败: scenicId={}, faceId={}, e={}", scenicId, faceId, e.getMessage());
}
}, "PuzzleTemplateGenerator-" + scenicId + "-" + faceId).start();
}

View File

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

View File

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

View File

@@ -168,7 +168,6 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
// 重试时也不需要限流,由外层调度器控制
JSONObject retryResponse = client.addUser(base64Image, "BASE64", dbName, entityId, options);
if (retryResponse.getInt("error_code") == 0) {
log.info("使用base64重试添加人脸成功,entityId: {}", entityId);
AddFaceResp resp = new AddFaceResp();
resp.setScore(100f);
return resp;

View File

@@ -1,6 +1,8 @@
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.storage.adapters.IStorageAdapter;
import lombok.Builder;
import lombok.Getter;
@@ -41,4 +43,28 @@ public class WatermarkConfig {
*/
@Builder.Default
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.enums.ImageType;
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.enums.ImageWatermarkOperatorEnum;
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.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
@@ -21,6 +23,7 @@ import java.util.List;
/**
* 水印处理Stage
* 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印
* 支持边缘端渲染(可选)
*/
@Slf4j
@StageConfig(
@@ -127,6 +130,19 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
File watermarkedFile = context.getTempFileManager()
.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);
IOperator operator = ImageWatermarkFactory.get(type);
@@ -143,6 +159,46 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
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.exception.ImageWatermarkUnsupportedException;
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.NormalWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.PrinterDefaultWatermarkOperator;
@@ -18,11 +17,11 @@ public class ImageWatermarkFactory {
}
public static IOperator get(ImageWatermarkOperatorEnum type) {
return switch (type) {
case WATERMARK -> new DefaultImageWatermarkOperator();
case NORMAL -> new NormalWatermarkOperator();
case LEICA -> new LeicaWatermarkOperator();
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,141 @@
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;
/**
* 拼图默认水印模板构建器
*
* 布局说明:
* - 白色背景
* - 顶部90%为原图区域(COVER模式)
* - 底部10%为信息区域:
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
*/
@Component
public class PuzzleDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
public static final String STYLE = "puzzle_default";
// 布局比例配置
private static final double IMAGE_HEIGHT_RATIO = 0.90; // 原图占90%高度
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 canvasWidth = imageWidth;
int canvasHeight = imageHeight;
// 原图区域占90%高度,底部信息区占10%高度
int originalImageHeight = (int) (imageHeight * IMAGE_HEIGHT_RATIO);
int bottomAreaHeight = imageHeight - originalImageHeight;
// 创建模板(白色背景)
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,155 @@
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%白边
* - 内部区域:顶部90%为原图区域(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 IMAGE_HEIGHT_RATIO = 0.90; // 原图占内容区90%高度
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 canvasWidth = imageWidth + borderX * 2;
int canvasHeight = imageHeight + borderY * 2;
// 内容区起始位置(白边内)
int contentStartX = borderX;
int contentStartY = borderY;
// 内容区尺寸 = 原图尺寸
int contentWidth = imageWidth;
int contentHeight = imageHeight;
// 原图区域占90%高度,底部信息区占10%高度
int originalImageHeight = (int) (contentHeight * IMAGE_HEIGHT_RATIO);
int bottomAreaHeight = contentHeight - originalImageHeight;
// 创建模板(白色背景)
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) (contentHeight * 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
public enum ImageWatermarkOperatorEnum {
WATERMARK("defW", "jpg"),
LEICA("leica", "png"),
NORMAL("normal", "png"),
PRINTER_DEFAULT("pDefault", "png");
LEICA("leica", "jpg"),
NORMAL("normal", "jpg"),
PRINTER_DEFAULT("pDefault", "jpg"),
PUZZLE_PRINT("puzzle_print", "jpg");
private final String type;
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

@@ -105,10 +105,36 @@ public interface StatisticsMapper {
List<HashMap<String, String>> orderChartByHour(CommonQueryReq query);
/**
* 按小时统计扫码人数(仅统计数据,不含订单)
*/
List<HashMap<String, String>> scanCodeMemberChartByHour(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);
/**
* 统计分销员扫码次数
*/

View File

@@ -89,4 +89,18 @@ public interface UserNotificationAuthorizationMapper extends BaseMapper<UserNoti
@Param("templateId") String templateId,
@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不能为空")
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,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;
}

View File

@@ -0,0 +1,46 @@
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("wechat_subscribe_event_template")
public class WechatSubscribeEventTemplateEntity {
@TableId
private Long id;
private String eventKey;
private String templateKey;
/**
* 景区ID;0=默认配置
*/
private Long scenicId;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
private Integer sortOrder;
private Integer sendDelaySeconds;
private Integer dedupSeconds;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,42 @@
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("wechat_subscribe_scene_template")
public class WechatSubscribeSceneTemplateEntity {
@TableId
private Long id;
private String sceneKey;
private String templateKey;
/**
* 景区ID;0=默认配置
*/
private Long scenicId;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
private Integer sortOrder;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,48 @@
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("wechat_subscribe_send_log")
public class WechatSubscribeSendLogEntity {
@TableId
private Long id;
private String idempotencyKey;
private String eventKey;
private String templateKey;
private Long scenicId;
private Long memberId;
private String openId;
private String wechatTemplateId;
private String ztMessageId;
private String status;
private String errorMessage;
private String payloadJson;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,71 @@
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("wechat_subscribe_template_config")
public class WechatSubscribeTemplateConfigEntity {
@TableId
private Long id;
/**
* 逻辑模板键(业务固定)
*/
private String templateKey;
/**
* 景区ID;0=默认配置
*/
private Long scenicId;
/**
* 微信订阅消息模板ID(tmplId)
*/
private String wechatTemplateId;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
/**
* 标题模板(用于日志/后台展示)
*/
private String titleTemplate;
/**
* 内容模板(用于日志/后台展示)
*/
private String contentTemplate;
/**
* 跳转页面模板(小程序 page)
*/
private String pageTemplate;
/**
* data模板JSON:{ "thing1":"${scenicName}", "thing3":"${remark}" }
*/
private String dataTemplateJson;
/**
* 前端展示用描述
*/
private String description;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,37 @@
package com.ycwl.basic.model.pc.notify.req;
import com.ycwl.basic.model.common.BaseQueryParameterReq;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 事件-模板映射分页查询请求
*
* @Author: System
* @Date: 2025/12/31
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class WechatSubscribeEventTemplatePageReq extends BaseQueryParameterReq {
/**
* 景区ID;0=默认配置;为空表示不筛选
*/
private Long scenicId;
/**
* 事件键(模糊匹配)
*/
private String eventKey;
/**
* 逻辑模板键(模糊匹配)
*/
private String templateKey;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
}

View File

@@ -0,0 +1,48 @@
package com.ycwl.basic.model.pc.notify.req;
import lombok.Data;
/**
* 事件-模板映射保存请求(新增/修改)
*
* @Author: System
* @Date: 2025/12/31
*/
@Data
public class WechatSubscribeEventTemplateSaveReq {
/**
* 主键ID(为空表示新增;不为空表示更新)
*/
private Long id;
private String eventKey;
private String templateKey;
/**
* 景区ID;0=默认配置
*/
private Long scenicId;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
/**
* 排序(越小越靠前)
*/
private Integer sortOrder;
/**
* 发送延迟(秒),0表示立即发送(预留)
*/
private Integer sendDelaySeconds;
/**
* 去重窗口(秒),0表示仅依赖幂等键(预留)
*/
private Integer dedupSeconds;
}

View File

@@ -0,0 +1,37 @@
package com.ycwl.basic.model.pc.notify.req;
import lombok.Builder;
import lombok.Data;
import java.util.Map;
/**
* 微信订阅消息触发入参(后端内部调用)
*
* @Author: System
* @Date: 2025/12/31
*/
@Data
@Builder
public class WechatSubscribeNotifyTriggerRequest {
private Long scenicId;
private Long memberId;
private String openId;
/**
* 业务幂等ID(强烈建议必填)
* <p>
* 示例:taskId、couponId、faceId+日期 等。
* </p>
*/
private String bizId;
/**
* 模板渲染变量(${key})
*/
private Map<String, Object> variables;
}

View File

@@ -0,0 +1,37 @@
package com.ycwl.basic.model.pc.notify.req;
import com.ycwl.basic.model.common.BaseQueryParameterReq;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 场景-模板映射分页查询请求
*
* @Author: System
* @Date: 2025/12/31
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class WechatSubscribeSceneTemplatePageReq extends BaseQueryParameterReq {
/**
* 景区ID;0=默认配置;为空表示不筛选
*/
private Long scenicId;
/**
* 场景键(模糊匹配)
*/
private String sceneKey;
/**
* 逻辑模板键(模糊匹配)
*/
private String templateKey;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
}

View File

@@ -0,0 +1,38 @@
package com.ycwl.basic.model.pc.notify.req;
import lombok.Data;
/**
* 场景-模板映射保存请求(新增/修改)
*
* @Author: System
* @Date: 2025/12/31
*/
@Data
public class WechatSubscribeSceneTemplateSaveReq {
/**
* 主键ID(为空表示新增;不为空表示更新)
*/
private Long id;
private String sceneKey;
private String templateKey;
/**
* 景区ID;0=默认配置
*/
private Long scenicId;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
/**
* 排序(越小越靠前)
*/
private Integer sortOrder;
}

View File

@@ -0,0 +1,27 @@
package com.ycwl.basic.model.pc.notify.req;
import com.ycwl.basic.model.common.BaseQueryParameterReq;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 微信订阅消息发送日志分页查询请求
*
* @Author: System
* @Date: 2025/12/31
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class WechatSubscribeSendLogPageReq extends BaseQueryParameterReq {
private Long scenicId;
private Long memberId;
private String eventKey;
private String templateKey;
private String status;
}

View File

@@ -0,0 +1,37 @@
package com.ycwl.basic.model.pc.notify.req;
import com.ycwl.basic.model.common.BaseQueryParameterReq;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 微信订阅消息模板配置分页查询请求
*
* @Author: System
* @Date: 2025/12/31
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class WechatSubscribeTemplateConfigPageReq extends BaseQueryParameterReq {
/**
* 景区ID;0=默认配置;为空表示不筛选
*/
private Long scenicId;
/**
* 逻辑模板键(模糊匹配)
*/
private String templateKey;
/**
* 微信订阅消息模板ID(模糊匹配)
*/
private String wechatTemplateId;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
}

View File

@@ -0,0 +1,64 @@
package com.ycwl.basic.model.pc.notify.req;
import lombok.Data;
/**
* 微信订阅消息模板配置保存请求(新增/修改)
*
* @Author: System
* @Date: 2025/12/31
*/
@Data
public class WechatSubscribeTemplateConfigSaveReq {
/**
* 主键ID(为空表示新增;不为空表示更新)
*/
private Long id;
/**
* 逻辑模板键(业务固定)
*/
private String templateKey;
/**
* 景区ID;0=默认配置
*/
private Long scenicId;
/**
* 微信订阅消息模板ID(tmplId)
*/
private String wechatTemplateId;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
/**
* 标题模板(用于日志/后台展示)
*/
private String titleTemplate;
/**
* 内容模板(用于日志/后台展示)
*/
private String contentTemplate;
/**
* 跳转页面模板(小程序 page)
*/
private String pageTemplate;
/**
* data模板JSON:{ "thing1":"${scenicName}", "thing3":"${remark}" }
*/
private String dataTemplateJson;
/**
* 前端展示用描述
*/
private String description;
}

View File

@@ -0,0 +1,29 @@
package com.ycwl.basic.model.pc.notify.resp;
import lombok.Data;
/**
* 微信订阅消息触发结果(后端内部调用)
*
* @Author: System
* @Date: 2025/12/31
*/
@Data
public class WechatSubscribeNotifyTriggerResult {
/**
* 是否找到了可用配置(eventKey -> templateKey -> templateConfig)
*/
private boolean configFound;
/**
* 成功投递到消息系统的数量(以 producer send 成功返回为准)
*/
private int sentCount;
/**
* 跳过数量(无授权/幂等命中/配置不完整等)
*/
private int skippedCount;
}

View File

@@ -16,6 +16,7 @@ public class MemberPrintEntity {
private Long memberId;
private Long faceId;
private Long sourceId;
private String imageType;
private String origUrl;
private String cropUrl;
private String printUrl;

View File

@@ -9,6 +9,7 @@ public class MemberPrintResp {
private Integer id;
private Long scenicId;
private Long sourceId;
private String imageType;
private String scenicName;
private Long faceId;
private Long memberId;

View File

@@ -0,0 +1,21 @@
package com.ycwl.basic.model.pc.puzzle.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 会员拼图关联实体
* 记录人脸与拼图生成记录的关联关系,包含免费和购买状态
*/
@Data
@TableName("member_puzzle")
public class MemberPuzzleEntity {
private Long id;
private Long memberId;
private Long scenicId;
private Long faceId;
private Long recordId;
private Integer isBuy;
private Long orderId;
private Integer isFree;
}

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.model.pc.puzzle.entity;
import lombok.Data;
/**
* 拼图水印实体
* 存储拼图在不同场景下的水印版本(如打印水印、免费下载水印等)
*/
@Data
public class PuzzleWatermarkEntity {
private Integer id;
private Long recordId;
private Long faceId;
private String watermarkType;
private String watermarkUrl;
}

View File

@@ -21,4 +21,14 @@ public class CreateVirtualOrderRequest {
* 打印机ID(可选)
*/
private Integer printerId;
/**
* 是否需要图像增强(可选,默认不增强)
*/
private Boolean needEnhance;
/**
* 打印图片URL(可选,如果提供则使用此URL进行打印)
*/
private String printImgUrl;
}

View File

@@ -0,0 +1,92 @@
package com.ycwl.basic.pricing.controller;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.pricing.dto.CouponClaimResult;
import com.ycwl.basic.pricing.dto.req.SceneCouponClaimReq;
import com.ycwl.basic.pricing.dto.resp.SceneCouponAvailableResp;
import com.ycwl.basic.pricing.service.ISceneCouponService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 场景优惠券领取控制器(前端/移动端)
*/
@Slf4j
@RestController
@RequestMapping("/api/pricing/scene-coupon")
@RequiredArgsConstructor
public class SceneCouponClaimController {
private final ISceneCouponService sceneCouponService;
/**
* 查询场景下可领取的优惠券列表
*
* @param sceneKey 场景标识
* @param scenicId 景区ID
* @return 可领取优惠券列表(包含用户领取状态)
*/
@GetMapping("/available")
public ApiResponse<List<SceneCouponAvailableResp>> getAvailableCoupons(
@RequestParam String sceneKey,
@RequestParam Long scenicId) {
try {
Long userId = getUserId();
List<SceneCouponAvailableResp> list = sceneCouponService.getAvailableCoupons(sceneKey, scenicId, userId);
return ApiResponse.success(list);
} catch (Exception e) {
log.error("场景优惠券|可领取列表查询失败 sceneKey={}, scenicId={}", sceneKey, scenicId, e);
return ApiResponse.fail("查询失败: " + e.getMessage());
}
}
/**
* 领取场景优惠券
*
* @param req 领取请求
* @return 领取结果列表
*/
@PostMapping("/claim")
public ApiResponse<List<CouponClaimResult>> claimCoupons(@RequestBody SceneCouponClaimReq req) {
try {
Long userId = getUserId();
if (userId == null) {
return ApiResponse.fail("用户未登录");
}
List<CouponClaimResult> results = sceneCouponService.claimCoupons(req, userId);
// 判断整体结果
boolean hasSuccess = results.stream().anyMatch(CouponClaimResult::isSuccess);
if (!hasSuccess && !results.isEmpty()) {
// 全部失败,返回第一个错误信息
return ApiResponse.fail(results.get(0).getErrorMessage());
}
return ApiResponse.success(results);
} catch (Exception e) {
log.error("场景优惠券|领取失败 req={}", req, e);
return ApiResponse.fail("领取失败: " + e.getMessage());
}
}
/**
* 获取当前登录用户ID
*/
private Long getUserId() {
try {
String userIdStr = BaseContextHandler.getUserId();
if (userIdStr == null || userIdStr.isEmpty()) {
return null;
}
return Long.valueOf(userIdStr);
} catch (NumberFormatException e) {
log.warn("无法解析用户ID: {}", BaseContextHandler.getUserId());
return null;
}
}
}

View File

@@ -0,0 +1,116 @@
package com.ycwl.basic.pricing.controller;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigPageReq;
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigSaveReq;
import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp;
import com.ycwl.basic.pricing.service.ISceneCouponService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 场景优惠券配置管理控制器(后台管理端)
*/
@Slf4j
@RestController
@RequestMapping("/api/pricing/admin/scene-coupon")
@RequiredArgsConstructor
public class SceneCouponConfigController {
private final ISceneCouponService sceneCouponService;
/**
* 分页查询场景优惠券配置
*/
@PostMapping("/page")
public ApiResponse<PageInfo<SceneCouponConfigResp>> page(@RequestBody SceneCouponConfigPageReq req) {
try {
PageInfo<SceneCouponConfigResp> pageInfo = sceneCouponService.pageConfig(req);
return ApiResponse.success(pageInfo);
} catch (Exception e) {
log.error("场景优惠券|分页查询失败", e);
return ApiResponse.fail("分页查询失败: " + e.getMessage());
}
}
/**
* 获取配置详情
*/
@GetMapping("/detail/{id}")
public ApiResponse<SceneCouponConfigResp> getDetail(@PathVariable("id") Long id) {
try {
SceneCouponConfigResp detail = sceneCouponService.getConfigDetail(id);
if (detail == null) {
return ApiResponse.fail("记录不存在");
}
return ApiResponse.success(detail);
} catch (Exception e) {
log.error("场景优惠券|详情查询失败 id={}", id, e);
return ApiResponse.fail("详情查询失败: " + e.getMessage());
}
}
/**
* 保存配置(新增/更新)
*/
@PostMapping("/save")
public ApiResponse<Boolean> save(@RequestBody SceneCouponConfigSaveReq req) {
try {
boolean success = sceneCouponService.saveConfig(req);
return ApiResponse.success(success);
} catch (IllegalArgumentException e) {
return ApiResponse.fail(e.getMessage());
} catch (Exception e) {
log.error("场景优惠券|保存失败", e);
return ApiResponse.fail("保存失败: " + e.getMessage());
}
}
/**
* 删除配置
*/
@DeleteMapping("/delete/{id}")
public ApiResponse<Boolean> delete(@PathVariable("id") Long id) {
try {
boolean success = sceneCouponService.deleteConfig(id);
return ApiResponse.success(success);
} catch (Exception e) {
log.error("场景优惠券|删除失败 id={}", id, e);
return ApiResponse.fail("删除失败: " + e.getMessage());
}
}
/**
* 获取所有已配置的场景列表(用于前端下拉选择)
*/
@GetMapping("/scenes")
public ApiResponse<List<String>> listSceneKeys() {
try {
List<String> sceneKeys = sceneCouponService.listSceneKeys();
return ApiResponse.success(sceneKeys);
} catch (Exception e) {
log.error("场景优惠券|场景列表查询失败", e);
return ApiResponse.fail("场景列表查询失败: " + e.getMessage());
}
}
/**
* 根据场景和景区查询配置列表
*/
@GetMapping("/by-scene")
public ApiResponse<List<SceneCouponConfigResp>> listByScene(
@RequestParam String sceneKey,
@RequestParam Long scenicId) {
try {
List<SceneCouponConfigResp> list = sceneCouponService.listBySceneKeyAndScenicId(sceneKey, scenicId);
return ApiResponse.success(list);
} catch (Exception e) {
log.error("场景优惠券|按场景查询失败 sceneKey={}, scenicId={}", sceneKey, scenicId, e);
return ApiResponse.fail("按场景查询失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,25 @@
package com.ycwl.basic.pricing.dto.req;
import lombok.Data;
/**
* 场景优惠券领取请求
*/
@Data
public class SceneCouponClaimReq {
/**
* 场景标识符(必填)
*/
private String sceneKey;
/**
* 景区ID(必填)
*/
private Long scenicId;
/**
* 指定领取的优惠券ID(可选,不传则领取场景下所有可领取的优惠券)
*/
private Long couponId;
}

View File

@@ -0,0 +1,33 @@
package com.ycwl.basic.pricing.dto.req;
import com.ycwl.basic.model.common.BaseQueryParameterReq;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 场景优惠券配置分页查询请求
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class SceneCouponConfigPageReq extends BaseQueryParameterReq {
/**
* 场景标识(模糊匹配)
*/
private String sceneKey;
/**
* 景区ID;为空表示不筛选
*/
private Long scenicId;
/**
* 优惠券ID
*/
private Long couponId;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
}

View File

@@ -0,0 +1,40 @@
package com.ycwl.basic.pricing.dto.req;
import lombok.Data;
/**
* 场景优惠券配置保存请求(新增/修改)
*/
@Data
public class SceneCouponConfigSaveReq {
/**
* 主键ID(为空表示新增;不为空表示更新)
*/
private Long id;
/**
* 场景标识符
*/
private String sceneKey;
/**
* 关联的优惠券ID
*/
private Long couponId;
/**
* 景区ID;0=默认配置
*/
private Long scenicId;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
/**
* 排序顺序(越小越靠前)
*/
private Integer sortOrder;
}

View File

@@ -0,0 +1,78 @@
package com.ycwl.basic.pricing.dto.resp;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 场景下可领取优惠券响应
*/
@Data
public class SceneCouponAvailableResp {
/**
* 优惠券ID
*/
private Long couponId;
/**
* 优惠券名称
*/
private String couponName;
/**
* 优惠券类型 (PERCENTAGE/FIXED_AMOUNT)
*/
private String couponType;
/**
* 优惠值
*/
private BigDecimal discountValue;
/**
* 最小使用金额
*/
private BigDecimal minAmount;
/**
* 最大优惠金额
*/
private BigDecimal maxDiscount;
/**
* 有效期起始
*/
private Date validFrom;
/**
* 有效期结束
*/
private Date validUntil;
/**
* 用户可领取数量限制(每人最多可领,null或0表示无限制)
*/
private Integer userClaimLimit;
/**
* 用户已领取数量
*/
private Integer userClaimedCount;
/**
* 用户剩余可领取数量(-1表示无限制,0表示已达上限)
*/
private Integer userRemaining;
/**
* 是否可领取(综合判断:库存、用户限制、有效期等)
*/
private Boolean canClaim;
/**
* 不可领取原因(当 canClaim=false 时)
*/
private String cannotClaimReason;
}

View File

@@ -0,0 +1,63 @@
package com.ycwl.basic.pricing.dto.resp;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 场景优惠券配置响应(包含优惠券详情)
*/
@Data
public class SceneCouponConfigResp {
// ========== 配置信息 ==========
private Long id;
private String sceneKey;
private Long couponId;
private Long scenicId;
private Integer enabled;
private Integer sortOrder;
private Date createTime;
private Date updateTime;
// ========== 关联的优惠券信息 ==========
/**
* 优惠券名称
*/
private String couponName;
/**
* 优惠券类型 (PERCENTAGE/FIXED_AMOUNT)
*/
private String couponType;
/**
* 优惠值
*/
private BigDecimal discountValue;
/**
* 优惠券是否启用
*/
private Boolean couponActive;
/**
* 优惠券有效期起始
*/
private Date couponValidFrom;
/**
* 优惠券有效期结束
*/
private Date couponValidUntil;
}

View File

@@ -8,7 +8,6 @@ import com.ycwl.basic.pricing.enums.CouponType;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Date;
/**
@@ -80,12 +79,12 @@ public class PriceCouponConfig {
/**
* 生效时间
*/
private LocalDateTime validFrom;
private Date validFrom;
/**
* 失效时间
*/
private LocalDateTime validUntil;
private Date validUntil;
/**
* 是否启用

View File

@@ -0,0 +1,61 @@
package com.ycwl.basic.pricing.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;
/**
* 场景-优惠券关联配置实体
* <p>
* 用于配置不同场景下可领取的优惠券,支持分景区配置。
* scenicId=0 表示默认配置(所有景区通用),具体景区配置优先级高于默认配置。
* </p>
*/
@Data
@TableName("price_scene_coupon_config")
public class PriceSceneCouponConfig {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 场景标识符(如 home_banner, checkout_page, new_user_popup)
*/
private String sceneKey;
/**
* 关联的优惠券ID (price_coupon_config.id)
*/
private Long couponId;
/**
* 景区ID;0=默认配置(所有景区通用)
*/
private Long scenicId;
/**
* 是否启用:1启用 0禁用
*/
private Integer enabled;
/**
* 排序顺序(越小越靠前)
*/
private Integer sortOrder;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
}

View File

@@ -17,44 +17,55 @@ import java.util.List;
public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
/**
* 查询有效的优惠券配置
* 查询有效的优惠券配置(可领取的)
*/
@Select("SELECT * FROM price_coupon_config WHERE is_active = 1 " +
"AND valid_from <= NOW() AND valid_until > NOW() " +
"AND used_quantity < total_quantity")
"AND (total_quantity IS NULL OR total_quantity <= 0 OR COALESCE(claimed_quantity, 0) < total_quantity)")
List<PriceCouponConfig> selectValidCoupons();
/**
* 根据ID查询优惠券(包括使用数量检查)
* 根据ID查询优惠券(包括库存检查)
*/
@Select("SELECT * FROM price_coupon_config WHERE id = #{couponId} " +
"AND is_active = 1 AND valid_from <= NOW() AND valid_until > NOW() " +
"AND used_quantity < total_quantity")
"AND (total_quantity IS NULL OR total_quantity <= 0 OR COALESCE(claimed_quantity, 0) < total_quantity)")
PriceCouponConfig selectValidCouponById(Long couponId);
/**
* 增加优惠券使用数量
* 增加优惠券使用数量(支持无限量优惠券)
* 当 total_quantity 为 NULL 或 <= 0 时表示不限制使用数量
*/
@Update("UPDATE price_coupon_config SET used_quantity = used_quantity + 1, " +
"update_time = NOW() WHERE id = #{couponId} AND used_quantity < total_quantity")
@Update("UPDATE price_coupon_config SET used_quantity = COALESCE(used_quantity, 0) + 1, " +
"update_time = NOW() WHERE id = #{couponId} " +
"AND (total_quantity IS NULL OR total_quantity <= 0 OR COALESCE(used_quantity, 0) < total_quantity)")
int incrementUsedQuantity(Long couponId);
/**
* 原子性增加已领取数量(仅对有限库存的优惠券生效)
* 原子性增加已领取数量(仅对有限库存的优惠券生效,带库存检查
*/
@Update("UPDATE price_coupon_config SET claimed_quantity = COALESCE(claimed_quantity, 0) + 1, " +
"update_time = NOW() WHERE id = #{couponId} AND total_quantity IS NOT NULL AND total_quantity > 0 " +
"AND COALESCE(claimed_quantity, 0) < total_quantity")
int incrementClaimedQuantityIfAvailable(@Param("couponId") Long couponId);
/**
* 无条件增加已领取数量(用于无限量优惠券的领取统计)
*/
@Update("UPDATE price_coupon_config SET claimed_quantity = COALESCE(claimed_quantity, 0) + 1, " +
"update_time = NOW() WHERE id = #{couponId}")
int incrementClaimedQuantity(@Param("couponId") Long couponId);
/**
* 插入优惠券配置
*/
@Insert("INSERT INTO price_coupon_config (coupon_name, coupon_type, discount_value, min_amount, " +
"max_discount, applicable_products, required_attribute_keys, total_quantity, used_quantity, valid_from, valid_until, " +
"max_discount, applicable_products, required_attribute_keys, total_quantity, used_quantity, " +
"claimed_quantity, user_claim_limit, valid_from, valid_until, " +
"is_active, scenic_id, create_time, update_time) VALUES " +
"(#{couponName}, #{couponType}, #{discountValue}, #{minAmount}, #{maxDiscount}, " +
"#{applicableProducts}, #{requiredAttributeKeys}, #{totalQuantity}, #{usedQuantity}, #{validFrom}, #{validUntil}, " +
"#{applicableProducts}, #{requiredAttributeKeys}, #{totalQuantity}, #{usedQuantity}, " +
"#{claimedQuantity}, #{userClaimLimit}, #{validFrom}, #{validUntil}, " +
"#{isActive}, #{scenicId}, NOW(), NOW())")
int insertCoupon(PriceCouponConfig coupon);
@@ -63,7 +74,8 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
*/
@Update("UPDATE price_coupon_config SET coupon_name = #{couponName}, coupon_type = #{couponType}, " +
"discount_value = #{discountValue}, min_amount = #{minAmount}, max_discount = #{maxDiscount}, " +
"applicable_products = #{applicableProducts}, required_attribute_keys = #{requiredAttributeKeys}, total_quantity = #{totalQuantity}, " +
"applicable_products = #{applicableProducts}, required_attribute_keys = #{requiredAttributeKeys}, " +
"total_quantity = #{totalQuantity}, user_claim_limit = #{userClaimLimit}, " +
"valid_from = #{validFrom}, valid_until = #{validUntil}, is_active = #{isActive}, " +
"scenic_id = #{scenicId}, update_time = NOW() WHERE id = #{id}")
int updateCoupon(PriceCouponConfig coupon);
@@ -117,11 +129,11 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
int deleteCoupon(Long id);
/**
* 查询指定景区的有效优惠券配置
* 查询指定景区的有效优惠券配置(可领取的)
*/
@Select("SELECT * FROM price_coupon_config WHERE is_active = 1 " +
"AND valid_from <= NOW() AND valid_until > NOW() " +
"AND used_quantity < total_quantity " +
"AND (total_quantity IS NULL OR total_quantity <= 0 OR COALESCE(claimed_quantity, 0) < total_quantity) " +
"AND (scenic_id IS NULL OR scenic_id = #{scenicId})")
List<PriceCouponConfig> selectValidCouponsByScenicId(@Param("scenicId") String scenicId);

View File

@@ -0,0 +1,99 @@
package com.ycwl.basic.pricing.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp;
import com.ycwl.basic.pricing.entity.PriceSceneCouponConfig;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 场景优惠券配置Mapper
*/
@Mapper
public interface PriceSceneCouponConfigMapper extends BaseMapper<PriceSceneCouponConfig> {
/**
* 查询场景下已启用的优惠券配置(带优惠券详情)
* 用于前端领取接口
*
* @param sceneKey 场景标识
* @param scenicId 景区ID
* @return 配置列表(带优惠券信息)
*/
@Select("SELECT scc.id, scc.scene_key, scc.coupon_id, scc.scenic_id, scc.enabled, " +
"scc.sort_order, scc.create_time, scc.update_time, " +
"c.coupon_name, c.coupon_type, c.discount_value, " +
"c.is_active AS coupon_active, c.valid_from AS coupon_valid_from, c.valid_until AS coupon_valid_until " +
"FROM price_scene_coupon_config scc " +
"JOIN price_coupon_config c ON scc.coupon_id = c.id AND c.deleted = 0 " +
"WHERE scc.scene_key = #{sceneKey} AND scc.scenic_id = #{scenicId} " +
"AND scc.enabled = 1 AND c.is_active = 1 " +
"AND (c.valid_from IS NULL OR c.valid_from <= NOW()) " +
"AND (c.valid_until IS NULL OR c.valid_until > NOW()) " +
"ORDER BY scc.sort_order ASC, scc.id ASC")
List<SceneCouponConfigResp> selectEnabledBySceneKeyAndScenicId(
@Param("sceneKey") String sceneKey,
@Param("scenicId") Long scenicId);
/**
* 检查场景下是否存在已启用的配置(用于判断是否需要回退到默认)
*
* @param sceneKey 场景标识
* @param scenicId 景区ID
* @return 配置数量
*/
@Select("SELECT COUNT(*) FROM price_scene_coupon_config " +
"WHERE scene_key = #{sceneKey} AND scenic_id = #{scenicId} AND enabled = 1")
int countEnabledBySceneKeyAndScenicId(
@Param("sceneKey") String sceneKey,
@Param("scenicId") Long scenicId);
/**
* 查询所有已配置的场景列表(去重)
*
* @return 场景标识列表
*/
@Select("SELECT DISTINCT scene_key FROM price_scene_coupon_config ORDER BY scene_key")
List<String> selectDistinctSceneKeys();
/**
* 管理端:带优惠券信息的分页查询
*
* @param sceneKey 场景标识(模糊匹配,可为null)
* @param scenicId 景区ID(精确匹配,可为null)
* @param couponId 优惠券ID(精确匹配,可为null)
* @param enabled 启用状态(精确匹配,可为null)
* @return 配置列表(带优惠券信息)
*/
@Select("<script>" +
"SELECT scc.id, scc.scene_key, scc.coupon_id, scc.scenic_id, scc.enabled, " +
"scc.sort_order, scc.create_time, scc.update_time, " +
"c.coupon_name, c.coupon_type, c.discount_value, " +
"c.is_active AS coupon_active, c.valid_from AS coupon_valid_from, c.valid_until AS coupon_valid_until " +
"FROM price_scene_coupon_config scc " +
"LEFT JOIN price_coupon_config c ON scc.coupon_id = c.id AND c.deleted = 0 " +
"<where>" +
"<if test='sceneKey != null and sceneKey != \"\"'>" +
"AND scc.scene_key LIKE CONCAT('%', #{sceneKey}, '%') " +
"</if>" +
"<if test='scenicId != null'>" +
"AND scc.scenic_id = #{scenicId} " +
"</if>" +
"<if test='couponId != null'>" +
"AND scc.coupon_id = #{couponId} " +
"</if>" +
"<if test='enabled != null'>" +
"AND scc.enabled = #{enabled} " +
"</if>" +
"</where>" +
"ORDER BY scc.scenic_id DESC, scc.scene_key ASC, scc.sort_order ASC, scc.id DESC" +
"</script>")
List<SceneCouponConfigResp> selectPageWithCouponInfo(
@Param("sceneKey") String sceneKey,
@Param("scenicId") Long scenicId,
@Param("couponId") Long couponId,
@Param("enabled") Integer enabled);
}

View File

@@ -0,0 +1,88 @@
package com.ycwl.basic.pricing.service;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.pricing.dto.CouponClaimResult;
import com.ycwl.basic.pricing.dto.req.SceneCouponClaimReq;
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigPageReq;
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigSaveReq;
import com.ycwl.basic.pricing.dto.resp.SceneCouponAvailableResp;
import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp;
import java.util.List;
/**
* 场景优惠券服务接口
*/
public interface ISceneCouponService {
// ==================== 后台管理接口 ====================
/**
* 分页查询场景优惠券配置
*
* @param req 查询请求
* @return 分页结果
*/
PageInfo<SceneCouponConfigResp> pageConfig(SceneCouponConfigPageReq req);
/**
* 获取配置详情
*
* @param id 配置ID
* @return 配置详情
*/
SceneCouponConfigResp getConfigDetail(Long id);
/**
* 保存配置(新增/更新)
*
* @param req 保存请求
* @return 是否成功
*/
boolean saveConfig(SceneCouponConfigSaveReq req);
/**
* 删除配置
*
* @param id 配置ID
* @return 是否成功
*/
boolean deleteConfig(Long id);
/**
* 获取所有已配置的场景列表
*
* @return 场景标识列表
*/
List<String> listSceneKeys();
/**
* 根据场景和景区查询配置列表
*
* @param sceneKey 场景标识
* @param scenicId 景区ID
* @return 配置列表
*/
List<SceneCouponConfigResp> listBySceneKeyAndScenicId(String sceneKey, Long scenicId);
// ==================== 前端领取接口 ====================
/**
* 查询场景下可领取的优惠券列表
*
* @param sceneKey 场景标识
* @param scenicId 景区ID
* @param userId 用户ID(用于判断用户已领取数量)
* @return 可领取优惠券列表
*/
List<SceneCouponAvailableResp> getAvailableCoupons(String sceneKey, Long scenicId, Long userId);
/**
* 领取场景优惠券
*
* @param req 领取请求
* @param userId 用户ID
* @return 领取结果列表
*/
List<CouponClaimResult> claimCoupons(SceneCouponClaimReq req, Long userId);
}

View File

@@ -23,7 +23,6 @@ import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@@ -125,6 +124,10 @@ public class CouponServiceImpl implements ICouponService {
List<String> applicableProductTypes = objectMapper.readValue(
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
// 空数组表示不限制商品类型,适用于所有商品
if (applicableProductTypes == null || applicableProductTypes.isEmpty()) {
// 不过滤,使用全部商品
} else {
discountableProducts = products.stream()
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
.toList();
@@ -132,6 +135,7 @@ public class CouponServiceImpl implements ICouponService {
if (discountableProducts.isEmpty()) {
return false;
}
}
} catch (Exception e) {
log.error("解析适用商品类型失败", e);
return false;
@@ -198,6 +202,13 @@ public class CouponServiceImpl implements ICouponService {
List<String> applicableProductTypes = objectMapper.readValue(
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
// 空数组表示不限制商品类型,返回所有商品总价
if (applicableProductTypes == null || applicableProductTypes.isEmpty()) {
return products.stream()
.map(ProductItem::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// 计算适用商品的总价
return products.stream()
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
@@ -304,11 +315,11 @@ public class CouponServiceImpl implements ICouponService {
}
// 4. 检查优惠券有效期
LocalDateTime now = LocalDateTime.now();
if (coupon.getValidFrom() != null && now.isBefore(coupon.getValidFrom())) {
Date now = new Date();
if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
return CouponClaimResult.failure(CouponClaimResult.ERROR_COUPON_EXPIRED, "优惠券尚未生效");
}
if (coupon.getValidUntil() != null && now.isAfter(coupon.getValidUntil())) {
if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
return CouponClaimResult.failure(CouponClaimResult.ERROR_COUPON_EXPIRED, "优惠券已过期");
}
@@ -354,17 +365,20 @@ public class CouponServiceImpl implements ICouponService {
}
// 9. 更新优惠券已领取数量(区分于已使用数量)
// 仅在有总量限制时才更新claimedQuantity(totalQuantity为正整数)
if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) {
// 有总量限制:使用带库存检查的原子更新
int affected = couponConfigMapper.incrementClaimedQuantityIfAvailable(coupon.getId());
if (affected == 0) {
throw new CouponInvalidException(
CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK,
"优惠券已被领取完,请稍后重试");
}
} else {
// 无总量限制:无条件增加已领取数量(用于统计)
couponConfigMapper.incrementClaimedQuantity(coupon.getId());
}
int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1;
coupon.setClaimedQuantity(updatedClaimedQuantity);
}
log.info("优惠券领取成功: userId={}, couponId={}, claimRecordId={}",
request.getUserId(), request.getCouponId(), claimRecord.getId());

View File

@@ -0,0 +1,359 @@
package com.ycwl.basic.pricing.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.model.common.BaseQueryParameterReq;
import com.ycwl.basic.pricing.dto.CouponClaimRequest;
import com.ycwl.basic.pricing.dto.CouponClaimResult;
import com.ycwl.basic.pricing.dto.req.SceneCouponClaimReq;
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigPageReq;
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigSaveReq;
import com.ycwl.basic.pricing.dto.resp.SceneCouponAvailableResp;
import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp;
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
import com.ycwl.basic.pricing.entity.PriceSceneCouponConfig;
import com.ycwl.basic.pricing.mapper.PriceCouponClaimRecordMapper;
import com.ycwl.basic.pricing.mapper.PriceCouponConfigMapper;
import com.ycwl.basic.pricing.mapper.PriceSceneCouponConfigMapper;
import com.ycwl.basic.pricing.service.ICouponService;
import com.ycwl.basic.pricing.service.ISceneCouponService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
/**
* 场景优惠券服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SceneCouponServiceImpl implements ISceneCouponService {
private static final int MAX_PAGE_SIZE = 200;
private static final Long DEFAULT_SCENIC_ID = 0L;
private final PriceSceneCouponConfigMapper sceneCouponConfigMapper;
private final PriceCouponConfigMapper couponConfigMapper;
private final PriceCouponClaimRecordMapper claimRecordMapper;
private final ICouponService couponService;
// ==================== 后台管理接口实现 ====================
@Override
public PageInfo<SceneCouponConfigResp> pageConfig(SceneCouponConfigPageReq req) {
if (req == null) {
req = new SceneCouponConfigPageReq();
}
sanitizePage(req);
PageHelper.startPage(req.getPageNum(), req.getPageSize());
List<SceneCouponConfigResp> list = sceneCouponConfigMapper.selectPageWithCouponInfo(
req.getSceneKey(), req.getScenicId(), req.getCouponId(), req.getEnabled());
return new PageInfo<>(list);
}
@Override
public SceneCouponConfigResp getConfigDetail(Long id) {
if (id == null) {
return null;
}
PriceSceneCouponConfig config = sceneCouponConfigMapper.selectById(id);
if (config == null) {
return null;
}
SceneCouponConfigResp resp = new SceneCouponConfigResp();
resp.setId(config.getId());
resp.setSceneKey(config.getSceneKey());
resp.setCouponId(config.getCouponId());
resp.setScenicId(config.getScenicId());
resp.setEnabled(config.getEnabled());
resp.setSortOrder(config.getSortOrder());
resp.setCreateTime(config.getCreateTime());
resp.setUpdateTime(config.getUpdateTime());
// 填充优惠券信息
PriceCouponConfig coupon = couponConfigMapper.selectById(config.getCouponId());
if (coupon != null) {
resp.setCouponName(coupon.getCouponName());
resp.setCouponType(coupon.getCouponType() != null ? coupon.getCouponType().name() : null);
resp.setDiscountValue(coupon.getDiscountValue());
resp.setCouponActive(coupon.getIsActive());
resp.setCouponValidFrom(coupon.getValidFrom());
resp.setCouponValidUntil(coupon.getValidUntil());
}
return resp;
}
@Override
public boolean saveConfig(SceneCouponConfigSaveReq req) {
String err = validateSaveReq(req);
if (err != null) {
throw new IllegalArgumentException(err);
}
PriceSceneCouponConfig entity = new PriceSceneCouponConfig();
entity.setSceneKey(req.getSceneKey().trim());
entity.setCouponId(req.getCouponId());
entity.setScenicId(req.getScenicId());
entity.setEnabled(req.getEnabled());
entity.setSortOrder(Objects.requireNonNullElse(req.getSortOrder(), 0));
entity.setUpdateTime(new Date());
try {
if (req.getId() != null) {
// 更新
PriceSceneCouponConfig existing = sceneCouponConfigMapper.selectById(req.getId());
if (existing == null) {
throw new IllegalArgumentException("记录不存在");
}
entity.setId(req.getId());
return sceneCouponConfigMapper.updateById(entity) > 0;
}
// 新增前检查唯一性
PriceSceneCouponConfig existing = sceneCouponConfigMapper.selectOne(
new QueryWrapper<PriceSceneCouponConfig>()
.eq("scene_key", entity.getSceneKey())
.eq("coupon_id", entity.getCouponId())
.eq("scenic_id", entity.getScenicId()));
if (existing != null) {
// 已存在则更新
entity.setId(existing.getId());
return sceneCouponConfigMapper.updateById(entity) > 0;
}
entity.setCreateTime(new Date());
return sceneCouponConfigMapper.insert(entity) > 0;
} catch (DuplicateKeyException e) {
throw new IllegalArgumentException("保存失败:配置已存在(sceneKey+couponId+scenicId重复)");
}
}
@Override
public boolean deleteConfig(Long id) {
if (id == null) {
return false;
}
return sceneCouponConfigMapper.deleteById(id) > 0;
}
@Override
public List<String> listSceneKeys() {
return sceneCouponConfigMapper.selectDistinctSceneKeys();
}
@Override
public List<SceneCouponConfigResp> listBySceneKeyAndScenicId(String sceneKey, Long scenicId) {
return sceneCouponConfigMapper.selectPageWithCouponInfo(sceneKey, scenicId, null, null);
}
// ==================== 前端领取接口实现 ====================
@Override
public List<SceneCouponAvailableResp> getAvailableCoupons(String sceneKey, Long scenicId, Long userId) {
if (sceneKey == null || sceneKey.isBlank() || scenicId == null) {
return new ArrayList<>();
}
// 景区隔离查询:先查具体景区,为空则回退到默认配置
List<SceneCouponConfigResp> configs = getConfigsWithFallback(sceneKey, scenicId);
if (configs.isEmpty()) {
return new ArrayList<>();
}
List<SceneCouponAvailableResp> result = new ArrayList<>();
for (SceneCouponConfigResp config : configs) {
SceneCouponAvailableResp resp = buildAvailableResp(config, userId);
result.add(resp);
}
return result;
}
@Override
@Transactional
public List<CouponClaimResult> claimCoupons(SceneCouponClaimReq req, Long userId) {
if (req.getSceneKey() == null || req.getSceneKey().isBlank() || req.getScenicId() == null) {
return List.of(CouponClaimResult.failure(CouponClaimResult.ERROR_INVALID_PARAMS, "sceneKey和scenicId不能为空"));
}
// 获取场景下的优惠券配置
List<SceneCouponConfigResp> configs = getConfigsWithFallback(req.getSceneKey(), req.getScenicId());
if (configs.isEmpty()) {
return List.of(CouponClaimResult.failure("SCENE_NOT_FOUND", "该场景暂无可领取的优惠券"));
}
// 如果指定了couponId,只领取指定的
if (req.getCouponId() != null) {
configs = configs.stream()
.filter(c -> req.getCouponId().equals(c.getCouponId()))
.toList();
if (configs.isEmpty()) {
return List.of(CouponClaimResult.failure("COUPON_NOT_IN_SCENE", "指定的优惠券不在该场景中"));
}
}
// 调用现有的领取服务
List<CouponClaimResult> results = new ArrayList<>();
for (SceneCouponConfigResp config : configs) {
CouponClaimRequest claimReq = new CouponClaimRequest();
claimReq.setUserId(userId);
claimReq.setCouponId(config.getCouponId());
claimReq.setScenicId(String.valueOf(req.getScenicId()));
claimReq.setClaimSource("scene:" + req.getSceneKey());
CouponClaimResult claimResult = couponService.claimCoupon(claimReq);
results.add(claimResult);
}
return results;
}
// ==================== 私有方法 ====================
/**
* 带景区回退的配置查询
* 如果景区有配置(哪怕只有一条),则使用景区的配置;
* 如果景区没有配置,则使用默认景区(scenicId=0)的配置。
*/
private List<SceneCouponConfigResp> getConfigsWithFallback(String sceneKey, Long scenicId) {
// 1. 先查具体景区是否有配置
int count = sceneCouponConfigMapper.countEnabledBySceneKeyAndScenicId(sceneKey, scenicId);
if (count > 0) {
// 景区有配置,使用景区的配置
return sceneCouponConfigMapper.selectEnabledBySceneKeyAndScenicId(sceneKey, scenicId);
}
// 2. 景区没有配置,回退到默认配置 (scenicId=0)
return sceneCouponConfigMapper.selectEnabledBySceneKeyAndScenicId(sceneKey, DEFAULT_SCENIC_ID);
}
/**
* 构建可领取优惠券响应
*/
private SceneCouponAvailableResp buildAvailableResp(SceneCouponConfigResp config, Long userId) {
SceneCouponAvailableResp resp = new SceneCouponAvailableResp();
// 查询优惠券详情
PriceCouponConfig coupon = couponConfigMapper.selectById(config.getCouponId());
if (coupon == null) {
resp.setCouponId(config.getCouponId());
resp.setCanClaim(false);
resp.setCannotClaimReason("优惠券不存在");
return resp;
}
resp.setCouponId(coupon.getId());
resp.setCouponName(coupon.getCouponName());
resp.setCouponType(coupon.getCouponType() != null ? coupon.getCouponType().name() : null);
resp.setDiscountValue(coupon.getDiscountValue());
resp.setMinAmount(coupon.getMinAmount());
resp.setMaxDiscount(coupon.getMaxDiscount());
resp.setValidFrom(coupon.getValidFrom());
resp.setValidUntil(coupon.getValidUntil());
resp.setUserClaimLimit(coupon.getUserClaimLimit());
// 查询用户已领取数量
int userClaimedCount = 0;
if (userId != null) {
userClaimedCount = claimRecordMapper.countUserCouponClaims(userId, coupon.getId());
}
resp.setUserClaimedCount(userClaimedCount);
// 计算剩余可领取数量
int userRemaining;
if (coupon.getUserClaimLimit() == null || coupon.getUserClaimLimit() <= 0) {
userRemaining = -1; // -1表示无限制
} else {
userRemaining = Math.max(0, coupon.getUserClaimLimit() - userClaimedCount);
}
resp.setUserRemaining(userRemaining);
// 综合判断是否可领取
String cannotClaimReason = checkCanClaim(coupon, userClaimedCount);
resp.setCanClaim(cannotClaimReason == null);
resp.setCannotClaimReason(cannotClaimReason);
return resp;
}
/**
* 检查是否可领取
*
* @return null表示可领取,否则返回不可领取原因
*/
private String checkCanClaim(PriceCouponConfig coupon, int userClaimedCount) {
// 1. 检查启用状态
if (!Boolean.TRUE.equals(coupon.getIsActive())) {
return "优惠券已停用";
}
// 2. 检查有效期
Date now = new Date();
if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
return "优惠券尚未生效";
}
if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
return "优惠券已过期";
}
// 3. 检查库存
if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) {
int claimed = coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity();
if (claimed >= coupon.getTotalQuantity()) {
return "优惠券已领完";
}
}
// 4. 检查用户领取限制
if (coupon.getUserClaimLimit() != null && coupon.getUserClaimLimit() > 0) {
if (userClaimedCount >= coupon.getUserClaimLimit()) {
return "您已达到领取上限";
}
}
return null; // 可领取
}
private static void sanitizePage(BaseQueryParameterReq req) {
if (req.getPageNum() == null || req.getPageNum() < 1) {
req.setPageNum(1);
}
if (req.getPageSize() == null || req.getPageSize() < 1) {
req.setPageSize(10);
}
if (req.getPageSize() > MAX_PAGE_SIZE) {
req.setPageSize(MAX_PAGE_SIZE);
}
}
private static String validateSaveReq(SceneCouponConfigSaveReq req) {
if (req == null) {
return "请求体不能为空";
}
if (req.getSceneKey() == null || req.getSceneKey().isBlank()) {
return "sceneKey不能为空";
}
if (req.getCouponId() == null) {
return "couponId不能为空";
}
if (req.getScenicId() == null) {
return "scenicId不能为空";
}
if (req.getEnabled() == null || (req.getEnabled() != 0 && req.getEnabled() != 1)) {
return "enabled必须为0或1";
}
return null;
}
}

View File

@@ -88,11 +88,6 @@ public class PuzzleTemplateDTO {
*/
private Integer canPrint;
/**
* 用户查看区域(裁切区域),格式:x,y,w,h
*/
private String userArea;
/**
* 元素列表
*/

View File

@@ -71,11 +71,6 @@ public class TemplateCreateRequest {
*/
private Integer canPrint;
/**
* 用户查看区域(裁切区域),格式:x,y,w,h
*/
private String userArea;
/**
* 状态:0-禁用 1-启用
*/

View File

@@ -4,7 +4,6 @@ import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskFailRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskSuccessRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeUploadUrlsResponse;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerAuthRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncResponse;
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
@@ -36,9 +35,8 @@ public class PuzzleEdgeRenderTaskController {
}
@PostMapping("/task/{taskId}/uploadUrls")
public ApiResponse<PuzzleEdgeUploadUrlsResponse> uploadUrls(@PathVariable Long taskId,
@RequestBody PuzzleEdgeWorkerAuthRequest req) {
return ApiResponse.success(puzzleEdgeRenderTaskService.getUploadUrls(taskId, req != null ? req.getAccessKey() : null));
public ApiResponse<PuzzleEdgeUploadUrlsResponse> uploadUrls(@PathVariable Long taskId) {
return ApiResponse.success(puzzleEdgeRenderTaskService.getUploadUrls(taskId));
}
@PostMapping("/task/{taskId}/success")

View File

@@ -1,9 +0,0 @@
package com.ycwl.basic.puzzle.edge.dto;
import lombok.Data;
@Data
public class PuzzleEdgeWorkerAuthRequest {
private String accessKey;
}

View File

@@ -34,6 +34,18 @@ public class PuzzleEdgeRenderTaskEntity {
@TableField("face_id")
private Long faceId;
/**
* 任务类型:PUZZLE-原始拼图 WATERMARK-水印拼图
*/
@TableField("task_type")
private String taskType;
/**
* 水印类型(仅task_type=WATERMARK时有效)
*/
@TableField("watermark_type")
private String watermarkType;
@TableField("content_hash")
private String contentHash;

View File

@@ -35,6 +35,9 @@ public class PuzzleEdgeWorkerIpInterceptor implements HandlerInterceptor {
if (Ipv4CidrMatcher.matches(clientIp, properties.getAllowedIpCidr())) {
return true;
}
if (Ipv4CidrMatcher.matches(clientIp, "127.0.0.1/8")) {
return true;
}
log.warn("拒绝边缘 Worker 请求: uri={}, ip={}, allowedIpCidr={}",
request != null ? request.getRequestURI() : null,

View File

@@ -1,34 +0,0 @@
package com.ycwl.basic.puzzle.edge.mapper;
import com.ycwl.basic.puzzle.edge.entity.PuzzleEdgeRenderTaskEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
@Mapper
public interface PuzzleEdgeRenderTaskMapper {
PuzzleEdgeRenderTaskEntity getById(@Param("id") Long id);
int insert(PuzzleEdgeRenderTaskEntity entity);
/**
* 获取下一条可领取任务ID:PENDING 或 RUNNING但租约已过期
*/
Long findNextClaimableTaskId();
/**
* 领取任务(并写入租约与attempt)
*/
int claimTask(@Param("taskId") Long taskId,
@Param("workerId") Long workerId,
@Param("leaseExpireTime") Date leaseExpireTime);
int markSuccess(@Param("taskId") Long taskId, @Param("workerId") Long workerId);
int markFail(@Param("taskId") Long taskId,
@Param("workerId") Long workerId,
@Param("errorMessage") String errorMessage);
}

View File

@@ -4,7 +4,7 @@ import cn.hutool.core.util.StrUtil;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.fasterxml.jackson.core.type.TypeReference;
import com.ycwl.basic.model.pc.renderWorker.entity.RenderWorkerEntity;
import com.ycwl.basic.model.pc.puzzle.entity.PuzzleWatermarkEntity;
import com.ycwl.basic.model.task.req.ClientStatusReqVo;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeRenderTaskDTO;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskFailRequest;
@@ -17,8 +17,9 @@ import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleWatermarkMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.repository.RenderWorkerRepository;
import com.ycwl.basic.service.pc.processor.PuzzleRelationProcessor;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
@@ -56,6 +57,15 @@ public class PuzzleEdgeRenderTaskService {
private static final int STATUS_SUCCESS = 2;
private static final int STATUS_FAIL = 3;
/**
* 任务类型:原始拼图(成功后更新 puzzle_generation_record)
*/
public static final String TASK_TYPE_PUZZLE = "PUZZLE";
/**
* 任务类型:水印拼图(成功后写入 puzzle_watermark)
*/
public static final String TASK_TYPE_WATERMARK = "WATERMARK";
private static final int MAX_SYNC_TASKS = 5;
private static final long LEASE_MILLIS = TimeUnit.SECONDS.toMillis(20);
private static final long UPLOAD_URL_EXPIRE_MILLIS = TimeUnit.HOURS.toMillis(1);
@@ -134,16 +144,25 @@ public class PuzzleEdgeRenderTaskService {
private final ConcurrentHashMap<Long, WaitFutureEntry> waitFutures = new ConcurrentHashMap<>();
private final PuzzleGenerationRecordMapper recordMapper;
private final PuzzleWatermarkMapper puzzleWatermarkMapper;
private final PuzzleRepository puzzleRepository;
private final PrinterService printerService;
private final RenderWorkerRepository renderWorkerRepository;
private final PuzzleRelationProcessor puzzleRelationProcessor;
/**
* 固定的 workerId,用于标识通过 IP CIDR 验证的 worker
* 由于 IP 验证已在拦截器层完成,此处不再区分具体 worker
*/
private static final Long DEFAULT_WORKER_ID = 1L;
public PuzzleEdgeWorkerSyncResponse sync(PuzzleEdgeWorkerSyncRequest req) {
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
// IP 验证已在拦截器层完成,此处无需验证 accessKey
Long workerId = DEFAULT_WORKER_ID;
// 客户端状态上报(可选,不再关联具体 worker)
ClientStatusReqVo clientStatus = req != null ? req.getClientStatus() : null;
if (clientStatus != null) {
renderWorkerRepository.setWorkerHostStatus(worker.getId(), clientStatus);
log.debug("收到客户端状态上报: {}", clientStatus);
}
int maxTasks = req != null && req.getMaxTasks() != null ? req.getMaxTasks() : 1;
@@ -156,12 +175,12 @@ public class PuzzleEdgeRenderTaskService {
PuzzleEdgeWorkerSyncResponse resp = new PuzzleEdgeWorkerSyncResponse();
for (int i = 0; i < maxTasks; i++) {
PuzzleEdgeRenderTaskEntity task = claimOne(worker.getId());
PuzzleEdgeRenderTaskEntity task = claimOne(workerId);
if (task == null) {
break;
}
PuzzleEdgeRenderTaskDTO dto = toTaskDTOOrFail(task, worker.getId());
PuzzleEdgeRenderTaskDTO dto = toTaskDTOOrFail(task, workerId);
if (dto != null) {
resp.getTasks().add(dto);
}
@@ -170,15 +189,17 @@ public class PuzzleEdgeRenderTaskService {
return resp;
}
public PuzzleEdgeUploadUrlsResponse getUploadUrls(Long taskId, String accessKey) {
RenderWorkerEntity worker = requireWorker(accessKey);
PuzzleEdgeRenderTaskEntity task = getAndCheckRunningTask(taskId, worker.getId());
public PuzzleEdgeUploadUrlsResponse getUploadUrls(Long taskId) {
// IP 验证已在拦截器层完成,此处无需验证 accessKey
Long workerId = DEFAULT_WORKER_ID;
PuzzleEdgeRenderTaskEntity task = getAndCheckRunningTask(taskId, workerId);
return buildUploadUrls(task);
}
public void taskSuccess(Long taskId, PuzzleEdgeTaskSuccessRequest req) {
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, worker.getId());
// IP 验证已在拦截器层完成,此处无需验证 accessKey
Long workerId = DEFAULT_WORKER_ID;
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, workerId);
if (task.getStatus() != null && task.getStatus() == STATUS_SUCCESS) {
return;
}
@@ -186,22 +207,39 @@ public class PuzzleEdgeRenderTaskService {
throw new IllegalArgumentException("任务状态非法");
}
boolean updated = tryMarkSuccess(task, worker.getId());
boolean updated = tryMarkSuccess(task, workerId);
if (!updated) {
throw new IllegalStateException("任务状态更新失败");
}
PuzzleGenerationRecordEntity record = recordMapper.getById(task.getRecordId());
if (record == null) {
log.warn("边缘渲染任务回报成功,但生成记录不存在: taskId={}, recordId={}", taskId, task.getRecordId());
return;
IStorageAdapter storage = StorageFactory.use();
String resultImageUrl = storage.getUrl(task.getOriginalObjectKey());
// 根据任务类型决定写入哪个表
String taskType = task.getTaskType();
if (TASK_TYPE_WATERMARK.equals(taskType)) {
// 水印拼图任务:写入 puzzle_watermark 表
handleWatermarkTaskSuccess(task, resultImageUrl);
} else {
// 原始拼图任务(默认):更新 puzzle_generation_record 表
handlePuzzleTaskSuccess(task, req, resultImageUrl);
}
IStorageAdapter storage = StorageFactory.use();
String originalImageUrl = storage.getUrl(task.getOriginalObjectKey());
String resultImageUrl = StrUtil.isNotBlank(task.getCroppedObjectKey())
? storage.getUrl(task.getCroppedObjectKey())
: originalImageUrl;
// 通知等待方任务完成
completeWaitFuture(taskId, TaskWaitResult.success(resultImageUrl));
}
/**
* 处理原始拼图任务成功
*/
private void handlePuzzleTaskSuccess(PuzzleEdgeRenderTaskEntity task,
PuzzleEdgeTaskSuccessRequest req,
String resultImageUrl) {
PuzzleGenerationRecordEntity record = recordMapper.getById(task.getRecordId());
if (record == null) {
log.warn("边缘渲染任务回报成功,但生成记录不存在: taskId={}, recordId={}", task.getId(), task.getRecordId());
return;
}
Long resultFileSize = req != null ? req.getResultFileSize() : null;
Integer resultWidth = req != null ? req.getResultWidth() : null;
@@ -211,15 +249,22 @@ public class PuzzleEdgeRenderTaskService {
recordMapper.updateSuccess(
record.getId(),
resultImageUrl,
originalImageUrl,
resultFileSize,
resultWidth,
resultHeight,
renderDurationMs
);
// 通知等待方任务完成
completeWaitFuture(taskId, TaskWaitResult.success(resultImageUrl));
// 清除生成记录缓存(状态已更新)
puzzleRepository.clearRecordCache(record.getId(), record.getFaceId());
// 创建member_puzzle关联记录(使用INSERT IGNORE避免重复)
puzzleRelationProcessor.createPuzzleRelation(
record.getUserId(),
record.getScenicId(),
record.getFaceId(),
record.getId()
);
PuzzleTemplateEntity template = puzzleRepository.getTemplateById(task.getTemplateId());
if (template != null && template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
@@ -228,8 +273,8 @@ public class PuzzleEdgeRenderTaskService {
record.getUserId(),
record.getScenicId(),
record.getFaceId(),
originalImageUrl,
record.getId()
resultImageUrl,
record.getId() // 拼图记录ID,用于关联 puzzle_generation_record 表
);
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
} catch (Exception e) {
@@ -238,9 +283,30 @@ public class PuzzleEdgeRenderTaskService {
}
}
/**
* 处理水印拼图任务成功
*/
private void handleWatermarkTaskSuccess(PuzzleEdgeRenderTaskEntity task, String resultImageUrl) {
PuzzleWatermarkEntity watermark = new PuzzleWatermarkEntity();
watermark.setRecordId(task.getRecordId());
watermark.setFaceId(task.getFaceId());
watermark.setWatermarkType(task.getWatermarkType());
watermark.setWatermarkUrl(resultImageUrl);
try {
puzzleWatermarkMapper.insert(watermark);
log.info("水印拼图任务成功,已写入puzzle_watermark: taskId={}, recordId={}, watermarkType={}",
task.getId(), task.getRecordId(), task.getWatermarkType());
} catch (Exception e) {
log.error("水印拼图任务成功,但写入puzzle_watermark失败: taskId={}, recordId={}",
task.getId(), task.getRecordId(), e);
}
}
public void taskFail(Long taskId, PuzzleEdgeTaskFailRequest req) {
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, worker.getId());
// IP 验证已在拦截器层完成,此处无需验证 accessKey
Long workerId = DEFAULT_WORKER_ID;
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, workerId);
if (task.getStatus() != null && task.getStatus() == STATUS_FAIL) {
return;
}
@@ -252,12 +318,15 @@ public class PuzzleEdgeRenderTaskService {
? req.getErrorMessage()
: "边缘渲染失败";
boolean updated = tryMarkFail(task, worker.getId(), errorMessage);
boolean updated = tryMarkFail(task, workerId, errorMessage);
if (!updated) {
throw new IllegalStateException("任务状态更新失败");
}
recordMapper.updateFail(task.getRecordId(), errorMessage);
// 清除生成记录缓存(状态已更新)
puzzleRepository.clearRecordCache(task.getRecordId(), task.getFaceId());
// 通知等待方任务失败
completeWaitFuture(taskId, TaskWaitResult.fail(errorMessage));
}
@@ -273,6 +342,7 @@ public class PuzzleEdgeRenderTaskService {
List<Long> retryRecordIds = new ArrayList<>();
Map<Long, String> failRecordMessages = new HashMap<>();
Map<Long, String> failTaskMessages = new HashMap<>(); // taskId -> errorMessage
Map<Long, Long> failRecordFaceIds = new HashMap<>(); // recordId -> faceId,用于缓存清除
synchronized (taskLock) {
long now = System.currentTimeMillis();
@@ -303,6 +373,7 @@ public class PuzzleEdgeRenderTaskService {
task.setUpdateTime(new Date(now));
if (task.getRecordId() != null) {
failRecordMessages.put(task.getRecordId(), errorMessage);
failRecordFaceIds.put(task.getRecordId(), task.getFaceId());
}
// 记录需要通知的任务
failTaskMessages.put(task.getId(), errorMessage);
@@ -329,6 +400,9 @@ public class PuzzleEdgeRenderTaskService {
for (Map.Entry<Long, String> entry : failRecordMessages.entrySet()) {
recordMapper.updateFail(entry.getKey(), entry.getValue());
// 清除生成记录缓存
Long faceId = failRecordFaceIds.get(entry.getKey());
puzzleRepository.clearRecordCache(entry.getKey(), faceId);
}
// 通知等待方任务最终失败
@@ -402,9 +476,6 @@ public class PuzzleEdgeRenderTaskService {
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
String originalObjectKey = String.format("puzzle/%s/%s", template.getCode(), fileName);
String croppedObjectKey = StrUtil.isNotBlank(template.getUserArea())
? String.format("puzzle/%s_cropped/%s", template.getCode(), fileName)
: null;
Map<String, Object> payload = new HashMap<>();
payload.put("recordId", record.getId());
@@ -417,7 +488,6 @@ public class PuzzleEdgeRenderTaskService {
templatePayload.put("backgroundType", template.getBackgroundType());
templatePayload.put("backgroundColor", template.getBackgroundColor());
templatePayload.put("backgroundImage", template.getBackgroundImage());
templatePayload.put("userArea", template.getUserArea());
payload.put("template", templatePayload);
List<Map<String, Object>> elementPayloadList = new ArrayList<>();
@@ -451,13 +521,127 @@ public class PuzzleEdgeRenderTaskService {
task.setTemplateCode(template.getCode());
task.setScenicId(record.getScenicId());
task.setFaceId(record.getFaceId());
task.setTaskType(TASK_TYPE_PUZZLE);
task.setWatermarkType(null);
task.setContentHash(record.getContentHash());
task.setStatus(STATUS_PENDING);
task.setAttemptCount(0);
task.setOutputFormat(normalizedFormat);
task.setOutputQuality(outputQuality);
task.setOriginalObjectKey(originalObjectKey);
task.setCroppedObjectKey(croppedObjectKey);
task.setCroppedObjectKey(null);
task.setPayloadJson(JacksonUtil.toJson(payload));
Long taskId = taskIdSequence.incrementAndGet();
Date now = new Date();
task.setId(taskId);
task.setCreateTime(now);
task.setUpdateTime(now);
taskCache.put(taskId, task);
return taskId;
}
/**
* 创建水印拼图边缘渲染任务(供中心业务侧调用)
* 成功后将结果写入 puzzle_watermark 表
*
* @param recordId 原始拼图生成记录ID
* @param faceId 人脸ID(可选)
* @param watermarkType 水印类型(如 print、free_download)
* @param template 模板配置
* @param sortedElements 元素列表(按z-index排序)
* @param finalDynamicData 动态数据
* @param outputFormat 输出格式
* @param quality 输出质量
* @return 任务ID
*/
public Long createWatermarkRenderTask(Long recordId,
Long faceId,
String watermarkType,
PuzzleTemplateEntity template,
List<PuzzleElementEntity> sortedElements,
Map<String, String> finalDynamicData,
String outputFormat,
Integer quality) {
if (recordId == null) {
throw new IllegalArgumentException("recordId不能为空");
}
if (StrUtil.isBlank(watermarkType)) {
throw new IllegalArgumentException("watermarkType不能为空");
}
if (template == null || template.getId() == null) {
throw new IllegalArgumentException("template不能为空");
}
if (sortedElements == null) {
sortedElements = List.of();
}
if (finalDynamicData == null) {
finalDynamicData = Map.of();
}
String normalizedFormat = normalizeOutputFormat(outputFormat);
Integer outputQuality = quality != null ? quality : 90;
String ext = "PNG".equals(normalizedFormat) ? "png" : "jpeg";
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
// 水印拼图使用单独的目录
String originalObjectKey = String.format("puzzle_watermark/%s/%s/%s", template.getCode(), watermarkType, fileName);
Map<String, Object> payload = new HashMap<>();
payload.put("recordId", recordId);
payload.put("watermarkType", watermarkType);
Map<String, Object> templatePayload = new HashMap<>();
templatePayload.put("id", template.getId());
templatePayload.put("code", template.getCode());
templatePayload.put("canvasWidth", template.getCanvasWidth());
templatePayload.put("canvasHeight", template.getCanvasHeight());
templatePayload.put("backgroundType", template.getBackgroundType());
templatePayload.put("backgroundColor", template.getBackgroundColor());
templatePayload.put("backgroundImage", template.getBackgroundImage());
payload.put("template", templatePayload);
List<Map<String, Object>> elementPayloadList = new ArrayList<>();
for (PuzzleElementEntity e : sortedElements) {
Map<String, Object> elementPayload = new HashMap<>();
elementPayload.put("id", e.getId());
elementPayload.put("type", e.getElementType());
elementPayload.put("key", e.getElementKey());
elementPayload.put("name", e.getElementName());
elementPayload.put("x", e.getXPosition());
elementPayload.put("y", e.getYPosition());
elementPayload.put("width", e.getWidth());
elementPayload.put("height", e.getHeight());
elementPayload.put("zIndex", e.getZIndex());
elementPayload.put("rotation", e.getRotation());
elementPayload.put("opacity", e.getOpacity());
elementPayload.put("config", e.getConfig());
elementPayloadList.add(elementPayload);
}
payload.put("elements", elementPayloadList);
payload.put("dynamicData", finalDynamicData);
Map<String, Object> outputPayload = new HashMap<>();
outputPayload.put("format", normalizedFormat);
outputPayload.put("quality", outputQuality);
payload.put("output", outputPayload);
PuzzleEdgeRenderTaskEntity task = new PuzzleEdgeRenderTaskEntity();
task.setRecordId(recordId);
task.setTemplateId(template.getId());
task.setTemplateCode(template.getCode());
task.setScenicId(template.getScenicId());
task.setFaceId(faceId);
task.setTaskType(TASK_TYPE_WATERMARK);
task.setWatermarkType(watermarkType);
task.setContentHash(null); // 水印任务不需要内容哈希去重
task.setStatus(STATUS_PENDING);
task.setAttemptCount(0);
task.setOutputFormat(normalizedFormat);
task.setOutputQuality(outputQuality);
task.setOriginalObjectKey(originalObjectKey);
task.setCroppedObjectKey(null);
task.setPayloadJson(JacksonUtil.toJson(payload));
Long taskId = taskIdSequence.incrementAndGet();
@@ -749,20 +933,6 @@ public class PuzzleEdgeRenderTaskService {
return attemptCount < MAX_RETRY_ATTEMPTS;
}
private RenderWorkerEntity requireWorker(String accessKey) {
if (StrUtil.isBlank(accessKey)) {
throw new IllegalArgumentException("accessKey不能为空");
}
RenderWorkerEntity worker = renderWorkerRepository.getWorkerByAccessKey(accessKey);
if (worker == null) {
throw new IllegalArgumentException("worker不存在");
}
if (worker.getStatus() == null || worker.getStatus() != 1) {
throw new IllegalArgumentException("worker未启用");
}
return worker;
}
private String normalizeOutputFormat(String format) {
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
if ("JPG".equals(outputFormat)) {

View File

@@ -29,9 +29,83 @@ public class ImageConfig implements ElementConfig {
/**
* 圆角半径(像素,0为直角)
* 注意:当 borderRadius >= min(width, height) / 2 时,效果为圆形
*/
private Integer borderRadius = 0;
/**
* 叠加图片配置
* 用于在主图上叠加另一张图片(如二维码中心的头像)
*/
private OverlayImageConfig overlayImage;
/**
* 叠加图片配置类
*/
@Data
public static class OverlayImageConfig {
/**
* 叠加图片的数据源 key(从 dynamicData 获取 URL)
* 例如:faceAvatar
*/
private String imageKey;
/**
* 叠加图片的默认 URL(当 dynamicData 中无对应值时使用)
*/
private String defaultImageUrl;
/**
* 叠加图片宽度占主图宽度的比例(0.0 - 1.0)
* 默认 0.45(与现有水印实现一致)
*/
private Double widthRatio = 0.45;
/**
* 叠加图片高度占主图高度的比例(0.0 - 1.0)
* 默认 0.45
*/
private Double heightRatio = 0.45;
/**
* 叠加图片的适配模式
* 默认 COVER(与现有水印实现一致)
*/
private String imageFitMode = "COVER";
/**
* 叠加图片的圆角半径
* 默认 -1 表示自动计算为圆形(min(width, height) / 2)
*/
private Integer borderRadius = -1;
/**
* 叠加图片的水平对齐方式
* CENTER - 居中(默认)
* LEFT - 左对齐
* RIGHT - 右对齐
*/
private String horizontalAlign = "CENTER";
/**
* 叠加图片的垂直对齐方式
* CENTER - 居中(默认)
* TOP - 顶部对齐
* BOTTOM - 底部对齐
*/
private String verticalAlign = "CENTER";
/**
* 水平偏移量(像素),正值向右,负值向左
*/
private Integer offsetX = 0;
/**
* 垂直偏移量(像素),正值向下,负值向上
*/
private Integer offsetY = 0;
}
@Override
public void validate() {
// 校验圆角半径
@@ -50,21 +124,76 @@ public class ImageConfig implements ElementConfig {
}
}
// 校验图片URL
if (StrUtil.isBlank(defaultImageUrl)) {
throw new IllegalArgumentException("默认图片URL不能为空");
}
// 校验图片URL(注意:现在可以通过 dynamicData 动态填充,所以允许为空)
if (StrUtil.isNotBlank(defaultImageUrl)) {
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
}
}
// 校验叠加图片配置
if (overlayImage != null) {
validateOverlayImage(overlayImage);
}
}
private void validateOverlayImage(OverlayImageConfig overlay) {
// 校验比例范围
if (overlay.getWidthRatio() != null && (overlay.getWidthRatio() <= 0 || overlay.getWidthRatio() > 1)) {
throw new IllegalArgumentException("叠加图片宽度比例必须在 0-1 之间: " + overlay.getWidthRatio());
}
if (overlay.getHeightRatio() != null && (overlay.getHeightRatio() <= 0 || overlay.getHeightRatio() > 1)) {
throw new IllegalArgumentException("叠加图片高度比例必须在 0-1 之间: " + overlay.getHeightRatio());
}
// 校验对齐方式
if (StrUtil.isNotBlank(overlay.getHorizontalAlign())) {
String align = overlay.getHorizontalAlign().toUpperCase();
if (!"CENTER".equals(align) && !"LEFT".equals(align) && !"RIGHT".equals(align)) {
throw new IllegalArgumentException("水平对齐方式只能是CENTER、LEFT或RIGHT: " + overlay.getHorizontalAlign());
}
}
if (StrUtil.isNotBlank(overlay.getVerticalAlign())) {
String align = overlay.getVerticalAlign().toUpperCase();
if (!"CENTER".equals(align) && !"TOP".equals(align) && !"BOTTOM".equals(align)) {
throw new IllegalArgumentException("垂直对齐方式只能是CENTER、TOP或BOTTOM: " + overlay.getVerticalAlign());
}
}
// 校验叠加图片URL
if (StrUtil.isNotBlank(overlay.getDefaultImageUrl())) {
if (!overlay.getDefaultImageUrl().startsWith("http://") && !overlay.getDefaultImageUrl().startsWith("https://")) {
throw new IllegalArgumentException("叠加图片URL必须以http://或https://开头: " + overlay.getDefaultImageUrl());
}
}
// 校验适配模式
if (StrUtil.isNotBlank(overlay.getImageFitMode())) {
String mode = overlay.getImageFitMode().toUpperCase();
if (!"CONTAIN".equals(mode) && !"COVER".equals(mode) && !"FILL".equals(mode) && !"SCALE_DOWN".equals(mode)) {
throw new IllegalArgumentException("叠加图片适配模式只能是CONTAIN、COVER、FILL或SCALE_DOWN: " + overlay.getImageFitMode());
}
}
}
@Override
public String getConfigSchema() {
return "{\n" +
" \"defaultImageUrl\": \"https://example.com/image.jpg\",\n" +
" \"imageFitMode\": \"CONTAIN|COVER|FILL|SCALE_DOWN\",\n" +
" \"borderRadius\": 0\n" +
" \"borderRadius\": 0,\n" +
" \"overlayImage\": {\n" +
" \"imageKey\": \"faceAvatar\",\n" +
" \"defaultImageUrl\": \"https://example.com/default-avatar.png\",\n" +
" \"widthRatio\": 0.45,\n" +
" \"heightRatio\": 0.45,\n" +
" \"imageFitMode\": \"COVER\",\n" +
" \"borderRadius\": -1,\n" +
" \"horizontalAlign\": \"CENTER\",\n" +
" \"verticalAlign\": \"CENTER\",\n" +
" \"offsetX\": 0,\n" +
" \"offsetY\": 0\n" +
" }\n" +
"}";
}
}

View File

@@ -76,12 +76,6 @@ public class PuzzleGenerationRecordEntity {
@TableField("result_image_url")
private String resultImageUrl;
/**
* 原始图片URL(未裁切的图片,用于打印)
*/
@TableField("original_image_url")
private String originalImageUrl;
/**
* 文件大小(字节)
*/

View File

@@ -109,12 +109,6 @@ public class PuzzleTemplateEntity {
@TableField("can_print")
private Integer canPrint;
/**
* 用户查看区域(裁切区域),格式:x,y,w,h
*/
@TableField("user_area")
private String userArea;
/**
* 创建时间
*/

View File

@@ -0,0 +1,49 @@
package com.ycwl.basic.puzzle.mapper;
import com.ycwl.basic.model.pc.puzzle.entity.MemberPuzzleEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 会员拼图关联Mapper接口
*/
@Mapper
public interface MemberPuzzleMapper {
/**
* 添加关联记录
*/
int addRelation(MemberPuzzleEntity entity);
/**
* 批量添加关联记录
*/
int addRelations(@Param("list") List<MemberPuzzleEntity> list);
/**
* 根据人脸ID查询关联列表
*/
List<MemberPuzzleEntity> listByFaceId(@Param("faceId") Long faceId);
/**
* 根据人脸ID统计免费数量
*/
int countFreeByFaceId(@Param("faceId") Long faceId);
/**
* 更新关联记录(购买状态、订单ID等)
*/
int updateRelation(MemberPuzzleEntity entity);
/**
* 批量标记为免费
*/
int freeRelations(@Param("ids") List<Long> ids);
/**
* 根据人脸ID和记录ID查询
*/
MemberPuzzleEntity getByFaceAndRecord(@Param("faceId") Long faceId, @Param("recordId") Long recordId);
}

View File

@@ -52,7 +52,6 @@ public interface PuzzleGenerationRecordMapper {
*/
int updateSuccess(@Param("id") Long id,
@Param("resultImageUrl") String resultImageUrl,
@Param("originalImageUrl") String originalImageUrl,
@Param("resultFileSize") Long resultFileSize,
@Param("resultWidth") Integer resultWidth,
@Param("resultHeight") Integer resultHeight,
@@ -77,4 +76,15 @@ public interface PuzzleGenerationRecordMapper {
PuzzleGenerationRecordEntity findByContentHash(@Param("templateId") Long templateId,
@Param("contentHash") String contentHash,
@Param("scenicId") Long scenicId);
/**
* 根据人脸ID和模板ID查询最近的成功记录
* 用于素材版本缓存命中时快速返回历史结果
*
* @param faceId 人脸ID
* @param templateId 模板ID
* @return 最近的成功记录,如果不存在返回null
*/
PuzzleGenerationRecordEntity findLatestSuccessByFaceAndTemplate(@Param("faceId") Long faceId,
@Param("templateId") Long templateId);
}

View File

@@ -0,0 +1,44 @@
package com.ycwl.basic.puzzle.mapper;
import com.ycwl.basic.model.pc.puzzle.entity.PuzzleWatermarkEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 拼图水印Mapper接口
* 用于存储和查询拼图在不同场景下的水印版本
*/
@Mapper
public interface PuzzleWatermarkMapper {
/**
* 新增拼图水印记录
*/
int insert(PuzzleWatermarkEntity entity);
/**
* 批量查询拼图水印
*
* @param recordIds 拼图生成记录ID列表
* @param faceId 人脸ID(可选)
* @param watermarkType 水印类型
* @return 水印列表
*/
List<PuzzleWatermarkEntity> listByRecordIds(@Param("recordIds") List<Long> recordIds,
@Param("faceId") Long faceId,
@Param("watermarkType") String watermarkType);
/**
* 查询单条拼图水印
*
* @param recordId 拼图生成记录ID
* @param faceId 人脸ID(可选)
* @param watermarkType 水印类型
* @return 水印记录
*/
PuzzleWatermarkEntity getByRecordAndType(@Param("recordId") Long recordId,
@Param("faceId") Long faceId,
@Param("watermarkType") String watermarkType);
}

View File

@@ -2,8 +2,10 @@ package com.ycwl.basic.puzzle.repository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.extern.slf4j.Slf4j;
@@ -26,6 +28,7 @@ public class PuzzleRepository {
private final PuzzleTemplateMapper templateMapper;
private final PuzzleElementMapper elementMapper;
private final PuzzleGenerationRecordMapper recordMapper;
private final RedisTemplate<String, String> redisTemplate;
/**
@@ -43,17 +46,39 @@ public class PuzzleRepository {
*/
private static final String PUZZLE_ELEMENTS_BY_TEMPLATE_KEY = "puzzle:elements:templateId:%s";
/**
* 生成记录缓存KEY(根据faceId)
*/
private static final String PUZZLE_RECORDS_BY_FACE_KEY = "puzzle:records:faceId:%s";
/**
* 景区模板列表缓存KEY(根据scenicId)
*/
private static final String PUZZLE_TEMPLATES_BY_SCENIC_KEY = "puzzle:templates:scenicId:%s";
/**
* 单条生成记录缓存KEY(根据recordId)
*/
private static final String PUZZLE_RECORD_BY_ID_KEY = "puzzle:record:id:%s";
/**
* 缓存过期时间(小时)
*/
private static final long CACHE_EXPIRE_HOURS = 24;
/**
* 生成记录缓存过期时间(分钟)- 较短,因为可能频繁变化
*/
private static final long RECORD_CACHE_EXPIRE_MINUTES = 30;
public PuzzleRepository(
PuzzleTemplateMapper templateMapper,
PuzzleElementMapper elementMapper,
PuzzleGenerationRecordMapper recordMapper,
RedisTemplate<String, String> redisTemplate) {
this.templateMapper = templateMapper;
this.elementMapper = elementMapper;
this.recordMapper = recordMapper;
this.redisTemplate = redisTemplate;
}
@@ -147,6 +172,8 @@ public class PuzzleRepository {
* @param code 模板编码(可为null,此时需要先查询获取)
*/
public void clearTemplateCache(Long id, String code) {
Long scenicId = null;
// 如果没有传code,尝试从缓存或数据库获取
if (code == null && id != null) {
String idKey = String.format(PUZZLE_TEMPLATE_BY_ID_KEY, id);
@@ -154,10 +181,25 @@ public class PuzzleRepository {
if (cacheValue != null) {
PuzzleTemplateEntity template = JacksonUtil.parseObject(cacheValue, PuzzleTemplateEntity.class);
code = template.getCode();
scenicId = template.getScenicId();
} else {
PuzzleTemplateEntity template = templateMapper.getById(id);
if (template != null) {
code = template.getCode();
scenicId = template.getScenicId();
}
}
} else if (id != null) {
// 有 code 但需要获取 scenicId
String idKey = String.format(PUZZLE_TEMPLATE_BY_ID_KEY, id);
String cacheValue = redisTemplate.opsForValue().get(idKey);
if (cacheValue != null) {
PuzzleTemplateEntity template = JacksonUtil.parseObject(cacheValue, PuzzleTemplateEntity.class);
scenicId = template.getScenicId();
} else {
PuzzleTemplateEntity template = templateMapper.getById(id);
if (template != null) {
scenicId = template.getScenicId();
}
}
}
@@ -180,6 +222,11 @@ public class PuzzleRepository {
if (id != null) {
clearElementsCache(id);
}
// 清除景区模板列表缓存(确保列表数据一致性)
if (scenicId != null) {
clearTemplateByScenicCache(scenicId);
}
}
// ==================== 元素缓存 ====================
@@ -225,6 +272,58 @@ public class PuzzleRepository {
log.debug("清除元素缓存: templateId={}", templateId);
}
// ==================== 景区模板列表缓存 ====================
/**
* 根据景区ID获取启用的模板列表(优先从缓存读取)
* 用于人脸匹配后的批量拼图生成场景
*
* @param scenicId 景区ID
* @return 启用状态的模板列表
*/
public List<PuzzleTemplateEntity> listTemplateByScenic(Long scenicId) {
if (scenicId == null) {
log.warn("景区ID为空,跳过缓存查询");
return templateMapper.list(null, null, 1);
}
String cacheKey = String.format(PUZZLE_TEMPLATES_BY_SCENIC_KEY, scenicId);
// 1. 尝试从缓存读取
Boolean hasKey = redisTemplate.hasKey(cacheKey);
if (Boolean.TRUE.equals(hasKey)) {
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
log.debug("从缓存读取景区模板列表: scenicId={}", scenicId);
return JacksonUtil.parseObject(cacheValue, new TypeReference<List<PuzzleTemplateEntity>>() {});
}
}
// 2. 从数据库查询(只查启用状态 status=1)
List<PuzzleTemplateEntity> templates = templateMapper.list(scenicId, null, 1);
// 3. 写入缓存(即使是空列表也缓存,避免缓存穿透)
String json = JacksonUtil.toJSONString(templates);
redisTemplate.opsForValue().set(cacheKey, json, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
log.debug("景区模板列表缓存写入: scenicId={}, count={}", scenicId, templates.size());
return templates;
}
/**
* 清除景区模板列表缓存
*
* @param scenicId 景区ID
*/
public void clearTemplateByScenicCache(Long scenicId) {
if (scenicId == null) {
return;
}
String cacheKey = String.format(PUZZLE_TEMPLATES_BY_SCENIC_KEY, scenicId);
redisTemplate.delete(cacheKey);
log.debug("清除景区模板列表缓存: scenicId={}", scenicId);
}
// ==================== 批量清除 ====================
/**
@@ -238,6 +337,8 @@ public class PuzzleRepository {
deleteByPattern("puzzle:template:*");
// 使用 SCAN 删除元素缓存
deleteByPattern("puzzle:elements:*");
// 使用 SCAN 删除景区模板列表缓存
deleteByPattern("puzzle:templates:*");
log.warn("拼图缓存清除完成");
}
@@ -257,4 +358,122 @@ public class PuzzleRepository {
log.error("删除缓存失败: pattern={}", pattern, e);
}
}
// ==================== 生成记录缓存 ====================
/**
* 根据人脸ID获取生成记录列表(优先从缓存读取)
*
* @param faceId 人脸ID
* @return 生成记录列表
*/
public List<PuzzleGenerationRecordEntity> getRecordsByFaceId(Long faceId) {
String cacheKey = String.format(PUZZLE_RECORDS_BY_FACE_KEY, faceId);
// 1. 尝试从缓存读取
Boolean hasKey = redisTemplate.hasKey(cacheKey);
if (Boolean.TRUE.equals(hasKey)) {
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
log.debug("从缓存读取生成记录列表: faceId={}", faceId);
return JacksonUtil.parseObject(cacheValue, new TypeReference<List<PuzzleGenerationRecordEntity>>() {});
}
}
// 2. 从数据库查询
List<PuzzleGenerationRecordEntity> records = recordMapper.listByFaceId(faceId);
// 3. 写入缓存
String json = JacksonUtil.toJSONString(records);
redisTemplate.opsForValue().set(cacheKey, json, RECORD_CACHE_EXPIRE_MINUTES, TimeUnit.MINUTES);
log.debug("生成记录列表缓存写入: faceId={}, count={}", faceId, records.size());
return records;
}
/**
* 根据人脸ID获取生成记录数量(优先从缓存读取)
*
* @param faceId 人脸ID
* @return 记录数量
*/
public int countRecordsByFaceId(Long faceId) {
List<PuzzleGenerationRecordEntity> records = getRecordsByFaceId(faceId);
return records.size();
}
/**
* 根据记录ID获取单条生成记录(优先从缓存读取)
*
* @param recordId 记录ID
* @return 生成记录,不存在返回null
*/
public PuzzleGenerationRecordEntity getRecordById(Long recordId) {
String cacheKey = String.format(PUZZLE_RECORD_BY_ID_KEY, recordId);
// 1. 尝试从缓存读取
Boolean hasKey = redisTemplate.hasKey(cacheKey);
if (Boolean.TRUE.equals(hasKey)) {
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
log.debug("从缓存读取生成记录: recordId={}", recordId);
return JacksonUtil.parseObject(cacheValue, PuzzleGenerationRecordEntity.class);
}
}
// 2. 从数据库查询
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
if (record == null) {
return null;
}
// 3. 写入缓存
String json = JacksonUtil.toJSONString(record);
redisTemplate.opsForValue().set(cacheKey, json, RECORD_CACHE_EXPIRE_MINUTES, TimeUnit.MINUTES);
log.debug("生成记录缓存写入: recordId={}", recordId);
return record;
}
/**
* 清除人脸相关的生成记录缓存
* 在新拼图生成成功后调用
*
* @param faceId 人脸ID
*/
public void clearRecordCacheByFace(Long faceId) {
if (faceId == null) {
return;
}
// 清除列表缓存
String listKey = String.format(PUZZLE_RECORDS_BY_FACE_KEY, faceId);
redisTemplate.delete(listKey);
log.debug("清除人脸生成记录缓存: faceId={}", faceId);
}
/**
* 清除单条记录的缓存
*
* @param recordId 记录ID
*/
public void clearRecordCacheById(Long recordId) {
if (recordId == null) {
return;
}
String cacheKey = String.format(PUZZLE_RECORD_BY_ID_KEY, recordId);
redisTemplate.delete(cacheKey);
log.debug("清除生成记录缓存: recordId={}", recordId);
}
/**
* 清除单条记录缓存并同时清除关联的人脸缓存
*
* @param recordId 记录ID
* @param faceId 人脸ID
*/
public void clearRecordCache(Long recordId, Long faceId) {
clearRecordCacheById(recordId);
clearRecordCacheByFace(faceId);
}
}

View File

@@ -2,6 +2,7 @@ package com.ycwl.basic.puzzle.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.ycwl.basic.biz.FaceStatusManager;
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
@@ -17,6 +18,7 @@ import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.pc.processor.PuzzleRelationProcessor;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.utils.WxMpUtil;
@@ -56,6 +58,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
private final PuzzleDuplicationDetector duplicationDetector;
private final PrinterService printerService;
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
private final FaceStatusManager faceStatusManager;
private final PuzzleRelationProcessor puzzleRelationProcessor;
public PuzzleGenerateServiceImpl(
PuzzleRepository puzzleRepository,
@@ -65,7 +69,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
@Lazy ScenicRepository scenicRepository,
@Lazy PuzzleDuplicationDetector duplicationDetector,
@Lazy PrinterService printerService,
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService) {
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService,
@Lazy FaceStatusManager faceStatusManager,
@Lazy PuzzleRelationProcessor puzzleRelationProcessor) {
this.puzzleRepository = puzzleRepository;
this.recordMapper = recordMapper;
this.imageRenderer = imageRenderer;
@@ -74,6 +80,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
this.duplicationDetector = duplicationDetector;
this.printerService = printerService;
this.puzzleEdgeRenderTaskService = puzzleEdgeRenderTaskService;
this.faceStatusManager = faceStatusManager;
this.puzzleRelationProcessor = puzzleRelationProcessor;
}
@Override
@@ -101,6 +109,32 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
}
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
// 2.5 素材版本缓存检查(减少数据库查询)
// 如果缓存存在,说明自上次成功生成后素材没有变化,可以直接复用历史记录
if (request.getFaceId() != null && !faceStatusManager.isPuzzleSourceChanged(request.getFaceId(), template.getId(), 0)) {
PuzzleGenerationRecordEntity cachedRecord = recordMapper.findLatestSuccessByFaceAndTemplate(
request.getFaceId(), template.getId());
if (cachedRecord != null) {
long duration = System.currentTimeMillis() - startTime;
log.info("素材版本缓存命中,复用历史记录: faceId={}, templateId={}, recordId={}, imageUrl={}, duration={}ms",
request.getFaceId(), template.getId(), cachedRecord.getId(),
cachedRecord.getResultImageUrl(), duration);
return PuzzleGenerateResponse.success(
cachedRecord.getResultImageUrl(),
cachedRecord.getResultFileSize(),
cachedRecord.getResultWidth(),
cachedRecord.getResultHeight(),
(int) duration,
cachedRecord.getId(),
true,
cachedRecord.getId()
);
}
// 缓存存在但记录被删除,继续执行正常流程
log.debug("素材版本缓存存在但历史记录不存在,继续正常生成: faceId={}, templateId={}",
request.getFaceId(), template.getId());
}
// 3. 查询并排序元素
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
if (elements.isEmpty()) {
@@ -124,6 +158,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
long duration = System.currentTimeMillis() - startTime;
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
// 标记素材版本缓存
if (request.getFaceId() != null) {
faceStatusManager.markPuzzleSourceVersion(request.getFaceId(), template.getId(), 0);
}
return PuzzleGenerateResponse.success(
duplicateRecord.getResultImageUrl(),
duplicateRecord.getResultFileSize(),
@@ -141,6 +179,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
record.setContentHash(contentHash);
recordMapper.insert(record);
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
// 8. 创建边缘渲染任务并等待完成
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
record,
@@ -164,6 +205,19 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
log.info("同步拼图边缘渲染完成: recordId={}, taskId={}, imageUrl={}, duration={}ms",
record.getId(), taskId, waitResult.getImageUrl(), duration);
// 标记素材版本缓存(成功生成后)
if (request.getFaceId() != null) {
faceStatusManager.markPuzzleSourceVersion(request.getFaceId(), template.getId(), 0);
}
// 创建member_puzzle关联记录
puzzleRelationProcessor.createPuzzleRelation(
request.getUserId(),
resolvedScenicId,
request.getFaceId(),
record.getId()
);
// 重新查询记录获取完整信息(边缘渲染回调已更新)
PuzzleGenerationRecordEntity updatedRecord = recordMapper.getById(record.getId());
if (updatedRecord != null && updatedRecord.getResultImageUrl() != null) {
@@ -212,6 +266,23 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
}
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
// 2.5 素材版本缓存检查(减少数据库查询)
// 如果缓存存在,说明自上次成功生成后素材没有变化,可以直接复用历史记录
if (request.getFaceId() != null && !faceStatusManager.isPuzzleSourceChanged(request.getFaceId(), template.getId(), 0)) {
PuzzleGenerationRecordEntity cachedRecord = recordMapper.findLatestSuccessByFaceAndTemplate(
request.getFaceId(), template.getId());
if (cachedRecord != null) {
long duration = System.currentTimeMillis() - startTime;
log.info("素材版本缓存命中,复用历史记录: faceId={}, templateId={}, recordId={}, imageUrl={}, duration={}ms",
request.getFaceId(), template.getId(), cachedRecord.getId(),
cachedRecord.getResultImageUrl(), duration);
return cachedRecord.getId();
}
// 缓存存在但记录被删除,继续执行正常流程
log.debug("素材版本缓存存在但历史记录不存在,继续正常生成: faceId={}, templateId={}",
request.getFaceId(), template.getId());
}
// 3. 查询并排序元素
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
if (elements.isEmpty()) {
@@ -235,6 +306,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
long duration = System.currentTimeMillis() - startTime;
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
// 标记素材版本缓存
if (request.getFaceId() != null) {
faceStatusManager.markPuzzleSourceVersion(request.getFaceId(), template.getId(), 0);
}
return duplicateRecord.getId();
}
@@ -243,6 +318,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
record.setContentHash(contentHash);
recordMapper.insert(record);
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
// 8. 创建边缘渲染任务
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
record,
@@ -253,6 +331,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
request.getQuality()
);
// 异步任务:在回调成功后标记缓存(由边缘渲染服务在成功回调中处理)
// 这里只记录请求信息供后续使用
long duration = System.currentTimeMillis() - startTime;
log.info("异步拼图任务已进入边缘渲染队列: recordId={}, taskId={}, templateCode={}, duration={}ms",
record.getId(), taskId, request.getTemplateCode(), duration);
@@ -331,6 +411,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
record.setContentHash(contentHash);
recordMapper.insert(record);
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
// 9. 执行核心生成逻辑
return doGenerateInternal(request, template, resolvedScenicId, record, startTime);
}
@@ -385,40 +468,27 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
// 渲染图片
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
// 上传图到OSS(未裁切)
String originalImageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("图上传成功: url={}", originalImageUrl);
// 处理用户区域裁切
String finalImageUrl = originalImageUrl;
BufferedImage finalImage = resultImage;
if (StrUtil.isNotBlank(template.getUserArea())) {
try {
BufferedImage croppedImage = cropImage(resultImage, template.getUserArea());
finalImageUrl = uploadImage(croppedImage, template.getCode() + "_cropped", request.getOutputFormat(), request.getQuality());
finalImage = croppedImage;
log.info("裁切后图片上传成功: userArea={}, url={}", template.getUserArea(), finalImageUrl);
} catch (Exception e) {
log.error("图片裁切失败,使用原图: userArea={}", template.getUserArea(), e);
}
}
// 上传图到OSS
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("上传成功: url={}", imageUrl);
// 更新记录为成功
long duration = System.currentTimeMillis() - startTime;
long fileSize = estimateFileSize(finalImage, request.getOutputFormat());
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
recordMapper.updateSuccess(
record.getId(),
finalImageUrl,
originalImageUrl,
imageUrl,
fileSize,
finalImage.getWidth(),
finalImage.getHeight(),
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration
);
log.info("拼图生成成功: recordId={}, originalUrl={}, finalUrl={}, duration={}ms",
record.getId(), originalImageUrl, finalImageUrl, duration);
// 清除生成记录缓存(状态已更新)
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
record.getId(), imageUrl, duration);
// 检查是否自动添加到打印队列
if (template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
@@ -427,8 +497,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
request.getUserId(),
resolvedScenicId,
request.getFaceId(),
originalImageUrl,
record.getId()
imageUrl,
record.getId() // 拼图记录ID,用于关联 puzzle_generation_record 表
);
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
} catch (Exception e) {
@@ -437,10 +507,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
}
return PuzzleGenerateResponse.success(
finalImageUrl,
imageUrl,
fileSize,
finalImage.getWidth(),
finalImage.getHeight(),
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration,
record.getId(),
false,
@@ -450,6 +520,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
} catch (Exception e) {
log.error("拼图生成失败: templateCode={}", request.getTemplateCode(), e);
recordMapper.updateFail(record.getId(), e.getMessage());
// 清除生成记录缓存(状态已更新)
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
}
}
@@ -685,43 +757,4 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
return templateScenicId;
}
/**
* 裁切图片
* @param image 原图
* @param userArea 裁切区域,格式:x,y,w,h
* @return 裁切后的图片
*/
private BufferedImage cropImage(BufferedImage image, String userArea) {
if (StrUtil.isBlank(userArea)) {
return image;
}
try {
String[] parts = userArea.split(",");
if (parts.length != 4) {
throw new IllegalArgumentException("userArea格式错误,应为:x,y,w,h");
}
int x = Integer.parseInt(parts[0].trim());
int y = Integer.parseInt(parts[1].trim());
int w = Integer.parseInt(parts[2].trim());
int h = Integer.parseInt(parts[3].trim());
// 边界检查
if (x < 0 || y < 0 || w <= 0 || h <= 0) {
throw new IllegalArgumentException("裁切区域参数必须为正数");
}
if (x + w > image.getWidth() || y + h > image.getHeight()) {
throw new IllegalArgumentException("裁切区域超出图片边界");
}
// 执行裁切
return image.getSubimage(x, y, w, h);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("userArea格式错误,参数必须为数字", e);
}
}
}

View File

@@ -56,6 +56,11 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
entity.setDeleted(0);
templateMapper.insert(entity);
// 清除景区模板列表缓存
if (entity.getScenicId() != null) {
puzzleRepository.clearTemplateByScenicCache(entity.getScenicId());
}
log.info("拼图模板创建成功: id={}, code={}", entity.getId(), entity.getCode());
return entity.getId();
}
@@ -71,8 +76,11 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
throw new IllegalArgumentException("模板不存在: " + id);
}
// 如果修改了编码,检查新编码是否已存在
// 记录旧值
String oldCode = existing.getCode();
Long oldScenicId = existing.getScenicId();
// 如果修改了编码,检查新编码是否已存在
if (request.getCode() != null && !request.getCode().equals(existing.getCode())) {
int count = templateMapper.countByCode(request.getCode(), id);
if (count > 0) {
@@ -85,12 +93,18 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
entity.setId(id);
templateMapper.update(entity);
// 清除缓存(如果修改了code,需要同时清除新旧code的缓存)
// 清除缓存
puzzleRepository.clearTemplateCache(id, oldCode);
if (request.getCode() != null && !request.getCode().equals(oldCode)) {
puzzleRepository.clearTemplateCache(null, request.getCode());
}
// 如果 scenicId 变更,清除新旧两个景区的缓存
Long newScenicId = request.getScenicId();
if (newScenicId != null && !newScenicId.equals(oldScenicId)) {
puzzleRepository.clearTemplateByScenicCache(newScenicId);
}
log.info("拼图模板更新成功: id={}", id);
}

View File

@@ -1,7 +1,5 @@
package com.ycwl.basic.repository;
import com.ycwl.basic.facebody.enums.FaceBodyAdapterType;
import com.ycwl.basic.integration.common.util.ConfigValueUtil;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.scenic.service.ScenicIntegrationService;
@@ -14,8 +12,6 @@ import com.ycwl.basic.model.pc.mp.MpNotifyConfigEntity;
import com.ycwl.basic.model.pc.mp.ScenicMpNotifyVO;
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
import com.ycwl.basic.pay.enums.PayAdapterType;
import com.ycwl.basic.storage.enums.StorageType;
import com.ycwl.basic.utils.JacksonUtil;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import org.springframework.beans.factory.annotation.Autowired;
@@ -41,8 +37,6 @@ public class ScenicRepository {
public static final String SCENIC_MP_CACHE_KEY = "scenic:%s:mp";
public static final String SCENIC_MP_NOTIFY_CACHE_KEY = "scenic:%s:mpNotify";
@Autowired
private MpNotifyConfigMapper mpNotifyConfigMapper;
public ScenicV2DTO getScenicBasic(Long id) {
ScenicV2DTO scenicDTO = scenicIntegrationService.getScenic(id);
@@ -70,61 +64,6 @@ public class ScenicRepository {
return mpConfigEntity;
}
public ScenicMpNotifyVO getScenicMpNotifyConfig(Long scenicId) {
if (redisTemplate.hasKey(String.format(SCENIC_MP_NOTIFY_CACHE_KEY, scenicId))) {
return JacksonUtil.parseObject(redisTemplate.opsForValue().get(String.format(SCENIC_MP_NOTIFY_CACHE_KEY, scenicId)), ScenicMpNotifyVO.class);
}
MpConfigEntity mpConfig = getScenicMpConfig(scenicId);
if (mpConfig == null) {
return null;
}
ScenicMpNotifyVO mpNotifyConfig = new ScenicMpNotifyVO();
mpNotifyConfig.setAppId(mpConfig.getAppId());
mpNotifyConfig.setAppSecret(mpConfig.getAppSecret());
mpNotifyConfig.setMpId(mpConfig.getId());
mpNotifyConfig.setAppState(mpConfig.getState());
List<MpNotifyConfigEntity> mpNotifyConfigList = mpNotifyConfigMapper.listByMpId(mpConfig.getId());
mpNotifyConfigList.forEach(item -> {
switch (item.getType()) {
case 0:
mpNotifyConfig.setVideoGeneratedTemplateId(item.getTemplateId());
break;
case 1:
mpNotifyConfig.setVideoDownloadTemplateId(item.getTemplateId());
break;
case 2:
mpNotifyConfig.setVideoPreExpireTemplateId(item.getTemplateId());
break;
}
});
redisTemplate.opsForValue().set(String.format(SCENIC_MP_NOTIFY_CACHE_KEY, scenicId), JacksonUtil.toJSONString(mpNotifyConfig));
return mpNotifyConfig;
}
public String getVideoGeneratedTemplateId(Long scenicId) {
ScenicMpNotifyVO scenicMpNotifyConfig = getScenicMpNotifyConfig(scenicId);
if (scenicMpNotifyConfig != null) {
return scenicMpNotifyConfig.getVideoGeneratedTemplateId();
}
return null;
}
public String getVideoDownloadTemplateId(Long scenicId) {
ScenicMpNotifyVO scenicMpNotifyConfig = getScenicMpNotifyConfig(scenicId);
if (scenicMpNotifyConfig != null) {
return scenicMpNotifyConfig.getVideoDownloadTemplateId();
}
return null;
}
public String getVideoPreExpireTemplateId(Long scenicId) {
ScenicMpNotifyVO scenicMpNotifyConfig = getScenicMpNotifyConfig(scenicId);
if (scenicMpNotifyConfig != null) {
return scenicMpNotifyConfig.getVideoPreExpireTemplateId();
}
return null;
}
public List<ScenicV2DTO> list(ScenicReqQuery scenicReqQuery) {
try {
// 将 ScenicReqQuery 参数转换为 zt-scenic 服务需要的参数

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