Compare commits

...

40 Commits

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

- 将日期时间格式从 yyyy-MM-dd HH:mm 修改为 yyyy-MM-dd
```
2026-01-20 18:51:52 +08:00
a8156976be feat(puzzle): 添加免费拼图通知任务功能
- 在MemberPuzzleMapper中新增listFreeUnpurchased方法用于查询指定时间范围内生成且未购买的免费拼图记录
- 新增FreePuzzleNotificationTask定时任务类,每天晚7点执行免费拼图通知
- 添加SQL映射配置实现免费拼图记录的查询逻辑
- 实现微信订阅通知触发机制,向符合条件的用户发送免费拼图领取通知
- 集成景区信息查询和会员信息获取功能用于通知内容构造
2026-01-20 18:35:57 +08:00
ce48bd00c9 feat(task): 添加优惠券领取和过半提醒定时任务
- 新增优惠券领取通知定时任务,每小时执行一次查询最近2小时内领取的优惠券
- 新增优惠券有效期过半提醒定时任务,每天18点执行
- 引入ScenicRepository和ScenicV2DTO用于获取景区基础信息
- 修改processNotification方法提取公共逻辑到processRecords方法
- 在微信订阅消息变量中增加景区ID和景区名称字段
- 优化优惠券相关查询逻辑和数据处理流程
2026-01-20 17:11:20 +08:00
c5df277e6c feat(task): 添加优惠券过期提醒定时任务
- 实现优惠券过期提醒功能,每天20点执行
- 实现优惠券临期提醒功能,每天8点执行
- 集成微信订阅消息通知服务
- 查询指定时间范围内已领取的优惠券记录
- 构建优惠券信息变量并触发通知
- 添加异常处理和日志记录机制
2026-01-20 16:58:36 +08:00
9a31e71e42 refactor(coupon): 移除优惠券相关模块代码
- 删除优惠券控制器相关类,包括 AppCouponController 和 CouponController
- 移除优惠券记录控制器 CouponRecordController
- 删除优惠券数据访问层接口及实现类
- 移除优惠券相关的实体类、请求响应对象
- 清理业务逻辑层中与优惠券相关的服务接口及实现
- 从 PriceBiz 中移除优惠券相关导入依赖
- 从任务类 DownloadNotificationTasker 中移除优惠券相关导入
- 删除优惠券相关的 MyBatis 映射文件
2026-01-20 16:30:47 +08:00
e268d236f4 fix(coupon): 修复优惠券重复领取和状态检查逻辑
- 修改数据库查询方法返回类型为List以支持多条记录查询
- 更新AutoCouponServiceImpl中的重复领取检查逻辑
- 在CouponServiceImpl中实现可用优惠券筛选功能
- 优化优惠券状态验证逻辑并改进错误信息提示
- 修复使用优惠券时的状态判断条件
2026-01-20 15:53:33 +08:00
143426db1f refactor(glm): 优化 GLM 客户端实现
- 添加 @Lazy 注解以延迟初始化 GLM 客户端
- 避免应用启动时不必要的资源消耗
- 提高系统启动性能和内存使用效率
2026-01-20 15:52:27 +08:00
fcc4b06295 refactor(goods): 优化视频片段更新状态检查逻辑
- 移除无新片段情况下的冗余日志输出
- 将视频未关联任务的日志级别从error调整为warn
- 保持原有的业务逻辑判断不变
2026-01-20 15:52:13 +08:00
f876dc59fa feat(task): 添加资源通知定时任务功能
- 实现了每日19点执行的资源通知定时任务
- 查询当日新增人脸数据并获取相关会员信息
- 整合景区、视频和照片资源统计数据
- 集成微信订阅消息推送服务
- 构建资源通知模板变量并触发消息发送
- 添加异常处理和日志记录机制
2026-01-20 15:34:46 +08:00
8e6d10ad95 feat(watermark): 调整水印模板布局为原图完整显示
- 将原图区域从90%高度调整为100%完整高度
- 添加底部扩展10%区域用于信息展示
- 更新PuzzleDefault和PuzzlePrint模板的画布尺寸计算逻辑
- 修改二维码尺寸计算基准为原始图片高度
- 调整布局参数常量命名以反映新的设计思路
2026-01-20 11:36:25 +08:00
42bf3d3d0a refactor(puzzle): 优化拼图记录查询逻辑
- 移除 BigDecimal 导入并修改拼图数量统计方式
- 使用关联表查询替换直接的数量统计方法
- 更新拼图记录查询逻辑,通过关联表获取数据
- 添加对空值的过滤处理确保数据完整性
- 修改内容页面转换方法,支持免费状态判断
- 删除价格计算相关依赖和服务调用
- 添加 MemberPuzzleEntity 和 FreeStatus 常量支持
- 从关联记录读取免费状态替代价格计算逻辑
2026-01-20 11:24:57 +08:00
679f2d3a79 feat(order): 添加退款单号幂等性支持并完善订单状态流转验证
- 在RefundRequest中新增refundNo字段作为退款单号幂等键
- 添加订单状态、支付状态、退款状态流转验证逻辑
- 完善updateRefundStatus方法支持退款单号和支付平台退款单号参数
- 优化createRefundRecord方法增加退款单号重复性检查和幂等处理
- 重构支付回调处理逻辑统一状态更新方式
- 增强订单状态流转的安全性和一致性校验
2026-01-19 21:28:24 +08:00
3084afc6a7 fix(pricing): 修复升级检查中的支付金额处理逻辑
- 移除 resolvePaidAmount 方法中对 purchasedDetails 的依赖
- 添加支付金额为空时的异常抛出机制
- 在升单结果中添加补差价金额的判断逻辑
- 更新文档中标注支付金额为必填字段
- 优化打包优惠计算中的补差价处理流程
2026-01-19 20:28:36 +08:00
91626626f4 feat(pricing): 优化升单价格计算逻辑支持补差价功能
- 修改 UpgradeBundleDiscountResult 和 UpgradeOnePriceResult 中 estimatedFinalAmount 字段含义为补差价金额
- 在 UpgradeCheckRequest 中新增 paidAmount 字段用于传递已支付金额
- 在 UpgradeCheckResult 中新增 bestUpgradeType 和 bestPayableAmount 字段提供最优升单建议
- 在 UpgradePriceSummary 中新增 paidAmount 字段记录已支付金额
- 更新价格计算服务实现,加入已支付金额处理逻辑
- 新增 normalizeAmount、calculateSupplementAmount 等工具方法确保金额计算精度
- 修复测试代码中的数据类型不匹配问题
2026-01-19 20:25:44 +08:00
b1cfef278d Merge branch 'order_update'
# Conflicts:
#	src/main/java/com/ycwl/basic/pricing/CLAUDE.md
2026-01-19 19:54:59 +08:00
c42474256e fix(LyCompatibleController): 修复视频列表获取逻辑
- 修改了ContentPageVO的获取方式,使用filter过滤掉contentId为null的记录
- 使用findFirst替换getFirst避免空指针异常
- 确保只有有效的contentId才会被用于后续的视频查询操作
2026-01-19 19:32:09 +08:00
63180159d2 feat(puzzle): 实现免费拼图下载时自动添加水印功能
- 在AppPuzzleController中新增水印相关依赖注入
- 添加免费拼图判断逻辑,区分付费与免费拼图下载流程
- 实现addWatermarkForFreePuzzle方法处理水印添加
- 集成景区信息、人脸信息、二维码等水印模板数据
- 添加水印任务创建与等待机制,支持30秒超时处理
- 增加水印操作的日志记录与异常处理机制
- 优化免费拼图下载的安全性保护措施
2026-01-19 18:58:08 +08:00
e647ad75c6 feat(clickhouse): 实现统计数据查询的时间序列填充功能
- 将日期时间处理从旧的 Date 和 SimpleDateFormat 迁移到新的 Java 8 时间 API
- 添加小时级别数据序列填充功能,确保每个小时都有数据记录
- 添加日期级别数据序列填充功能,确保每天都有数据记录
- 实现缺失时间段的数据自动补零机制
- 重构查询方法以支持连续时间序列数据返回
- 提高统计图表数据完整性和可视化效果
2026-01-17 16:58:23 +08:00
5d5643e7d7 feat(pricing): 新增照片打印SKU及价格计算逻辑
- 添加 PHOTO_PRINT_MU 和 PHOTO_PRINT_FX 枚举类型定义
- 实现手机照片打印和特效照片打印的基础价格计算(单价×数量)
- 支持景区特定配置的价格计算逻辑
- 验证新SKU与现有 PHOTO_PRINT 的行为一致性
- 添加相关单元测试确保价格计算准确性
2025-11-17 08:53:08 +08:00
dc4091e058 feat(pricing): 新增升单检测功能- 添加升单检测API端点 /api/pricing/upgrade-check
- 实现 `checkUpgrade` 核心方法,用于检测已购与待购商品组合优惠
- 支持一口价和打包优惠的综合评估逻辑- 提供详细的请求参数与响应结果结构定义
- 更新文档说明升单检测的业务价值与使用场景- 补充关键架构变更记录与兼容性注意事项
2025-10-11 21:09:36 +08:00
a5c815b6ed feat(pricing): 新增升单检测功能
- 添加升单检测接口和相关 DTO 类
- 实现升单检测逻辑,包括价格汇总、一口价评估和打包优惠评估
- 优化商品列表复制和规范化处理
- 新增 IBundleDiscountService 依赖
2025-09-18 19:51:13 +08:00
138 changed files with 6737 additions and 4849 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,8 +1,6 @@
package com.ycwl.basic.biz;
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.order.entity.OrderEntity;
import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity;

View File

@@ -12,10 +12,13 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.TimeZone;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
/**
* ClickHouse 统计数据查询服务实现
@@ -366,12 +369,14 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
sql.append("GROUP BY toStartOfHour(s.create_time) ");
sql.append("ORDER BY toStartOfHour(s.create_time)");
return getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
HashMap<String, String> map = new HashMap<>();
map.put("t", rs.getString("t"));
map.put("count", rs.getString("count"));
return map;
});
return fillHourSeries(rawData, query.getStartTime(), query.getEndTime());
}
@Override
@@ -389,12 +394,14 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
sql.append("GROUP BY toStartOfDay(s.create_time) ");
sql.append("ORDER BY toStartOfDay(s.create_time)");
return getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
HashMap<String, String> map = new HashMap<>();
map.put("t", rs.getString("t"));
map.put("count", rs.getString("count"));
return map;
});
return fillDateSeries(rawData, query.getStartTime(), query.getEndTime());
}
@Override
@@ -418,12 +425,14 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
sql.append("GROUP BY toStartOfHour(s.create_time) ");
sql.append("ORDER BY toStartOfHour(s.create_time)");
return getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
HashMap<String, String> map = new HashMap<>();
map.put("t", rs.getString("t"));
map.put("count", rs.getString("count"));
return map;
});
return fillHourSeries(rawData, query.getStartTime(), query.getEndTime());
}
@Override
@@ -447,11 +456,81 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
sql.append("GROUP BY toStartOfDay(s.create_time) ");
sql.append("ORDER BY toStartOfDay(s.create_time)");
return getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
HashMap<String, String> map = new HashMap<>();
map.put("t", rs.getString("t"));
map.put("count", rs.getString("count"));
return map;
});
return fillDateSeries(rawData, query.getStartTime(), query.getEndTime());
}
/**
* 填充小时序列,确保每个小时都有数据(缺失的填充为0)
*/
private List<HashMap<String, String>> fillHourSeries(List<HashMap<String, String>> rawData, Date startTime, Date endTime) {
if (startTime == null || endTime == null) {
return rawData;
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd HH");
LocalDateTime start = startTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().truncatedTo(ChronoUnit.HOURS);
LocalDateTime end = endTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().truncatedTo(ChronoUnit.HOURS);
// 将原始数据转为 Map 以便快速查找
Map<String, String> dataMap = rawData.stream()
.collect(Collectors.toMap(
m -> m.get("t"),
m -> m.get("count"),
(existing, replacement) -> existing
));
List<HashMap<String, String>> result = new ArrayList<>();
LocalDateTime current = start;
while (!current.isAfter(end)) {
String timeKey = current.format(formatter);
HashMap<String, String> item = new HashMap<>();
item.put("t", timeKey);
item.put("count", dataMap.getOrDefault(timeKey, "0"));
result.add(item);
current = current.plusHours(1);
}
return result;
}
/**
* 填充日期序列,确保每天都有数据(缺失的填充为0)
*/
private List<HashMap<String, String>> fillDateSeries(List<HashMap<String, String>> rawData, Date startTime, Date endTime) {
if (startTime == null || endTime == null) {
return rawData;
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd");
LocalDate start = startTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
LocalDate end = endTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
// 将原始数据转为 Map 以便快速查找
Map<String, String> dataMap = rawData.stream()
.collect(Collectors.toMap(
m -> m.get("t"),
m -> m.get("count"),
(existing, replacement) -> existing
));
List<HashMap<String, String>> result = new ArrayList<>();
LocalDate current = start;
while (!current.isAfter(end)) {
String timeKey = current.format(formatter);
HashMap<String, String> item = new HashMap<>();
item.put("t", timeKey);
item.put("count", dataMap.getOrDefault(timeKey, "0"));
result.add(item);
current = current.plusDays(1);
}
return result;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.model.mobile.scenic.content.ScenicTemplateContentVO;
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
@@ -85,4 +86,46 @@ public class AppTemplateController {
return ApiResponse.success(coverUrls);
}
/**
* 根据景区ID获取所有模板内容列表(返回模板基础信息,与 faceId 无关)
*
* @param scenicId 景区ID
* @return 景区模板内容列表
*/
@GetMapping("/scenic/{scenicId}/contents")
@IgnoreToken
public ApiResponse<List<ScenicTemplateContentVO>> getScenicTemplateContents(@PathVariable("scenicId") Long scenicId) {
if (scenicId == null) {
return ApiResponse.fail("景区ID不能为空");
}
List<ScenicTemplateContentVO> contentList = new ArrayList<>();
// 获取普通模板
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(scenicId);
for (TemplateRespVO template : templateList) {
ScenicTemplateContentVO content = new ScenicTemplateContentVO();
content.setGoodsType(0); // 普通模板默认商品类型为 0
content.setName(template.getName());
content.setGroup(template.getGroup());
content.setTemplateId(template.getId());
content.setTemplateCoverUrl(template.getCoverUrl());
contentList.add(content);
}
// 获取拼图模板
List<PuzzleTemplateEntity> puzzleTemplateList = puzzleRepository.listTemplateByScenic(scenicId);
for (PuzzleTemplateEntity puzzleTemplate : puzzleTemplateList) {
ScenicTemplateContentVO content = new ScenicTemplateContentVO();
content.setGoodsType(3); // 拼图模板商品类型为 3
content.setName(puzzleTemplate.getName());
content.setGroup("氛围拼图"); // 拼图模板固定分组
content.setTemplateId(puzzleTemplate.getId());
content.setTemplateCoverUrl(puzzleTemplate.getCoverImage());
contentList.add(content);
}
return ApiResponse.success(contentList);
}
}

View File

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

View File

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

View File

@@ -12,8 +12,8 @@ import java.util.Map;
*
* 布局说明:
* - 白色背景
* - 顶部90%为原图区域(COVER模式)
* - 底部10%为信息区域:
* - 顶部100%为原图区域(COVER模式,保持原图完整尺寸
* - 底部扩展10%为信息区域:
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
*/
@@ -23,7 +23,7 @@ public class PuzzleDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemp
public static final String STYLE = "puzzle_default";
// 布局比例配置
private static final double IMAGE_HEIGHT_RATIO = 0.90; // 原图占90%高度
private static final double BOTTOM_EXTEND_RATIO = 0.10; // 底部扩展为原图高度的10%
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
@@ -43,13 +43,15 @@ public class PuzzleDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemp
int imageWidth = request.getImageWidth();
int imageHeight = request.getImageHeight();
// 画布尺寸 = 原图尺寸
int canvasWidth = imageWidth;
int canvasHeight = imageHeight;
// 底部扩展区域高度
int bottomAreaHeight = (int) (imageHeight * BOTTOM_EXTEND_RATIO);
// 原图区域占90%高度,底部信息区占10%高度
int originalImageHeight = (int) (imageHeight * IMAGE_HEIGHT_RATIO);
int bottomAreaHeight = imageHeight - originalImageHeight;
// 画布尺寸 = 原图尺寸 + 底部扩展
int canvasWidth = imageWidth;
int canvasHeight = imageHeight + bottomAreaHeight;
// 原图区域保持完整高度
int originalImageHeight = imageHeight;
// 创建模板(白色背景)
PuzzleTemplateEntity template = createTemplateWithColor(

View File

@@ -13,8 +13,8 @@ import java.util.Map;
* 布局说明:
* - 白色背景
* - 四周留1%白边
* - 内部区域:顶部90%为原图区域(COVER模式)
* - 底部10%为信息区域:
* - 内部区域:顶部100%为原图区域(COVER模式,保持原图完整尺寸
* - 底部扩展10%为信息区域:
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
*/
@@ -25,7 +25,7 @@ public class PuzzlePrintWatermarkTemplateBuilder extends AbstractWatermarkTempla
// 布局比例配置
private static final double BORDER_RATIO = 0.01; // 四周白边为1%
private static final double IMAGE_HEIGHT_RATIO = 0.90; // 原图占内容区90%高度
private static final double BOTTOM_EXTEND_RATIO = 0.10; // 底部扩展为原图高度的10%
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
@@ -49,21 +49,23 @@ public class PuzzlePrintWatermarkTemplateBuilder extends AbstractWatermarkTempla
int borderX = (int) (imageWidth * BORDER_RATIO);
int borderY = (int) (imageHeight * BORDER_RATIO);
// 画布尺寸 = 原图尺寸 + 四周白边
// 底部扩展区域高度
int bottomAreaHeight = (int) (imageHeight * BOTTOM_EXTEND_RATIO);
// 内容区高度 = 原图高度 + 扩展区域(扩展区域在白边内部)
int contentHeight = imageHeight + bottomAreaHeight;
// 画布尺寸 = 内容区尺寸 + 四周白边
int canvasWidth = imageWidth + borderX * 2;
int canvasHeight = imageHeight + borderY * 2;
int canvasHeight = contentHeight + 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;
int originalImageHeight = imageHeight;
// 创建模板(白色背景)
PuzzleTemplateEntity template = createTemplateWithColor(
@@ -88,7 +90,7 @@ public class PuzzlePrintWatermarkTemplateBuilder extends AbstractWatermarkTempla
// 2. 计算底部区域元素位置(相对于内容区)
int marginX = (int) (contentWidth * MARGIN_X_RATIO);
int qrcodeSize = (int) (contentHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
int qrcodeSize = (int) (imageHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
// 二维码垂直居中于底部区域
int qrcodeX = contentStartX + marginX;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.model.pc.notify.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -64,6 +65,13 @@ public class WechatSubscribeTemplateConfigEntity {
*/
private String description;
/**
* 去重窗口(秒),0表示永久去重,小于0表示不去重,大于0表示窗口期去重
* 来自 wechat_subscribe_event_template 表,仅在事件触发时有效
*/
@TableField(exist = false)
private Integer dedupSeconds;
private Date createTime;
private Date updateTime;

View File

@@ -10,6 +10,11 @@ import java.math.BigDecimal;
@Data
public class RefundRequest {
/**
* 退款单号(幂等键,必填)
*/
private String refundNo;
/**
* 订单ID
*/

View File

@@ -102,9 +102,11 @@ public interface IOrderService {
*
* @param orderId 订单ID
* @param refundStatus 退款状态
* @param refundNo 退款单号(幂等键)
* @param paymentRefundId 支付平台退款单号
* @return 更新结果
*/
boolean updateRefundStatus(Long orderId, RefundStatus refundStatus);
boolean updateRefundStatus(Long orderId, RefundStatus refundStatus, String refundNo, String paymentRefundId);
/**
* 创建退款记录

View File

@@ -47,6 +47,7 @@ import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.service.pc.ScenicService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -264,18 +265,29 @@ public class OrderServiceImpl implements IOrderService {
@Override
public boolean updateOrderStatus(Long orderId, String orderStatus, String paymentStatus) {
OrderV2 order = new OrderV2();
order.setId(orderId);
order.setOrderStatus(OrderStatus.fromCode(orderStatus));
order.setPaymentStatus(PaymentStatus.fromCode(paymentStatus));
order.setUpdateTime(new Date());
if ("PAID".equals(paymentStatus)) {
order.setPayTime(new Date());
OrderV2 currentOrder = orderV2Mapper.selectById(orderId);
if (currentOrder == null) {
log.warn("订单不存在: orderId={}", orderId);
return false;
}
if ("COMPLETED".equals(orderStatus)) {
order.setCompleteTime(new Date());
OrderStatus targetOrderStatus = OrderStatus.fromCode(orderStatus);
PaymentStatus targetPaymentStatus = PaymentStatus.fromCode(paymentStatus);
validateOrderStatusTransition(currentOrder, targetOrderStatus, targetPaymentStatus);
if (currentOrder.getOrderStatus() == targetOrderStatus
&& currentOrder.getPaymentStatus() == targetPaymentStatus) {
return true;
}
OrderV2 order = new OrderV2();
order.setId(orderId);
order.setOrderStatus(targetOrderStatus);
order.setPaymentStatus(targetPaymentStatus);
order.setUpdateTime(new Date());
if (targetPaymentStatus == PaymentStatus.PAID && currentOrder.getPayTime() == null) {
order.setPayTime(new Date());
}
return orderV2Mapper.updateById(order) > 0;
@@ -355,28 +367,30 @@ public class OrderServiceImpl implements IOrderService {
return false;
}
PaymentStatus oldPaymentStatus = currentOrder.getPaymentStatus();
PaymentStatus newPaymentStatus = PaymentStatus.fromCode(paymentStatus);
PaymentStatus targetPaymentStatus = PaymentStatus.fromCode(paymentStatus);
validatePaymentStatusTransition(currentOrder, targetPaymentStatus);
PaymentStatus oldPaymentStatus = currentOrder.getPaymentStatus();
if (oldPaymentStatus == targetPaymentStatus) {
return true;
}
// 更新订单支付状态
OrderV2 order = new OrderV2();
order.setId(orderId);
order.setPaymentStatus(newPaymentStatus);
order.setPaymentStatus(targetPaymentStatus);
order.setOrderStatus(OrderStatus.PAID);
order.setUpdateTime(new Date());
if ("PAID".equals(paymentStatus)) {
if (currentOrder.getPayTime() == null) {
order.setPayTime(new Date());
// 支付完成后,订单状态也需要更新
order.setOrderStatus(OrderStatus.PAID);
}
boolean success = orderV2Mapper.updateById(order) > 0;
// 发布支付状态变更事件
if (success && !oldPaymentStatus.equals(newPaymentStatus)) {
if (success) {
PaymentStatusChangeEvent event = new PaymentStatusChangeEvent(
orderId, currentOrder.getOrderNo(),
oldPaymentStatus, newPaymentStatus,
oldPaymentStatus, targetPaymentStatus,
"支付状态更新", null
);
orderEventManager.publishPaymentStatusChangeEvent(event);
@@ -387,36 +401,61 @@ public class OrderServiceImpl implements IOrderService {
@Override
@Transactional
public boolean updateRefundStatus(Long orderId, RefundStatus refundStatus) {
// 先查询当前订单状态
public boolean updateRefundStatus(Long orderId, RefundStatus refundStatus, String refundNo, String paymentRefundId) {
OrderV2 currentOrder = orderV2Mapper.selectById(orderId);
if (currentOrder == null) {
log.warn("订单不存在: orderId={}", orderId);
return false;
}
RefundStatus oldRefundStatus = currentOrder.getRefundStatus();
if (refundStatus == null) {
throw new BaseException("退款状态不能为空");
}
// 更新订单退款状态
validateRefundStatusTransition(currentOrder, refundStatus);
OrderRefundV2 refundRecord = resolveRefundRecord(orderId, refundNo);
String resolvedRefundNo = refundRecord.getRefundNo();
if (StringUtils.isBlank(resolvedRefundNo)) {
throw new BaseException("退款单号不能为空");
}
RefundStatus oldRefundStatus = currentOrder.getRefundStatus();
boolean refundStatusChanged = oldRefundStatus != refundStatus;
boolean paymentRefundIdNeedUpdate = StringUtils.isNotBlank(paymentRefundId)
&& StringUtils.isBlank(refundRecord.getPaymentRefundId());
if (!refundStatusChanged && !paymentRefundIdNeedUpdate
&& isOrderRefundStateConsistent(currentOrder, refundStatus)) {
return true;
}
if (refundStatusChanged || paymentRefundIdNeedUpdate) {
OrderRefundV2 updateRefund = new OrderRefundV2();
updateRefund.setId(refundRecord.getId());
updateRefund.setRefundStatus(refundStatus);
if (paymentRefundIdNeedUpdate) {
updateRefund.setPaymentRefundId(paymentRefundId);
}
updateRefund.setUpdateTime(new Date());
orderRefundMapper.updateById(updateRefund);
}
boolean needUpdateOrder = refundStatusChanged || !isOrderRefundStateConsistent(currentOrder, refundStatus);
boolean success = true;
if (needUpdateOrder) {
OrderV2 order = new OrderV2();
order.setId(orderId);
order.setRefundStatus(refundStatus);
applyOrderRefundState(order, refundStatus);
order.setUpdateTime(new Date());
// 根据退款状态更新订单状态
if (RefundStatus.FULL_REFUND.equals(refundStatus)) {
order.setOrderStatus(OrderStatus.REFUNDED);
} else if (RefundStatus.REFUND_PROCESSING.equals(refundStatus)) {
order.setOrderStatus(OrderStatus.REFUNDING);
success = orderV2Mapper.updateById(order) > 0;
}
boolean success = orderV2Mapper.updateById(order) > 0;
// 发布退款状态变更事件
if (success && !oldRefundStatus.equals(refundStatus)) {
if (success && refundStatusChanged) {
RefundStatusChangeEvent event = new RefundStatusChangeEvent(
orderId, currentOrder.getOrderNo(), null, null,
oldRefundStatus, refundStatus, null,
orderId, currentOrder.getOrderNo(), refundRecord.getId(), resolvedRefundNo,
oldRefundStatus, refundStatus, refundRecord.getRefundAmount(),
"退款状态更新", null
);
orderEventManager.publishRefundStatusChangeEvent(event);
@@ -430,17 +469,50 @@ public class OrderServiceImpl implements IOrderService {
public Long createRefundRecord(RefundRequest request) {
Date now = new Date();
// 生成退款单号
String refundNo = generateRefundNo();
if (request == null) {
throw new BaseException("退款请求不能为空");
}
if (request.getOrderId() == null) {
throw new BaseException("订单ID不能为空");
}
if (StringUtils.isBlank(request.getRefundNo())) {
throw new BaseException("退款单号不能为空");
}
if (StringUtils.isBlank(request.getRefundType())) {
throw new BaseException("退款类型不能为空");
}
OrderV2 order = orderV2Mapper.selectById(request.getOrderId());
if (order == null) {
throw new BaseException("订单不存在");
}
if (order.getOrderStatus() == OrderStatus.CANCELLED || order.getPaymentStatus() == PaymentStatus.UNPAID) {
throw new BaseException("未支付或已取消订单不允许退款");
}
if (order.getRefundStatus() == RefundStatus.FULL_REFUND) {
throw new BaseException("订单已完成退款");
}
String refundNo = request.getRefundNo();
OrderRefundV2 existingRefund = findRefundByNo(refundNo);
if (existingRefund != null) {
if (!request.getOrderId().equals(existingRefund.getOrderId())) {
throw new BaseException("退款单号已存在");
}
RefundStatus existingStatus = existingRefund.getRefundStatus() != null
? existingRefund.getRefundStatus()
: RefundStatus.REFUND_PROCESSING;
updateRefundStatus(request.getOrderId(), existingStatus, refundNo, existingRefund.getPaymentRefundId());
return existingRefund.getId();
}
// 创建退款记录
OrderRefundV2 refund = new OrderRefundV2();
refund.setOrderId(request.getOrderId());
refund.setRefundNo(refundNo);
refund.setRefundType(RefundType.fromCode(request.getRefundType()));
refund.setRefundAmount(request.getRefundAmount());
refund.setRefundFee(request.getRefundFee());
refund.setRefundStatus(com.ycwl.basic.order.enums.RefundStatus.REFUND_PROCESSING);
refund.setRefundStatus(RefundStatus.REFUND_PROCESSING);
refund.setRefundReason(request.getRefundReason());
refund.setRefundDescription(request.getRefundDescription());
refund.setOperatorRemarks(request.getOperatorRemarks());
@@ -451,8 +523,7 @@ public class OrderServiceImpl implements IOrderService {
orderRefundMapper.insert(refund);
// 更新订单退款状态
updateRefundStatus(request.getOrderId(), com.ycwl.basic.order.enums.RefundStatus.REFUND_PROCESSING);
updateRefundStatus(request.getOrderId(), RefundStatus.REFUND_PROCESSING, refundNo, null);
log.info("退款记录创建成功: refundId={}, refundNo={}, orderId={}, refundAmount={}",
refund.getId(), refundNo, request.getOrderId(), request.getRefundAmount());
@@ -468,6 +539,163 @@ public class OrderServiceImpl implements IOrderService {
// ====== 私有辅助方法 ======
private void validateOrderStatusTransition(OrderV2 currentOrder, OrderStatus targetOrderStatus,
PaymentStatus targetPaymentStatus) {
if (currentOrder == null || targetOrderStatus == null || targetPaymentStatus == null) {
throw new BaseException("订单状态参数不能为空");
}
OrderStatus currentOrderStatus = currentOrder.getOrderStatus();
PaymentStatus currentPaymentStatus = currentOrder.getPaymentStatus();
if (currentOrderStatus == null || currentPaymentStatus == null) {
throw new BaseException("订单状态异常");
}
if (currentOrderStatus == targetOrderStatus && currentPaymentStatus == targetPaymentStatus) {
return;
}
boolean allowed = false;
if (currentOrderStatus == OrderStatus.PENDING_PAYMENT) {
allowed = (targetOrderStatus == OrderStatus.PAID && targetPaymentStatus == PaymentStatus.PAID)
|| (targetOrderStatus == OrderStatus.CANCELLED && targetPaymentStatus == PaymentStatus.UNPAID);
} else if (currentOrderStatus == OrderStatus.PAID) {
allowed = (targetOrderStatus == OrderStatus.REFUNDING && targetPaymentStatus == PaymentStatus.PAID)
|| (targetOrderStatus == OrderStatus.REFUNDED && targetPaymentStatus == PaymentStatus.REFUNDED);
} else if (currentOrderStatus == OrderStatus.REFUNDING) {
allowed = targetOrderStatus == OrderStatus.REFUNDED && targetPaymentStatus == PaymentStatus.REFUNDED;
} else if (currentOrderStatus == OrderStatus.CANCELLED) {
allowed = targetOrderStatus == OrderStatus.CANCELLED && targetPaymentStatus == PaymentStatus.UNPAID;
} else if (currentOrderStatus == OrderStatus.REFUNDED) {
allowed = targetOrderStatus == OrderStatus.REFUNDED && targetPaymentStatus == PaymentStatus.REFUNDED;
}
if (!allowed) {
throw new BaseException("订单状态流转非法");
}
}
private void validatePaymentStatusTransition(OrderV2 currentOrder, PaymentStatus targetPaymentStatus) {
if (currentOrder == null || targetPaymentStatus == null) {
throw new BaseException("支付状态参数不能为空");
}
if (currentOrder.getOrderStatus() == null || currentOrder.getPaymentStatus() == null) {
throw new BaseException("订单状态异常");
}
if (targetPaymentStatus != PaymentStatus.PAID) {
throw new BaseException("支付状态仅允许更新为已支付");
}
if (currentOrder.getOrderStatus() == OrderStatus.CANCELLED) {
throw new BaseException("已取消订单不允许支付");
}
if (currentOrder.getPaymentStatus() == PaymentStatus.PAID) {
if (currentOrder.getOrderStatus() == OrderStatus.REFUNDED) {
throw new BaseException("订单状态不允许支付");
}
return;
}
if (currentOrder.getPaymentStatus() != PaymentStatus.UNPAID
|| currentOrder.getOrderStatus() != OrderStatus.PENDING_PAYMENT) {
throw new BaseException("订单状态不允许支付");
}
}
private void validateRefundStatusTransition(OrderV2 currentOrder, RefundStatus targetRefundStatus) {
if (currentOrder == null || targetRefundStatus == null) {
throw new BaseException("退款状态参数不能为空");
}
if (currentOrder.getOrderStatus() == null || currentOrder.getPaymentStatus() == null) {
throw new BaseException("订单状态异常");
}
if (currentOrder.getOrderStatus() == OrderStatus.CANCELLED) {
throw new BaseException("已取消订单不允许退款");
}
if (currentOrder.getPaymentStatus() == PaymentStatus.UNPAID) {
throw new BaseException("未支付订单不允许退款");
}
RefundStatus currentRefundStatus = currentOrder.getRefundStatus();
if (currentRefundStatus == null) {
throw new BaseException("订单退款状态异常");
}
if (currentRefundStatus == targetRefundStatus) {
return;
}
boolean allowed = false;
if (currentRefundStatus == RefundStatus.NO_REFUND) {
allowed = targetRefundStatus == RefundStatus.REFUND_PROCESSING
|| targetRefundStatus == RefundStatus.PARTIAL_REFUND
|| targetRefundStatus == RefundStatus.FULL_REFUND;
} else if (currentRefundStatus == RefundStatus.REFUND_PROCESSING) {
allowed = targetRefundStatus == RefundStatus.PARTIAL_REFUND
|| targetRefundStatus == RefundStatus.FULL_REFUND;
} else if (currentRefundStatus == RefundStatus.PARTIAL_REFUND) {
allowed = targetRefundStatus == RefundStatus.FULL_REFUND;
}
if (!allowed) {
throw new BaseException("退款状态流转非法");
}
}
private boolean isOrderRefundStateConsistent(OrderV2 order, RefundStatus refundStatus) {
if (order == null || refundStatus == null) {
return false;
}
if (refundStatus == RefundStatus.REFUND_PROCESSING || refundStatus == RefundStatus.PARTIAL_REFUND) {
return order.getOrderStatus() == OrderStatus.REFUNDING
&& order.getPaymentStatus() == PaymentStatus.PAID;
}
if (refundStatus == RefundStatus.FULL_REFUND) {
return order.getOrderStatus() == OrderStatus.REFUNDED
&& order.getPaymentStatus() == PaymentStatus.REFUNDED;
}
return order.getRefundStatus() == refundStatus;
}
private void applyOrderRefundState(OrderV2 order, RefundStatus refundStatus) {
if (order == null || refundStatus == null) {
return;
}
if (refundStatus == RefundStatus.REFUND_PROCESSING || refundStatus == RefundStatus.PARTIAL_REFUND) {
order.setOrderStatus(OrderStatus.REFUNDING);
order.setPaymentStatus(PaymentStatus.PAID);
} else if (refundStatus == RefundStatus.FULL_REFUND) {
order.setOrderStatus(OrderStatus.REFUNDED);
order.setPaymentStatus(PaymentStatus.REFUNDED);
}
}
private OrderRefundV2 resolveRefundRecord(Long orderId, String refundNo) {
if (StringUtils.isNotBlank(refundNo)) {
OrderRefundV2 refundRecord = findRefundByNo(refundNo);
if (refundRecord == null) {
throw new BaseException("退款单不存在");
}
if (!orderId.equals(refundRecord.getOrderId())) {
throw new BaseException("退款单不存在");
}
return refundRecord;
}
QueryWrapper<OrderRefundV2> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("order_id", orderId).orderByDesc("create_time");
List<OrderRefundV2> refunds = orderRefundMapper.selectList(queryWrapper);
if (refunds.isEmpty()) {
throw new BaseException("退款单不存在");
}
log.warn("退款单号缺失,使用最新退款记录: orderId={}", orderId);
return refunds.get(0);
}
private OrderRefundV2 findRefundByNo(String refundNo) {
if (StringUtils.isBlank(refundNo)) {
return null;
}
QueryWrapper<OrderRefundV2> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("refund_no", refundNo);
return orderRefundMapper.selectOne(queryWrapper);
}
/**
* 构建订单查询条件
*/
@@ -624,8 +852,9 @@ public class OrderServiceImpl implements IOrderService {
}
// 3. 验证订单状态
if (order.getPaymentStatus() != PaymentStatus.UNPAID) {
throw new RuntimeException("订单状态不允许支付");
if (order.getOrderStatus() != OrderStatus.PENDING_PAYMENT
|| order.getPaymentStatus() != PaymentStatus.UNPAID) {
throw new BaseException("订单状态不允许支付");
}
// 4. 获取用户openId(从订单中获取)
@@ -713,31 +942,15 @@ public class OrderServiceImpl implements IOrderService {
if (callbackResponse.isPay()) {
// 支付成功
statusChangeType = "PAID";
updatePaymentStatus(order.getId(), PaymentStatus.PAID.name());
// 触发支付成功事件
orderEventManager.publishPaymentStatusChangeEvent(
new PaymentStatusChangeEvent(order.getId(), order.getOrderNo(),
PaymentStatus.UNPAID, PaymentStatus.PAID, "支付回调成功", null)
);
updatePaymentStatus(order.getId(), PaymentStatus.PAID.getCode());
} else if (callbackResponse.isCancel()) {
// 支付取消 - 这种情况下支付状态保持未支付,不需要特别处理
// 支付取消
statusChangeType = "CANCELLED";
log.info("支付被取消,支付状态保持未支付: orderId={}", order.getId());
updateOrderStatus(order.getId(), OrderStatus.CANCELLED.getCode(), PaymentStatus.UNPAID.getCode());
} else if (callbackResponse.isRefund()) {
// 退款
statusChangeType = "REFUNDED";
updatePaymentStatus(order.getId(), PaymentStatus.REFUNDED.name());
updateRefundStatus(order.getId(), RefundStatus.FULL_REFUND);
// 触发退款事件
orderEventManager.publishRefundStatusChangeEvent(
new RefundStatusChangeEvent(order.getId(), order.getOrderNo(), null, null,
RefundStatus.NO_REFUND, RefundStatus.FULL_REFUND,
order.getFinalAmount(), "支付回调退款", null)
);
updateRefundStatus(order.getId(), RefundStatus.FULL_REFUND, null, callbackResponse.getTransactionId());
}
log.info("支付回调处理成功: orderId={}, orderNo={}, statusChangeType={}",

View File

@@ -82,6 +82,7 @@ com.ycwl.basic.pricing/
#### API端点
- `POST /api/pricing/calculate` — 执行价格计算(预览模式默认开启)
- `GET /api/pricing/coupons/my-coupons` — 查询用户可用优惠券
- `POST /api/pricing/upgrade-check` — 升单检测:综合已购与待购商品,判断是否命中一口价或打包优惠
#### 计算流程
```java
@@ -588,22 +589,147 @@ public class PriceCalculationResult {
- `GET /api/pricing/admin/one-price/scenic/{scenicId}` — 按景区查询启用配置
- `GET /api/pricing/admin/one-price/check/{scenicId}` — 景区是否适用一口价
## 升单检测功能 (Upgrade Detection)
### 1. 功能概述
升单检测功能是最新新增的功能,用于综合已购商品与待购商品,判断是否满足一口价或打包购买优惠条件,为用户提供购买建议。
### 2. 核心接口
#### IPriceCalculationService 升单检测方法
```java
/**
* 升单检测:综合已购与待购商品,判断是否命中一口价或打包优惠
* @param request 升单检测请求
* @return 升单检测结果
*/
UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request);
```
#### API 端点
- `POST /api/pricing/upgrade-check` — 升单检测接口
### 3. 检测逻辑
#### 请求参数 (UpgradeCheckRequest)
- `scenicId`: 景区ID
- `purchasedProducts`: 已购商品列表
- `intendingProducts`: 待购商品列表
- `paidAmount`: 已支付金额(内部代码传入,前端不必传;**必填**,为空直接报错)
#### 检测流程
1. **商品规范化**: 对已购和待购商品进行规范化处理
2. **价格汇总**: 分别计算已购和待购商品的总价格,并合并已支付金额
3. **一口价评估**: 判断合并商品是否满足一口价条件
4. **打包优惠评估**: 检测是否满足打包购买优惠条件
5. **结果汇总**: 生成升单检测结果和建议(补差价 <= 0 视为不可升单)
#### 响应结果 (UpgradeCheckResult)
- `summary`: 价格汇总信息(包含已支付金额)
- `onePriceResult`: 一口价检测结果(含补差价金额)
- `bundleDiscountResult`: 打包优惠检测结果(含补差价金额)
- `bestUpgradeType`: 最优升单类型(ONE_PRICE / BUNDLE_DISCOUNT)
- `bestPayableAmount`: 最低补差价金额
### 4. 业务价值
#### 用户体验提升
- 为用户提供购买建议,提高客单价
- 自动检测最优购买组合
- 清晰展示升单优惠金额
#### 销售支持
- 促进多商品销售
- 提高打包购买和一口价利用率
- 增加用户购买决策信心
### 5. 使用场景
#### 典型场景
- 用户已购买照片,建议加购视频享受打包优惠
- 用户购买多件商品,建议升级为一口价套餐
- 用户购买数量接近打包优惠门槛,建议增加数量
#### 实现细节
```java
// 升单检测核心逻辑
@Override
public UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request) {
// 1. 参数验证
if (request == null) {
throw new PriceCalculationException("升单检测请求不能为空");
}
// 2. 商品规范化
List<ProductItem> purchased = normalizeProducts(request.getPurchasedProducts());
List<ProductItem> intending = normalizeProducts(request.getIntendingProducts());
// 3. 合并商品列表
List<ProductItem> allProducts = new ArrayList<>();
allProducts.addAll(purchased);
allProducts.addAll(intending);
// 4. 价格计算
PriceDetails purchasedPrice = calculateProductsPriceWithOriginal(purchased);
PriceDetails intendingPrice = calculateProductsPriceWithOriginal(intending);
PriceDetails totalPrice = calculateProductsPrice(allProducts);
// 5. 优惠评估
UpgradeOnePriceResult onePriceResult = evaluateOnePrice(request.getScenicId(), allProducts, totalPrice);
UpgradeBundleDiscountResult bundleResult = evaluateBundleDiscount(request.getScenicId(), allProducts, totalPrice);
// 6. 结果汇总
return buildUpgradeResult(purchasedPrice, intendingPrice, onePriceResult, bundleResult);
}
```
## 测试策略
### 1. 单元测试
建议覆盖:
- 价格计算核心流程与边界
- 优惠券/券码/一口价适用性与叠加规则
- 异常场景与异常处理器
### 单元测试类型
- **服务层测试**:每个服务类都有对应测试类
- `PriceBundleServiceTest` - 套餐价格计算测试
- `ReusableVoucherServiceTest` - 可重复使用券码测试
- `VoucherTimeRangeTest` - 券码时间范围功能测试
- `VoucherPrintServiceCodeGenerationTest` - 券码生成测试
- **实体映射测试**:验证数据库映射和JSON序列化
- `PriceBundleConfigStructureTest` - 实体结构测试
- `PriceBundleConfigJsonTest` - JSON序列化测试
- `CouponSwitchFieldsMappingTest` - 字段映射测试
- **类型处理器测试**:验证自定义TypeHandler
- `BundleProductListTypeHandlerTest` - 套餐商品列表序列化测试
- **配置验证测试**:验证系统配置完整性
- `DefaultConfigValidationTest` - 验证所有ProductType的default配置
- `CodeGenerationStandaloneTest` - 独立代码生成测试
### 2. 集成测试
- 数据库读写与分页
- JSON 序列化/反序列化(TypeHandler)
- API 端点的入参/出参校验
### 测试执行命令
```bash
# 运行单个测试类
mvn test -Dtest=VoucherTimeRangeTest
mvn test -Dtest=ReusableVoucherServiceTest
mvn test -Dtest=BundleProductListTypeHandlerTest
### 3. 配置校验
- 校验各 ProductType 的默认配置完整性
- 关键枚举与配置代码路径的兼容性
# 运行整个pricing模块测试
mvn test -Dtest="com.ycwl.basic.pricing.*Test"
# 运行特定分类的测试
mvn test -Dtest="com.ycwl.basic.pricing.service.*Test" # 服务层测试
mvn test -Dtest="com.ycwl.basic.pricing.handler.*Test" # TypeHandler测试
mvn test -Dtest="com.ycwl.basic.pricing.entity.*Test" # 实体测试
mvn test -Dtest="com.ycwl.basic.pricing.mapper.*Test" # Mapper测试
# 运行带详细报告的测试
mvn test -Dtest="com.ycwl.basic.pricing.*Test" -Dsurefire.printSummary=true
```
### 重点测试场景
- **价格计算核心流程**:验证统一优惠检测和组合逻辑
- **可重复使用券码**:验证多次使用、时间间隔、用户限制逻辑
- **时间范围控制**:验证券码有效期开始和结束时间
- **优惠叠加规则**:验证券码、优惠券、一口价的叠加逻辑
- **JSON序列化**:验证复杂对象在数据库中的存储和读取
- **分页功能**:验证PageHelper和MyBatis-Plus分页集成
- **异常处理**:验证业务异常和全局异常处理器
## 数据库设计
@@ -665,11 +791,20 @@ CREATE INDEX idx_print_face_scenic ON voucher_print_record(face_id, scenic_id);
- 使用数据完整性检查 SQL 验证统计数据准确性
- **优惠券领取记录表查询优化** (v1.0.0): 为 `(user_id, coupon_id)` 添加复合索引以加速用户领取次数统计
### 关键架构变更
#### 最近重要更新 (2025-09-18)
1. **新增升单检测功能** - 添加了`/api/pricing/upgrade-check`接口,支持已购和待购商品的优惠组合检测
2. **新增打包购买优惠功能** - 实现了多商品组合优惠策略,优先级100(仅次于一口价)
3. **优惠优先级调整** - 确立了"一口价 > 打包购买 > 券码 > 优惠券"的优先级顺序
4. **PrinterServiceImpl重构** - 移除对PriceRepository的依赖,统一使用IPriceCalculationService
## 兼容性与注意事项
- 本模块使用 PageHelper(优惠券相关)与 MyBatis‑Plus(券码/一口价等)并存,请根据对应 Service/Mapper 选择分页与查询方式。
- 优惠优先级及叠加规则以各 Provider 与业务配置为准,避免在外层重复实现优先级判断逻辑。
- 若扩展新的优惠类型,务必实现 `IDiscountProvider` 并在 `IDiscountDetectionService` 中完成注册(当前实现通过组件扫描自动注册并排序)。
- 升单检测功能依赖完整的价格计算和优惠检测服务,确保相关依赖正常注入。
- **优惠券数量管理** (v1.0.0): 现有代码已调整为领取时更新 `claimedQuantity`,使用时更新 `usedQuantity`。如业务需求不同,请调整 `CouponServiceImpl.claimCoupon()``CouponServiceImpl.useCoupon()` 逻辑。
## 版本更新记录

View File

@@ -1,7 +1,9 @@
package com.ycwl.basic.pricing.controller;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.pricing.dto.*;
import com.ycwl.basic.pricing.dto.resp.UserCouponResp;
import com.ycwl.basic.pricing.service.ICouponService;
import com.ycwl.basic.pricing.service.IPriceCalculationService;
import lombok.RequiredArgsConstructor;
@@ -39,16 +41,47 @@ public class PriceCalculationController {
}
/**
* 查询用户可用优惠
* 升单检测:判断是否命中一口价或打包优惠
*/
@PostMapping("/upgrade-check")
public ApiResponse<UpgradeCheckResult> upgradeCheck(@RequestBody UpgradeCheckRequest request) {
log.info("升单检测请求: scenicId={}, purchased={}, intending={}",
request.getScenicId(),
request.getPurchasedProducts() != null ? request.getPurchasedProducts().size() : 0,
request.getIntendingProducts() != null ? request.getIntendingProducts().size() : 0);
UpgradeCheckResult result = priceCalculationService.checkUpgrade(request);
return ApiResponse.success(result);
}
/**
* 查询用户可用优惠券(包含领取记录信息)
*/
@GetMapping("/coupons/my-coupons")
public ApiResponse<List<CouponInfo>> getUserCoupons(@RequestParam Long userId) {
log.info("查询用户可用优惠券: userId={}", userId);
public ApiResponse<List<UserCouponResp>> getUserCoupons() {
Long userId = getUserId();
if (userId == null) {
return ApiResponse.fail("用户未登录");
}
List<CouponInfo> coupons = couponService.getUserAvailableCoupons(userId);
log.info("用户可用优惠券数量: {}", coupons.size());
List<UserCouponResp> coupons = couponService.getUserAvailableCoupons(userId);
return ApiResponse.success(coupons);
}
/**
* 获取当前登录用户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

@@ -60,13 +60,7 @@ public class SceneCouponClaimController {
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);

View File

@@ -5,6 +5,7 @@ import com.ycwl.basic.pricing.entity.PriceCouponConfig;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
* 优惠券领取结果DTO
@@ -58,6 +59,11 @@ public class CouponClaimResult {
private String scenicId;
private PriceCouponConfig coupon;
/**
* 已领取的记录列表(领取失败时返回,帮助前端展示用户已有的券)
*/
private List<PriceCouponClaimRecord> claimedRecords;
/**
* 创建成功结果
*/
@@ -85,6 +91,23 @@ public class CouponClaimResult {
return result;
}
/**
* 创建失败结果(带已领取的券列表)
*/
public static CouponClaimResult failureWithClaimedRecords(String errorCode, String errorMessage,
List<PriceCouponClaimRecord> claimedRecords,
PriceCouponConfig coupon) {
CouponClaimResult result = new CouponClaimResult();
result.success = false;
result.errorCode = errorCode;
result.errorMessage = errorMessage;
result.claimedRecords = claimedRecords;
result.coupon = coupon;
result.couponId = coupon != null ? coupon.getId() : null;
result.couponName = coupon != null ? coupon.getCouponName() : null;
return result;
}
/**
* 创建失败结果(仅错误消息)
*/

View File

@@ -55,4 +55,15 @@ public class PriceCalculationResult {
* 商品明细列表
*/
private List<ProductItem> productDetails;
/**
* 是否已购买标识(结合商品重复购买策略判断)
* true: 至少有一个不允许重复购买的商品(DuplicateCheckStrategy != NO_CHECK)的 quantity > 0
* false: 所有不允许重复购买的商品的 quantity 都为 0 或 null
*
* 说明:
* - 对于允许重复购买的商品(如打印类,策略为 NO_CHECK),即使 quantity > 0 也不影响此标识
* - 对于需要检查的商品(UNIQUE_RESOURCE、PARENT_RESOURCE),quantity > 0 表示已购买
*/
private Boolean isPurchased;
}

View File

@@ -56,4 +56,12 @@ public class ProductItem {
* 商品属性Key列表(服务端计算填充,客户端传入会被忽略)
*/
private List<String> attributeKeys;
/**
* 是否已购买(服务端填充)
* 结合 DuplicateCheckStrategy 判断:
* - NO_CHECK: 始终为 false(允许重复购买)
* - 其他策略: 基于用户已有资源判断
*/
private Boolean hasPurchased;
}

View File

@@ -0,0 +1,62 @@
package com.ycwl.basic.pricing.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 升单检测打包优惠结果
*/
@Data
public class UpgradeBundleDiscountResult {
/**
* 是否命中打包优惠
*/
private boolean applicable;
/**
* 打包配置ID
*/
private Long bundleConfigId;
/**
* 打包优惠名称
*/
private String bundleName;
/**
* 打包优惠描述
*/
private String bundleDescription;
/**
* 优惠类型
*/
private String discountType;
/**
* 优惠值
*/
private BigDecimal discountValue;
/**
* 实际优惠金额
*/
private BigDecimal discountAmount;
/**
* 满足条件的最少数量
*/
private Integer minQuantity;
/**
* 满足条件的最少金额
*/
private BigDecimal minAmount;
/**
* 使用优惠后的补差价金额(已支付金额已扣减)
*/
private BigDecimal estimatedFinalAmount;
}

View File

@@ -0,0 +1,38 @@
package com.ycwl.basic.pricing.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 升单检测请求
*/
@Data
public class UpgradeCheckRequest {
/**
* 景区ID
*/
private Long scenicId;
/**
* 用户faceId
*/
private Long faceId;
/**
* 已购买商品列表
*/
private List<ProductItem> purchasedProducts;
/**
* 准备购买的商品列表
*/
private List<ProductItem> intendingProducts;
/**
* 已支付金额(内部代码传入,前端不必传)
*/
private BigDecimal paidAmount;
}

View File

@@ -0,0 +1,58 @@
package com.ycwl.basic.pricing.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 升单检测结果
*/
@Data
public class UpgradeCheckResult {
/**
* 景区ID
*/
private Long scenicId;
/**
* 用户faceId
*/
private Long faceId;
/**
* 价格汇总信息
*/
private UpgradePriceSummary priceSummary;
/**
* 一口价检测结果
*/
private UpgradeOnePriceResult onePriceResult;
/**
* 打包优惠检测结果
*/
private UpgradeBundleDiscountResult bundleDiscountResult;
/**
* 最优升单类型(ONE_PRICE / BUNDLE_DISCOUNT)
*/
private String bestUpgradeType;
/**
* 最低补差价金额
*/
private BigDecimal bestPayableAmount;
/**
* 已购买商品明细(带计算价)
*/
private List<ProductItem> purchasedProducts;
/**
* 计划购买商品明细(带计算价)
*/
private List<ProductItem> intendingProducts;
}

View File

@@ -0,0 +1,52 @@
package com.ycwl.basic.pricing.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 升单检测一口价结果
*/
@Data
public class UpgradeOnePriceResult {
/**
* 是否命中一口价规则
*/
private boolean applicable;
/**
* 一口价配置ID
*/
private Long bundleConfigId;
/**
* 一口价名称
*/
private String bundleName;
/**
* 一口价描述
*/
private String description;
/**
* 适用景区ID
*/
private String scenicId;
/**
* 一口价金额
*/
private BigDecimal bundlePrice;
/**
* 优惠金额(合并小计 - 一口价金额)
*/
private BigDecimal discountAmount;
/**
* 使用一口价后的补差价金额(已支付金额已扣减)
*/
private BigDecimal estimatedFinalAmount;
}

View File

@@ -0,0 +1,47 @@
package com.ycwl.basic.pricing.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 升单检测价格汇总
*/
@Data
public class UpgradePriceSummary {
/**
* 已购买原价合计
*/
private BigDecimal purchasedOriginalAmount = BigDecimal.ZERO;
/**
* 已购买小计金额
*/
private BigDecimal purchasedSubtotalAmount = BigDecimal.ZERO;
/**
* 已支付金额(用于升单补差)
*/
private BigDecimal paidAmount = BigDecimal.ZERO;
/**
* 计划购买原价合计
*/
private BigDecimal intendingOriginalAmount = BigDecimal.ZERO;
/**
* 计划购买小计金额
*/
private BigDecimal intendingSubtotalAmount = BigDecimal.ZERO;
/**
* 合并后的原价合计
*/
private BigDecimal combinedOriginalAmount = BigDecimal.ZERO;
/**
* 合并后的小计金额
*/
private BigDecimal combinedSubtotalAmount = BigDecimal.ZERO;
}

View File

@@ -0,0 +1,89 @@
package com.ycwl.basic.pricing.dto.resp;
import com.ycwl.basic.pricing.enums.CouponStatus;
import com.ycwl.basic.pricing.enums.CouponType;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 用户优惠券响应DTO(包含领取记录+优惠券配置)
*/
@Data
public class UserCouponResp {
// ==================== 领取记录信息 ====================
/**
* 领取记录ID
*/
private Long claimRecordId;
/**
* 领取时间
*/
private Date claimTime;
/**
* 过期时间(根据领取时间+领取后有效期计算)
*/
private Date expireTime;
/**
* 优惠券状态
*/
private CouponStatus status;
/**
* 领取时的景区ID
*/
private String claimScenicId;
// ==================== 优惠券配置信息 ====================
/**
* 优惠券ID
*/
private Long couponId;
/**
* 优惠券名称
*/
private String couponName;
/**
* 优惠类型
*/
private CouponType couponType;
/**
* 优惠值(百分比时为折扣比例,固定金额时为具体金额)
*/
private BigDecimal discountValue;
/**
* 最小使用金额(门槛)
*/
private BigDecimal minAmount;
/**
* 最大优惠金额
*/
private BigDecimal maxDiscount;
/**
* 适用景区ID(NULL表示不限景区)
*/
private String scenicId;
/**
* 优惠券全局有效期开始时间
*/
private Date validFrom;
/**
* 优惠券全局有效期结束时间
*/
private Date validUntil;
}

View File

@@ -34,6 +34,12 @@ public class PriceCouponClaimRecord {
*/
private Date claimTime;
/**
* 过期时间(根据领取时间+领取后有效期计算)
*/
@TableField("expire_time")
private Date expireTime;
/**
* 使用时间
*/

View File

@@ -86,6 +86,11 @@ public class PriceCouponConfig {
*/
private Date validUntil;
/**
* 领取后有效天数(NULL表示不限制,仅使用全局有效期)
*/
private Integer validDaysAfterClaim;
/**
* 是否启用
*/

View File

@@ -25,7 +25,8 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper<PriceCouponClai
"FROM price_coupon_claim_record r " +
"JOIN price_coupon_config c ON r.coupon_id = c.id " +
"WHERE r.user_id = #{userId} AND r.status = 'CLAIMED' " +
"AND c.is_active = 1 AND c.valid_from <= NOW() AND c.valid_until > NOW()")
"AND c.is_active = 1 AND c.valid_from <= NOW() AND c.valid_until > NOW() " +
"AND (r.expire_time IS NULL OR r.expire_time > NOW())")
List<PriceCouponClaimRecord> selectUserAvailableCoupons(Long userId);
/**
@@ -33,7 +34,7 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper<PriceCouponClai
*/
@Select("SELECT * FROM price_coupon_claim_record " +
"WHERE user_id = #{userId} AND coupon_id = #{couponId}")
PriceCouponClaimRecord selectUserCouponRecord(@Param("userId") Long userId,
List<PriceCouponClaimRecord> selectUserCouponRecords(@Param("userId") Long userId,
@Param("couponId") Long couponId);
/**
@@ -58,8 +59,8 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper<PriceCouponClai
/**
* 插入优惠券领用记录
*/
@Insert("INSERT INTO price_coupon_claim_record (coupon_id, user_id, claim_time, status, scenic_id, create_time, update_time) " +
"VALUES (#{couponId}, #{userId}, NOW(), #{status}, #{scenicId}, NOW(), NOW())")
@Insert("INSERT INTO price_coupon_claim_record (coupon_id, user_id, claim_time, expire_time, status, scenic_id, create_time, update_time) " +
"VALUES (#{couponId}, #{userId}, NOW(), #{expireTime}, #{status}, #{scenicId}, NOW(), NOW())")
int insertClaimRecord(PriceCouponClaimRecord record);
/**

View File

@@ -6,6 +6,7 @@ import com.ycwl.basic.pricing.dto.CouponUseResult;
import com.ycwl.basic.pricing.dto.CouponClaimRequest;
import com.ycwl.basic.pricing.dto.CouponClaimResult;
import com.ycwl.basic.pricing.dto.ProductItem;
import com.ycwl.basic.pricing.dto.resp.UserCouponResp;
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
import java.math.BigDecimal;
@@ -55,12 +56,12 @@ public interface ICouponService {
CouponUseResult useCoupon(CouponUseRequest request);
/**
* 查询用户可用优惠券
* 查询用户可用优惠券(包含领取记录信息)
*
* @param userId 用户ID
* @return 用优惠券列表
* @return 用优惠券列表(包含领取记录+优惠券配置)
*/
List<CouponInfo> getUserAvailableCoupons(Long userId);
List<UserCouponResp> getUserAvailableCoupons(Long userId);
/**
* 领取优惠券(内部调用方法)

View File

@@ -2,6 +2,8 @@ package com.ycwl.basic.pricing.service;
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
import com.ycwl.basic.pricing.dto.UpgradeCheckRequest;
import com.ycwl.basic.pricing.dto.UpgradeCheckResult;
/**
* 价格计算服务接口
@@ -15,4 +17,12 @@ public interface IPriceCalculationService {
* @return 价格计算结果
*/
PriceCalculationResult calculatePrice(PriceCalculationRequest request);
/**
* 升单检测:综合已购与待购商品,判断是否命中一口价或打包优惠
*
* @param request 升单检测请求
* @return 检测结果
*/
UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request);
}

View File

@@ -54,12 +54,14 @@ public class AutoCouponServiceImpl implements IAutoCouponService {
for (Long couponId : couponIds) {
try {
// 检查用户是否已领取过该券(领券即消耗首次资格)
PriceCouponClaimRecord existingRecord = couponClaimRecordMapper.selectUserCouponRecord(
List<PriceCouponClaimRecord> existingRecords = couponClaimRecordMapper.selectUserCouponRecords(
memberId,
couponId
);
if (existingRecord != null) {
if (existingRecords != null && !existingRecords.isEmpty()) {
// 只要有记录(无论状态),都算已领取过
PriceCouponClaimRecord existingRecord = existingRecords.get(0);
log.debug("用户已领取过优惠券,跳过: memberId={}, couponId={}, claimTime={}",
memberId, couponId, existingRecord.getClaimTime());
skipCount++;

View File

@@ -3,6 +3,7 @@ package com.ycwl.basic.pricing.service.impl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ycwl.basic.pricing.dto.*;
import com.ycwl.basic.pricing.dto.resp.UserCouponResp;
import com.ycwl.basic.pricing.entity.PriceCouponClaimRecord;
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
import com.ycwl.basic.pricing.enums.CouponStatus;
@@ -223,15 +224,23 @@ public class CouponServiceImpl implements ICouponService {
@Override
@Transactional
public CouponUseResult useCoupon(CouponUseRequest request) {
PriceCouponClaimRecord record = couponClaimRecordMapper.selectUserCouponRecord(
List<PriceCouponClaimRecord> records = couponClaimRecordMapper.selectUserCouponRecords(
request.getUserId(), request.getCouponId());
if (record == null) {
if (records == null || records.isEmpty()) {
throw new CouponInvalidException("用户未拥有该优惠券");
}
if (record.getStatus() != CouponStatus.CLAIMED) {
throw new CouponInvalidException("优惠券状态无效: " + record.getStatus());
// 查找一张可用的优惠券(状态为CLAIMED)
PriceCouponClaimRecord record = records.stream()
.filter(r -> r.getStatus() == CouponStatus.CLAIMED)
.findFirst()
.orElse(null);
if (record == null) {
// 如果没有可用的,抛出异常。为了错误信息准确,可以检查最后一张的状态
CouponStatus lastStatus = records.getFirst().getStatus();
throw new CouponInvalidException("优惠券状态无效: " + lastStatus);
}
int updateCount = couponConfigMapper.incrementUsedQuantity(request.getCouponId());
@@ -266,20 +275,44 @@ public class CouponServiceImpl implements ICouponService {
}
@Override
public List<CouponInfo> getUserAvailableCoupons(Long userId) {
public List<UserCouponResp> getUserAvailableCoupons(Long userId) {
List<PriceCouponClaimRecord> records = couponClaimRecordMapper.selectUserAvailableCoupons(userId);
List<CouponInfo> coupons = new ArrayList<>();
List<UserCouponResp> coupons = new ArrayList<>();
for (PriceCouponClaimRecord record : records) {
PriceCouponConfig config = couponConfigMapper.selectById(record.getCouponId());
if (config != null) {
coupons.add(buildCouponInfo(config, null));
coupons.add(buildUserCouponResp(record, config));
}
}
return coupons;
}
private UserCouponResp buildUserCouponResp(PriceCouponClaimRecord record, PriceCouponConfig config) {
UserCouponResp resp = new UserCouponResp();
// 领取记录信息
resp.setClaimRecordId(record.getId());
resp.setClaimTime(record.getClaimTime());
resp.setExpireTime(record.getExpireTime());
resp.setStatus(record.getStatus());
resp.setClaimScenicId(record.getScenicId());
// 优惠券配置信息
resp.setCouponId(config.getId());
resp.setCouponName(config.getCouponName());
resp.setCouponType(config.getCouponType());
resp.setDiscountValue(config.getDiscountValue());
resp.setMinAmount(config.getMinAmount());
resp.setMaxDiscount(config.getMaxDiscount());
resp.setScenicId(config.getScenicId());
resp.setValidFrom(config.getValidFrom());
resp.setValidUntil(config.getValidUntil());
return resp;
}
private CouponInfo buildCouponInfo(PriceCouponConfig coupon, BigDecimal actualDiscountAmount) {
CouponInfo info = new CouponInfo();
info.setCouponId(coupon.getId());
@@ -338,9 +371,14 @@ public class CouponServiceImpl implements ICouponService {
request.getUserId(), request.getCouponId());
// countUserCouponClaims 使用 FOR UPDATE + 复合索引,确保并发下的计数准确
if (userClaimCount >= coupon.getUserClaimLimit()) {
return CouponClaimResult.failure(
// 查询用户已领取的记录,返回给前端展示
List<PriceCouponClaimRecord> claimedRecords = couponClaimRecordMapper.selectUserCouponRecords(
request.getUserId(), request.getCouponId());
return CouponClaimResult.failureWithClaimedRecords(
CouponClaimResult.ERROR_CLAIM_LIMIT_REACHED,
"您已达到该优惠券的领取上限(" + coupon.getUserClaimLimit() + "张)");
"您已达到该优惠券的领取上限(" + coupon.getUserClaimLimit() + "张)",
claimedRecords,
coupon);
}
}
@@ -350,6 +388,15 @@ public class CouponServiceImpl implements ICouponService {
claimRecord.setCouponId(request.getCouponId());
claimRecord.setUserId(request.getUserId());
claimRecord.setClaimTime(claimTime);
// 如果配置了领取后有效天数,计算过期时间
if (coupon.getValidDaysAfterClaim() != null && coupon.getValidDaysAfterClaim() > 0) {
java.util.Calendar calendar = java.util.Calendar.getInstance();
calendar.setTime(claimTime);
calendar.add(java.util.Calendar.DAY_OF_MONTH, coupon.getValidDaysAfterClaim());
claimRecord.setExpireTime(calendar.getTime());
}
claimRecord.setStatus(CouponStatus.CLAIMED);
claimRecord.setScenicId(request.getScenicId());
claimRecord.setCreateTime(claimTime);

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.pricing.service.impl;
import com.ycwl.basic.pricing.dto.*;
import com.ycwl.basic.pricing.entity.PriceBundleConfig;
import com.ycwl.basic.pricing.entity.PriceProductConfig;
import com.ycwl.basic.pricing.entity.PriceTierConfig;
import com.ycwl.basic.pricing.enums.ProductType;
@@ -32,10 +33,14 @@ import java.util.Set;
public class PriceCalculationServiceImpl implements IPriceCalculationService {
private static final String CAPABILITY_METADATA_ATTRIBUTE_KEYS = "pricingAttributeKeys";
private static final int AMOUNT_SCALE = 2;
private static final String UPGRADE_TYPE_ONE_PRICE = "ONE_PRICE";
private static final String UPGRADE_TYPE_BUNDLE_DISCOUNT = "BUNDLE_DISCOUNT";
private final IProductConfigService productConfigService;
private final ICouponService couponService;
private final IPriceBundleService bundleService;
private final IBundleDiscountService bundleDiscountService;
private final IDiscountDetectionService discountDetectionService;
private final IVoucherService voucherService;
private final IProductTypeCapabilityService productTypeCapabilityService;
@@ -159,6 +164,54 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
return result;
}
@Override
public UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request) {
if (request == null) {
throw new PriceCalculationException("升单检测请求不能为空");
}
List<ProductItem> purchasedProducts = cloneProducts(request.getPurchasedProducts());
List<ProductItem> intendingProducts = cloneProducts(request.getIntendingProducts());
if (purchasedProducts.isEmpty() && intendingProducts.isEmpty()) {
throw new PriceCalculationException("已购和待购商品列表不能同时为空");
}
normalizeProducts(purchasedProducts);
normalizeProducts(intendingProducts);
Long scenicId = request.getScenicId();
PriceDetails purchasedDetails = purchasedProducts.isEmpty()
? new PriceDetails(BigDecimal.ZERO, BigDecimal.ZERO)
: calculateProductsPriceWithOriginal(purchasedProducts, scenicId);
PriceDetails intendingDetails = intendingProducts.isEmpty()
? new PriceDetails(BigDecimal.ZERO, BigDecimal.ZERO)
: calculateProductsPriceWithOriginal(intendingProducts, scenicId);
List<ProductItem> combinedProducts = new ArrayList<>();
combinedProducts.addAll(purchasedProducts);
combinedProducts.addAll(intendingProducts);
PriceDetails combinedDetails = calculateProductsPriceWithOriginal(combinedProducts, scenicId);
BigDecimal paidAmount = resolvePaidAmount(request);
BigDecimal currentTotalAmount = calculateCurrentTotalAmount(paidAmount, intendingDetails);
UpgradePriceSummary priceSummary = buildPriceSummary(purchasedDetails, intendingDetails, combinedDetails, paidAmount);
UpgradeOnePriceResult onePriceResult = evaluateOnePrice(scenicId, combinedProducts, combinedDetails, paidAmount, currentTotalAmount);
UpgradeBundleDiscountResult bundleDiscountResult = evaluateBundleDiscount(scenicId, combinedProducts, combinedDetails, paidAmount, currentTotalAmount);
UpgradeCheckResult result = new UpgradeCheckResult();
result.setScenicId(scenicId);
result.setFaceId(request.getFaceId());
result.setPriceSummary(priceSummary);
result.setOnePriceResult(onePriceResult);
result.setBundleDiscountResult(bundleDiscountResult);
fillBestUpgrade(result, onePriceResult, bundleDiscountResult);
result.setPurchasedProducts(purchasedProducts);
result.setIntendingProducts(intendingProducts);
return result;
}
private BigDecimal calculateProductsPrice(List<ProductItem> products) {
BigDecimal totalAmount = BigDecimal.ZERO;
@@ -390,6 +443,229 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
return new ProductPriceInfo(actualPrice, originalPrice);
}
private UpgradePriceSummary buildPriceSummary(PriceDetails purchased, PriceDetails intending, PriceDetails combined, BigDecimal paidAmount) {
UpgradePriceSummary summary = new UpgradePriceSummary();
summary.setPurchasedOriginalAmount(purchased.getOriginalTotalAmount());
summary.setPurchasedSubtotalAmount(purchased.getTotalAmount());
summary.setIntendingOriginalAmount(intending.getOriginalTotalAmount());
summary.setIntendingSubtotalAmount(intending.getTotalAmount());
summary.setCombinedOriginalAmount(combined.getOriginalTotalAmount());
summary.setCombinedSubtotalAmount(combined.getTotalAmount());
summary.setPaidAmount(paidAmount);
return summary;
}
private UpgradeOnePriceResult evaluateOnePrice(Long scenicId,
List<ProductItem> combinedProducts,
PriceDetails combinedDetails,
BigDecimal paidAmount,
BigDecimal currentTotalAmount) {
UpgradeOnePriceResult result = new UpgradeOnePriceResult();
result.setApplicable(false);
PriceBundleConfig bundleConfig = bundleService.getBundleConfig(combinedProducts);
if (bundleConfig == null || !matchesScenic(bundleConfig.getScenicId(), scenicId)) {
return result;
}
BigDecimal bundlePrice = bundleConfig.getBundlePrice() != null
? bundleConfig.getBundlePrice()
: combinedDetails.getTotalAmount();
BigDecimal normalizedBundlePrice = normalizeAmount(bundlePrice);
BigDecimal normalizedCurrentTotal = normalizeAmount(currentTotalAmount);
BigDecimal normalizedPaidAmount = normalizeAmount(paidAmount);
if (!isUpgradeBeneficial(normalizedCurrentTotal, normalizedBundlePrice)) {
return result;
}
BigDecimal discountAmount = normalizedCurrentTotal.subtract(normalizedBundlePrice);
BigDecimal supplementAmount = calculateSupplementAmount(normalizedBundlePrice, normalizedPaidAmount);
if (supplementAmount.compareTo(BigDecimal.ZERO) <= 0) {
return result;
}
result.setApplicable(true);
result.setBundleConfigId(bundleConfig.getId());
result.setBundleName(bundleConfig.getBundleName());
result.setDescription(bundleConfig.getDescription());
result.setScenicId(bundleConfig.getScenicId());
result.setBundlePrice(normalizedBundlePrice);
result.setDiscountAmount(discountAmount);
result.setEstimatedFinalAmount(supplementAmount);
return result;
}
private UpgradeBundleDiscountResult evaluateBundleDiscount(Long scenicId,
List<ProductItem> combinedProducts,
PriceDetails combinedDetails,
BigDecimal paidAmount,
BigDecimal currentTotalAmount) {
UpgradeBundleDiscountResult result = new UpgradeBundleDiscountResult();
result.setApplicable(false);
BundleDiscountInfo bestDiscount = bundleDiscountService.getBestBundleDiscount(combinedProducts, scenicId);
if (bestDiscount == null) {
return result;
}
BigDecimal discountAmount = bestDiscount.getActualDiscountAmount();
if (discountAmount == null || discountAmount.compareTo(BigDecimal.ZERO) <= 0) {
discountAmount = bundleDiscountService.calculateBundleDiscount(bestDiscount, combinedProducts);
}
if (discountAmount == null || discountAmount.compareTo(BigDecimal.ZERO) <= 0) {
return result;
}
BigDecimal normalizedPaidAmount = normalizeAmount(paidAmount);
BigDecimal normalizedCurrentTotal = normalizeAmount(currentTotalAmount);
BigDecimal normalizedDiscount = normalizeAmount(discountAmount);
BigDecimal targetTotal = combinedDetails.getTotalAmount().subtract(normalizedDiscount);
if (targetTotal.compareTo(BigDecimal.ZERO) < 0) {
targetTotal = BigDecimal.ZERO;
}
if (!isUpgradeBeneficial(normalizedCurrentTotal, targetTotal)) {
return result;
}
BigDecimal supplementAmount = calculateSupplementAmount(targetTotal, normalizedPaidAmount);
if (supplementAmount.compareTo(BigDecimal.ZERO) <= 0) {
return result;
}
result.setApplicable(true);
result.setBundleConfigId(bestDiscount.getBundleConfigId());
result.setBundleName(bestDiscount.getBundleName());
result.setBundleDescription(bestDiscount.getBundleDescription());
result.setDiscountType(bestDiscount.getDiscountType());
result.setDiscountValue(bestDiscount.getDiscountValue());
result.setDiscountAmount(normalizedDiscount);
result.setMinQuantity(bestDiscount.getMinQuantity());
result.setMinAmount(bestDiscount.getMinAmount());
result.setEstimatedFinalAmount(supplementAmount);
return result;
}
private List<ProductItem> cloneProducts(List<ProductItem> source) {
List<ProductItem> copies = new ArrayList<>();
if (source == null) {
return copies;
}
for (ProductItem item : source) {
if (item == null) {
continue;
}
copies.add(cloneProductItem(item));
}
return copies;
}
private ProductItem cloneProductItem(ProductItem source) {
ProductItem copy = new ProductItem();
copy.setProductType(source.getProductType());
copy.setProductId(source.getProductId());
copy.setQuantity(source.getQuantity());
copy.setPurchaseCount(source.getPurchaseCount());
copy.setOriginalPrice(source.getOriginalPrice());
copy.setUnitPrice(source.getUnitPrice());
copy.setSubtotal(source.getSubtotal());
copy.setScenicId(source.getScenicId());
return copy;
}
private void normalizeProducts(List<ProductItem> products) {
for (ProductItem product : products) {
if (product.getProductType() == null) {
throw new PriceCalculationException("商品类型不能为空");
}
if (product.getProductId() == null) {
throw new PriceCalculationException("商品ID不能为空");
}
if (product.getPurchaseCount() == null) {
product.setPurchaseCount(1);
}
if (product.getQuantity() == null) {
product.setQuantity(1);
}
}
}
private boolean matchesScenic(String configScenicId, Long scenicId) {
if (scenicId == null) {
return true;
}
if (configScenicId == null || configScenicId.isEmpty()) {
return true;
}
return configScenicId.equals(String.valueOf(scenicId));
}
private BigDecimal resolvePaidAmount(UpgradeCheckRequest request) {
BigDecimal paidAmount = request != null ? request.getPaidAmount() : null;
if (paidAmount == null) {
throw new PriceCalculationException("已支付金额不能为空");
}
return normalizeAmount(paidAmount);
}
private BigDecimal calculateCurrentTotalAmount(BigDecimal paidAmount, PriceDetails intendingDetails) {
BigDecimal intendingAmount = intendingDetails != null ? intendingDetails.getTotalAmount() : BigDecimal.ZERO;
BigDecimal normalizedPaidAmount = normalizeAmount(paidAmount);
BigDecimal normalizedIntendingAmount = normalizeAmount(intendingAmount);
return normalizedPaidAmount.add(normalizedIntendingAmount).setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
}
private BigDecimal calculateSupplementAmount(BigDecimal targetTotalAmount, BigDecimal paidAmount) {
BigDecimal normalizedTarget = normalizeAmount(targetTotalAmount);
BigDecimal normalizedPaid = normalizeAmount(paidAmount);
BigDecimal supplementAmount = normalizedTarget.subtract(normalizedPaid);
if (supplementAmount.compareTo(BigDecimal.ZERO) < 0) {
supplementAmount = BigDecimal.ZERO;
}
return supplementAmount.setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
}
private boolean isUpgradeBeneficial(BigDecimal currentTotalAmount, BigDecimal targetTotalAmount) {
BigDecimal normalizedCurrent = normalizeAmount(currentTotalAmount);
BigDecimal normalizedTarget = normalizeAmount(targetTotalAmount);
return normalizedCurrent.compareTo(normalizedTarget) > 0;
}
private BigDecimal normalizeAmount(BigDecimal amount) {
if (amount == null) {
return BigDecimal.ZERO.setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
}
BigDecimal normalized = amount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : amount;
return normalized.setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
}
private void fillBestUpgrade(UpgradeCheckResult result,
UpgradeOnePriceResult onePriceResult,
UpgradeBundleDiscountResult bundleDiscountResult) {
if (result == null) {
return;
}
BigDecimal bestPayableAmount = null;
String bestUpgradeType = null;
if (onePriceResult != null && onePriceResult.isApplicable()) {
bestPayableAmount = onePriceResult.getEstimatedFinalAmount();
bestUpgradeType = UPGRADE_TYPE_ONE_PRICE;
}
if (bundleDiscountResult != null && bundleDiscountResult.isApplicable()) {
BigDecimal bundlePayableAmount = bundleDiscountResult.getEstimatedFinalAmount();
if (bestPayableAmount == null || (bundlePayableAmount != null
&& bundlePayableAmount.compareTo(bestPayableAmount) < 0)) {
bestPayableAmount = bundlePayableAmount;
bestUpgradeType = UPGRADE_TYPE_BUNDLE_DISCOUNT;
}
}
result.setBestPayableAmount(bestPayableAmount);
result.setBestUpgradeType(bestUpgradeType);
}
/**
* 计算优惠(券码 + 优惠券)
*/

View File

@@ -1,219 +0,0 @@
package com.ycwl.basic.puzzle.element.base;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.awt.*;
/**
* 元素抽象基类
* 定义所有Element的通用行为和属性
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
@Data
public abstract class BaseElement {
/**
* 元素ID
*/
protected Long id;
/**
* 元素类型
*/
protected ElementType elementType;
/**
* 元素标识(用于动态数据映射)
*/
protected String elementKey;
/**
* 元素名称(便于管理识别)
*/
protected String elementName;
/**
* 位置信息
*/
protected Position position;
/**
* JSON配置字符串(原始)
*/
protected String configJson;
/**
* 解析后的配置对象(子类特定)
*/
protected ElementConfig config;
// ========== 抽象方法(子类必须实现) ==========
/**
* 加载并解析JSON配置
* 子类需要将configJson解析为具体的Config对象
*
* @param configJson JSON配置字符串
*/
public abstract void loadConfig(String configJson);
/**
* 验证元素配置是否合法
*
* @throws ElementValidationException 配置不合法时抛出
*/
public abstract void validate() throws ElementValidationException;
/**
* 渲染元素到画布
* 这是元素的核心方法,负责将元素绘制到Graphics2D上
*
* @param context 渲染上下文
*/
public abstract void render(RenderContext context);
/**
* 获取配置的JSON Schema或说明
*
* @return 配置说明
*/
public abstract String getConfigSchema();
// ========== 通用方法 ==========
/**
* 初始化元素(加载配置并验证)
* 在创建Element实例后必须调用此方法
*/
public void initialize() {
if (StrUtil.isNotBlank(configJson)) {
loadConfig(configJson);
}
validate();
}
/**
* 应用透明度
* 如果元素有透明度设置,则应用到Graphics2D上
*
* @param g2d Graphics2D对象
* @return 原始的Composite对象(用于恢复)
*/
protected Composite applyOpacity(Graphics2D g2d) {
Composite originalComposite = g2d.getComposite();
if (position != null && position.hasOpacity()) {
g2d.setComposite(AlphaComposite.getInstance(
AlphaComposite.SRC_OVER,
position.getOpacityFloat()
));
}
return originalComposite;
}
/**
* 恢复透明度
*
* @param g2d Graphics2D对象
* @param originalComposite 原始的Composite对象
*/
protected void restoreOpacity(Graphics2D g2d, Composite originalComposite) {
if (originalComposite != null) {
g2d.setComposite(originalComposite);
}
}
/**
* 应用旋转
* 如果元素有旋转设置,则应用到Graphics2D上
*
* @param g2d Graphics2D对象
*/
protected void applyRotation(Graphics2D g2d) {
if (position != null && position.hasRotation()) {
// 以元素中心点为旋转中心
int centerX = position.getX() + position.getWidth() / 2;
int centerY = position.getY() + position.getHeight() / 2;
g2d.rotate(position.getRotationRadians(), centerX, centerY);
}
}
/**
* 解析颜色字符串(支持hex格式)
*
* @param colorStr 颜色字符串(如#FFFFFF)
* @return Color对象
*/
protected Color parseColor(String colorStr) {
if (StrUtil.isBlank(colorStr)) {
return Color.BLACK;
}
try {
// 移除#号
String hex = colorStr.startsWith("#") ? colorStr.substring(1) : colorStr;
// 解析RGB
return new Color(
Integer.valueOf(hex.substring(0, 2), 16),
Integer.valueOf(hex.substring(2, 4), 16),
Integer.valueOf(hex.substring(4, 6), 16)
);
} catch (Exception e) {
log.warn("颜色解析失败: {}, 使用默认黑色", colorStr);
return Color.BLACK;
}
}
/**
* 安全解析JSON配置
*
* @param configJson JSON字符串
* @param configClass 配置类
* @param <T> 配置类型
* @return 配置对象
*/
protected <T extends ElementConfig> T parseConfig(String configJson, Class<T> configClass) {
try {
if (StrUtil.isBlank(configJson)) {
// 返回默认实例
return configClass.getDeclaredConstructor().newInstance();
}
return JacksonUtil.fromJson(configJson, configClass);
} catch (Exception e) {
throw new ElementValidationException(
elementType != null ? elementType.getCode() : "UNKNOWN",
elementKey,
"JSON配置解析失败: " + e.getMessage()
);
}
}
/**
* 启用高质量渲染
*
* @param g2d Graphics2D对象
*/
protected void enableHighQualityRendering(Graphics2D g2d) {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
}
@Override
public String toString() {
return String.format("Element[type=%s, key=%s, name=%s, position=%s]",
elementType != null ? elementType.getCode() : "null",
elementKey,
elementName,
position);
}
}

View File

@@ -1,30 +0,0 @@
package com.ycwl.basic.puzzle.element.base;
/**
* 元素配置接口
* 所有Element的配置类都需要实现此接口
*
* @author Claude
* @since 2025-01-18
*/
public interface ElementConfig {
/**
* 获取配置说明(JSON Schema或描述)
*
* @return 配置说明
*/
default String getConfigSchema() {
return "{}";
}
/**
* 验证配置是否合法
* 子类应该重写此方法实现自己的验证逻辑
*
* @throws IllegalArgumentException 配置不合法时抛出
*/
default void validate() {
// 默认不做验证
}
}

View File

@@ -1,172 +0,0 @@
package com.ycwl.basic.puzzle.element.base;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Constructor;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 元素工厂类
* 负责根据类型创建Element实例
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class ElementFactory {
/**
* Element类型注册表
* key: ElementType枚举
* value: Element实现类的Class对象
*/
private static final Map<ElementType, Class<? extends BaseElement>> ELEMENT_REGISTRY = new ConcurrentHashMap<>();
/**
* 构造器缓存(性能优化)
* key: Element实现类
* value: 无参构造器
*/
private static final Map<Class<? extends BaseElement>, Constructor<? extends BaseElement>> CONSTRUCTOR_CACHE = new ConcurrentHashMap<>();
/**
* 注册Element类型
*
* @param type 元素类型
* @param elementClass Element实现类
*/
public static void register(ElementType type, Class<? extends BaseElement> elementClass) {
if (type == null || elementClass == null) {
throw new IllegalArgumentException("注册参数不能为null");
}
ELEMENT_REGISTRY.put(type, elementClass);
log.info("注册Element类型: {} -> {}", type.getCode(), elementClass.getName());
}
/**
* 根据Entity创建Element实例
*
* @param entity PuzzleElementEntity
* @return Element实例
*/
public static BaseElement create(PuzzleElementEntity entity) {
if (entity == null) {
throw new IllegalArgumentException("Entity不能为null");
}
// 解析元素类型
ElementType type;
try {
type = ElementType.fromCode(entity.getElementType());
} catch (IllegalArgumentException e) {
throw new ElementValidationException(
entity.getElementType(),
entity.getElementKey(),
"未知的元素类型: " + entity.getElementType()
);
}
// 检查类型是否已实现
if (!type.isImplemented()) {
throw new ElementValidationException(
type.getCode(),
entity.getElementKey(),
"元素类型尚未实现: " + type.getName()
);
}
// 获取Element实现类
Class<? extends BaseElement> elementClass = ELEMENT_REGISTRY.get(type);
if (elementClass == null) {
throw new ElementValidationException(
type.getCode(),
entity.getElementKey(),
"元素类型未注册: " + type.getCode()
);
}
// 创建Element实例
BaseElement element = createInstance(elementClass);
// 填充基本属性
element.setId(entity.getId());
element.setElementType(type);
element.setElementKey(entity.getElementKey());
element.setElementName(entity.getElementName());
element.setConfigJson(entity.getConfig());
// 填充位置信息
Position position = new Position(
entity.getXPosition(),
entity.getYPosition(),
entity.getWidth(),
entity.getHeight(),
entity.getZIndex(),
entity.getRotation(),
entity.getOpacity()
);
element.setPosition(position);
// 初始化(加载配置并验证)
element.initialize();
log.debug("创建Element成功: type={}, key={}", type.getCode(), entity.getElementKey());
return element;
}
/**
* 创建Element实例(使用反射)
*
* @param elementClass Element类
* @return Element实例
*/
private static BaseElement createInstance(Class<? extends BaseElement> elementClass) {
try {
// 从缓存获取构造器
Constructor<? extends BaseElement> constructor = CONSTRUCTOR_CACHE.get(elementClass);
if (constructor == null) {
constructor = elementClass.getDeclaredConstructor();
constructor.setAccessible(true);
CONSTRUCTOR_CACHE.put(elementClass, constructor);
}
return constructor.newInstance();
} catch (Exception e) {
throw new ElementValidationException(
"Element实例创建失败: " + elementClass.getName() + ", 原因: " + e.getMessage(),
e
);
}
}
/**
* 获取已注册的Element类型列表
*
* @return Element类型列表
*/
public static Map<ElementType, Class<? extends BaseElement>> getRegisteredTypes() {
return new ConcurrentHashMap<>(ELEMENT_REGISTRY);
}
/**
* 检查类型是否已注册
*
* @param type 元素类型
* @return true-已注册,false-未注册
*/
public static boolean isRegistered(ElementType type) {
return ELEMENT_REGISTRY.containsKey(type);
}
/**
* 清空注册表(主要用于测试)
*/
public static void clearRegistry() {
ELEMENT_REGISTRY.clear();
CONSTRUCTOR_CACHE.clear();
log.warn("Element注册表已清空");
}
}

View File

@@ -1,40 +0,0 @@
package com.ycwl.basic.puzzle.element.base;
import com.ycwl.basic.puzzle.element.enums.ElementType;
import com.ycwl.basic.puzzle.element.impl.ImageElement;
import com.ycwl.basic.puzzle.element.impl.TextElement;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
/**
* Element注册器
* 在Spring容器初始化时自动注册所有Element类型
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
@Component
public class ElementRegistrar {
@PostConstruct
public void registerElements() {
log.info("开始注册Element类型...");
// 注册文字元素
ElementFactory.register(ElementType.TEXT, TextElement.class);
// 注册图片元素
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
// 未来扩展的Element类型在这里注册
// ElementFactory.register(ElementType.QRCODE, QRCodeElement.class);
// ElementFactory.register(ElementType.GRADIENT, GradientElement.class);
// ElementFactory.register(ElementType.SHAPE, ShapeElement.class);
// ElementFactory.register(ElementType.DYNAMIC_IMAGE, DynamicImageElement.class);
log.info("Element类型注册完成,共注册{}种类型", ElementFactory.getRegisteredTypes().size());
}
}

View File

@@ -1,95 +0,0 @@
package com.ycwl.basic.puzzle.element.base;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 元素位置信息
* 封装所有与位置、大小、变换相关的属性
*
* @author Claude
* @since 2025-01-18
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Position {
/**
* X坐标(相对于画布左上角,像素)
*/
private Integer x;
/**
* Y坐标(相对于画布左上角,像素)
*/
private Integer y;
/**
* 宽度(像素)
*/
private Integer width;
/**
* 高度(像素)
*/
private Integer height;
/**
* 层级(数值越大越靠上,决定绘制顺序)
*/
private Integer zIndex;
/**
* 旋转角度(0-360度,顺时针)
*/
private Integer rotation;
/**
* 不透明度(0-100,100为完全不透明)
*/
private Integer opacity;
/**
* 获取不透明度的浮点数表示(0.0-1.0)
*
* @return 不透明度(0.0-1.0)
*/
public float getOpacityFloat() {
if (opacity == null) {
return 1.0f;
}
return Math.max(0, Math.min(100, opacity)) / 100.0f;
}
/**
* 获取旋转角度的弧度值
*
* @return 弧度值
*/
public double getRotationRadians() {
if (rotation == null || rotation == 0) {
return 0;
}
return Math.toRadians(rotation);
}
/**
* 是否需要旋转
*
* @return true-需要旋转,false-不需要
*/
public boolean hasRotation() {
return rotation != null && rotation != 0;
}
/**
* 是否有透明度
*
* @return true-有透明度,false-完全不透明
*/
public boolean hasOpacity() {
return opacity != null && opacity < 100;
}
}

View File

@@ -1,199 +0,0 @@
package com.ycwl.basic.puzzle.element.config;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.base.ElementConfig;
import lombok.Data;
/**
* 图片元素配置
*
* @author Claude
* @since 2025-01-18
*/
@Data
public class ImageConfig implements ElementConfig {
/**
* 默认图片URL
*/
private String defaultImageUrl;
/**
* 图片适配模式
* CONTAIN - 等比缩放适应(保持宽高比,可能留白)
* COVER - 等比缩放填充(保持宽高比,可能裁剪)
* FILL - 拉伸填充(不保持宽高比,可能变形)
* SCALE_DOWN - 缩小适应(类似CONTAIN,但不放大)
*/
private String imageFitMode = "FILL";
/**
* 圆角半径(像素,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() {
// 校验圆角半径
if (borderRadius != null && borderRadius < 0) {
throw new IllegalArgumentException("圆角半径不能为负数: " + borderRadius);
}
// 校验图片适配模式
if (StrUtil.isNotBlank(imageFitMode)) {
String mode = imageFitMode.toUpperCase();
if (!"CONTAIN".equals(mode) &&
!"COVER".equals(mode) &&
!"FILL".equals(mode) &&
!"SCALE_DOWN".equals(mode)) {
throw new IllegalArgumentException("图片适配模式只能是CONTAIN、COVER、FILL或SCALE_DOWN: " + imageFitMode);
}
}
// 校验图片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" +
" \"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

@@ -1,141 +0,0 @@
package com.ycwl.basic.puzzle.element.config;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.base.ElementConfig;
import lombok.Data;
import java.math.BigDecimal;
/**
* 文字元素配置
*
* @author Claude
* @since 2025-01-18
*/
@Data
public class TextConfig implements ElementConfig {
/**
* 默认文本内容
*/
private String defaultText;
/**
* 字体名称(如"微软雅黑"、"PingFang SC")
*/
private String fontFamily = "微软雅黑";
/**
* 字号(像素,范围10-200)
*/
private Integer fontSize = 14;
/**
* 字体颜色(hex格式,如#000000)
*/
private String fontColor = "#000000";
/**
* 字重:NORMAL-正常 BOLD-粗体
*/
private String fontWeight = "NORMAL";
/**
* 字体样式:NORMAL-正常 ITALIC-斜体
*/
private String fontStyle = "NORMAL";
/**
* 对齐方式:LEFT-左对齐 CENTER-居中 RIGHT-右对齐
*/
private String textAlign = "LEFT";
/**
* 行高倍数(如1.5表示1.5倍行距)
*/
private BigDecimal lineHeight = new BigDecimal("1.5");
/**
* 最大行数(超出后截断,NULL表示不限制)
*/
private Integer maxLines;
/**
* 文本装饰:NONE-无 UNDERLINE-下划线 LINE_THROUGH-删除线
*/
private String textDecoration = "NONE";
@Override
public void validate() {
// 校验字号范围
if (fontSize != null && (fontSize < 10 || fontSize > 200)) {
throw new IllegalArgumentException("字号必须在10-200之间: " + fontSize);
}
// 校验行高
if (lineHeight != null && lineHeight.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("行高必须大于0: " + lineHeight);
}
// 校验最大行数
if (maxLines != null && maxLines <= 0) {
throw new IllegalArgumentException("最大行数必须大于0: " + maxLines);
}
// 校验字重
if (StrUtil.isNotBlank(fontWeight)) {
if (!"NORMAL".equalsIgnoreCase(fontWeight) && !"BOLD".equalsIgnoreCase(fontWeight)) {
throw new IllegalArgumentException("字重只能是NORMAL或BOLD: " + fontWeight);
}
}
// 校验字体样式
if (StrUtil.isNotBlank(fontStyle)) {
if (!"NORMAL".equalsIgnoreCase(fontStyle) && !"ITALIC".equalsIgnoreCase(fontStyle)) {
throw new IllegalArgumentException("字体样式只能是NORMAL或ITALIC: " + fontStyle);
}
}
// 校验对齐方式
if (StrUtil.isNotBlank(textAlign)) {
if (!"LEFT".equalsIgnoreCase(textAlign) &&
!"CENTER".equalsIgnoreCase(textAlign) &&
!"RIGHT".equalsIgnoreCase(textAlign)) {
throw new IllegalArgumentException("对齐方式只能是LEFT、CENTER或RIGHT: " + textAlign);
}
}
// 校验文本装饰
if (StrUtil.isNotBlank(textDecoration)) {
if (!"NONE".equalsIgnoreCase(textDecoration) &&
!"UNDERLINE".equalsIgnoreCase(textDecoration) &&
!"LINE_THROUGH".equalsIgnoreCase(textDecoration)) {
throw new IllegalArgumentException("文本装饰只能是NONE、UNDERLINE或LINE_THROUGH: " + textDecoration);
}
}
// 校验颜色格式
if (StrUtil.isNotBlank(fontColor)) {
String hex = fontColor.startsWith("#") ? fontColor.substring(1) : fontColor;
if (hex.length() != 6 || !hex.matches("[0-9A-Fa-f]{6}")) {
throw new IllegalArgumentException("颜色格式必须是hex格式(如#FFFFFF): " + fontColor);
}
}
}
@Override
public String getConfigSchema() {
return "{\n" +
" \"defaultText\": \"默认文本\",\n" +
" \"fontFamily\": \"微软雅黑\",\n" +
" \"fontSize\": 14,\n" +
" \"fontColor\": \"#000000\",\n" +
" \"fontWeight\": \"NORMAL|BOLD\",\n" +
" \"fontStyle\": \"NORMAL|ITALIC\",\n" +
" \"textAlign\": \"LEFT|CENTER|RIGHT\",\n" +
" \"lineHeight\": 1.5,\n" +
" \"maxLines\": null,\n" +
" \"textDecoration\": \"NONE|UNDERLINE|LINE_THROUGH\"\n" +
"}";
}
}

View File

@@ -1,40 +0,0 @@
package com.ycwl.basic.puzzle.element.exception;
/**
* 元素验证异常
* 当元素配置不合法时抛出
*
* @author Claude
* @since 2025-01-18
*/
public class ElementValidationException extends RuntimeException {
private final String elementKey;
private final String elementType;
public ElementValidationException(String message) {
super(message);
this.elementKey = null;
this.elementType = null;
}
public ElementValidationException(String elementType, String elementKey, String message) {
super(String.format("[%s:%s] %s", elementType, elementKey, message));
this.elementKey = elementKey;
this.elementType = elementType;
}
public ElementValidationException(String message, Throwable cause) {
super(message, cause);
this.elementKey = null;
this.elementType = null;
}
public String getElementKey() {
return elementKey;
}
public String getElementType() {
return elementType;
}
}

View File

@@ -1,323 +0,0 @@
package com.ycwl.basic.puzzle.element.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.config.ImageConfig;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.Ellipse2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.net.InetAddress;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 图片元素实现
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class ImageElement extends BaseElement {
private static final int DOWNLOAD_TIMEOUT_MS = 5000;
private ImageConfig imageConfig;
@Override
public void loadConfig(String configJson) {
this.imageConfig = parseConfig(configJson, ImageConfig.class);
this.config = imageConfig;
}
@Override
public void validate() throws ElementValidationException {
try {
if (imageConfig == null) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"图片配置不能为空"
);
}
imageConfig.validate();
} catch (IllegalArgumentException e) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"配置验证失败: " + e.getMessage()
);
}
}
@Override
public void render(RenderContext context) {
Graphics2D g2d = context.getGraphics();
// 获取图片URL(优先使用动态数据)
String imageUrl = context.getDynamicData(elementKey, imageConfig.getDefaultImageUrl());
if (StrUtil.isBlank(imageUrl)) {
log.warn("图片元素没有图片URL: elementKey={}", elementKey);
return;
}
try {
// 下载图片
BufferedImage image = downloadImage(imageUrl);
if (image == null) {
log.error("图片下载失败: imageUrl={}", imageUrl);
return;
}
// 应用透明度
Composite originalComposite = applyOpacity(g2d);
// 缩放图片(根据适配模式)
BufferedImage scaledImage = scaleImage(image);
// 绘制图片(支持圆角)
if (imageConfig.getBorderRadius() != null && imageConfig.getBorderRadius() > 0) {
drawRoundedImage(g2d, scaledImage);
} else {
// 直接绘制
g2d.drawImage(scaledImage, position.getX(), position.getY(), null);
}
// 恢复透明度
restoreOpacity(g2d, originalComposite);
} catch (Exception e) {
log.error("图片元素渲染失败: elementKey={}, imageUrl={}", elementKey, imageUrl, e);
}
}
@Override
public String getConfigSchema() {
return imageConfig != null ? imageConfig.getConfigSchema() : "{}";
}
/**
* 下载图片
*
* @param imageUrl 图片URL或本地文件路径
* @return BufferedImage对象
*/
protected BufferedImage downloadImage(String imageUrl) {
if (StrUtil.isBlank(imageUrl)) {
return null;
}
if (isRemoteUrl(imageUrl)) {
if (!isSafeRemoteUrl(imageUrl)) {
log.warn("图片URL未通过安全校验, 已拒绝下载: {}", imageUrl);
return null;
}
try {
log.debug("下载图片: url={}", imageUrl);
byte[] imageBytes = HttpRequest.get(imageUrl)
.timeout(DOWNLOAD_TIMEOUT_MS)
.setFollowRedirects(false)
.execute()
.bodyBytes();
return ImageIO.read(new ByteArrayInputStream(imageBytes));
} catch (Exception e) {
log.error("图片下载失败: url={}", imageUrl, e);
return null;
}
}
return loadLocalImage(imageUrl);
}
/**
* 缩放图片(根据适配模式)
*
* @param source 原始图片
* @return 缩放后的图片
*/
private BufferedImage scaleImage(BufferedImage source) {
int targetWidth = position.getWidth();
int targetHeight = position.getHeight();
String fitMode = StrUtil.isNotBlank(imageConfig.getImageFitMode())
? imageConfig.getImageFitMode().toUpperCase()
: "FILL";
switch (fitMode) {
case "COVER":
// 等比缩放填充(可能裁剪)- 使用较大的比例
return scaleImageKeepRatio(source, targetWidth, targetHeight, true);
case "CONTAIN":
// 等比缩放适应(可能留白)- 使用较小的比例
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "SCALE_DOWN":
// 缩小适应(不放大)
if (source.getWidth() <= targetWidth && source.getHeight() <= targetHeight) {
return source; // 原图已小于目标,不处理
}
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "FILL":
default:
// 拉伸填充到目标尺寸(不保持宽高比,可能变形)
BufferedImage scaled = new BufferedImage(
targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = scaled.createGraphics();
enableHighQualityRendering(g);
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
g.dispose();
return scaled;
}
}
/**
* 等比缩放图片(保持宽高比)
*
* @param source 原始图片
* @param targetWidth 目标宽度
* @param targetHeight 目标高度
* @param cover true-COVER模式,false-CONTAIN模式
* @return 缩放后的图片
*/
private BufferedImage scaleImageKeepRatio(BufferedImage source,
int targetWidth, int targetHeight,
boolean cover) {
int sourceWidth = source.getWidth();
int sourceHeight = source.getHeight();
double widthRatio = (double) targetWidth / sourceWidth;
double heightRatio = (double) targetHeight / sourceHeight;
// cover模式使用较大比例(填充),contain模式使用较小比例(适应)
double ratio = cover
? Math.max(widthRatio, heightRatio)
: Math.min(widthRatio, heightRatio);
int scaledWidth = (int) (sourceWidth * ratio);
int scaledHeight = (int) (sourceHeight * ratio);
// 创建目标尺寸的画布
BufferedImage result = new BufferedImage(
targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = result.createGraphics();
enableHighQualityRendering(g);
// 居中绘制缩放后的图片
int x = (targetWidth - scaledWidth) / 2;
int y = (targetHeight - scaledHeight) / 2;
g.drawImage(source, x, y, scaledWidth, scaledHeight, null);
g.dispose();
return result;
}
/**
* 绘制圆角图片
*
* @param g2d Graphics2D对象
* @param image 图片
*/
private void drawRoundedImage(Graphics2D g2d, BufferedImage image) {
int width = position.getWidth();
int height = position.getHeight();
int radius = imageConfig.getBorderRadius();
// 创建圆角遮罩
BufferedImage rounded = new BufferedImage(
width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = rounded.createGraphics();
enableHighQualityRendering(g);
// 判断是否需要绘制圆形(当圆角半径>=最小边长的一半时)
boolean isCircle = (radius * 2 >= Math.min(width, height));
if (isCircle) {
// 绘制圆形遮罩
g.setColor(Color.WHITE);
g.fill(new Ellipse2D.Float(0, 0, width, height));
} else {
// 绘制圆角矩形遮罩
g.setColor(Color.WHITE);
g.fill(new RoundRectangle2D.Float(0, 0, width, height, radius * 2, radius * 2));
}
// 应用遮罩
g.setComposite(AlphaComposite.SrcAtop);
g.drawImage(image, 0, 0, width, height, null);
g.dispose();
// 绘制到主画布
g2d.drawImage(rounded, position.getX(), position.getY(), null);
}
private boolean isRemoteUrl(String imageUrl) {
return StrUtil.startWithIgnoreCase(imageUrl, "http://") ||
StrUtil.startWithIgnoreCase(imageUrl, "https://");
}
/**
* 判断URL是否为安全的公网HTTP地址,避免SSRF
*/
protected boolean isSafeRemoteUrl(String imageUrl) {
if (StrUtil.isBlank(imageUrl)) {
return false;
}
try {
URL url = new URL(imageUrl);
String protocol = url.getProtocol();
if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) {
return false;
}
InetAddress address = InetAddress.getByName(url.getHost());
if (address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()) {
return false;
}
return true;
} catch (Exception e) {
log.warn("图片URL解析失败: {}", imageUrl, e);
return false;
}
}
private BufferedImage loadLocalImage(String imageUrl) {
try {
Path path;
if (StrUtil.startWithIgnoreCase(imageUrl, "file:")) {
path = Paths.get(new URI(imageUrl));
} else {
path = Paths.get(imageUrl);
}
if (!Files.exists(path) || !Files.isRegularFile(path)) {
log.error("本地图片文件不存在: {}", imageUrl);
return null;
}
log.debug("加载本地图片: {}", path);
try (var inputStream = Files.newInputStream(path)) {
return ImageIO.read(inputStream);
}
} catch (Exception e) {
log.error("本地图片加载失败: {}", imageUrl, e);
return null;
}
}
}

View File

@@ -1,225 +0,0 @@
package com.ycwl.basic.puzzle.element.impl;
import cn.hutool.core.util.StrUtil;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.config.TextConfig;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import lombok.extern.slf4j.Slf4j;
import java.awt.*;
import java.awt.font.LineMetrics;
import java.awt.geom.Rectangle2D;
/**
* 文字元素实现
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
public class TextElement extends BaseElement {
private TextConfig textConfig;
@Override
public void loadConfig(String configJson) {
this.textConfig = parseConfig(configJson, TextConfig.class);
this.config = textConfig;
}
@Override
public void validate() throws ElementValidationException {
try {
if (textConfig == null) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"文字配置不能为空"
);
}
textConfig.validate();
} catch (IllegalArgumentException e) {
throw new ElementValidationException(
elementType.getCode(),
elementKey,
"配置验证失败: " + e.getMessage()
);
}
}
@Override
public void render(RenderContext context) {
Graphics2D g2d = context.getGraphics();
// 获取文本内容(优先使用动态数据)
String text = context.getDynamicData(elementKey, textConfig.getDefaultText());
if (StrUtil.isBlank(text)) {
log.debug("文字元素没有文本内容: elementKey={}", elementKey);
return;
}
try {
// 设置字体
Font font = createFont();
g2d.setFont(font);
// 设置颜色
g2d.setColor(parseColor(textConfig.getFontColor()));
// 应用透明度
Composite originalComposite = applyOpacity(g2d);
// 应用旋转
if (position.hasRotation()) {
applyRotation(g2d);
}
// 绘制文本
drawText(g2d, text);
// 恢复透明度
restoreOpacity(g2d, originalComposite);
} catch (Exception e) {
log.error("文字元素渲染失败: elementKey={}, text={}", elementKey, text, e);
}
}
@Override
public String getConfigSchema() {
return textConfig != null ? textConfig.getConfigSchema() : "{}";
}
/**
* 创建字体
*
* @return Font对象
*/
private Font createFont() {
int fontStyle = Font.PLAIN;
// 处理字重(BOLD)
if ("BOLD".equalsIgnoreCase(textConfig.getFontWeight())) {
fontStyle |= Font.BOLD;
}
// 处理字体样式(ITALIC)
if ("ITALIC".equalsIgnoreCase(textConfig.getFontStyle())) {
fontStyle |= Font.ITALIC;
}
return new Font(
textConfig.getFontFamily(),
fontStyle,
textConfig.getFontSize()
);
}
/**
* 绘制文本(支持多行、对齐、行高、最大行数)
*
* @param g2d Graphics2D对象
* @param text 文本内容
*/
private void drawText(Graphics2D g2d, String text) {
FontMetrics fm = g2d.getFontMetrics();
// 计算行高
float lineHeightMultiplier = textConfig.getLineHeight() != null
? textConfig.getLineHeight().floatValue()
: 1.5f;
int lineHeight = (int) (fm.getHeight() * lineHeightMultiplier);
// 分行
String[] lines = text.split("\\n");
Integer maxLines = textConfig.getMaxLines();
int actualLines = maxLines != null ? Math.min(lines.length, maxLines) : lines.length;
// 获取对齐方式
String textAlign = StrUtil.isNotBlank(textConfig.getTextAlign())
? textConfig.getTextAlign().toUpperCase()
: "LEFT";
// 计算总文本高度并实现垂直居中
int totalTextHeight = lineHeight * actualLines;
int verticalOffset = (position.getHeight() - totalTextHeight) / 2;
// 起始Y坐标(垂直居中)
int y = position.getY() + verticalOffset + fm.getAscent();
// 逐行绘制
for (int i = 0; i < actualLines; i++) {
String line = lines[i];
// 计算X坐标(根据对齐方式)
int x = calculateTextX(line, fm, textAlign);
// 绘制文本
g2d.drawString(line, x, y);
// 绘制文本装饰(下划线、删除线)
if (StrUtil.isNotBlank(textConfig.getTextDecoration())) {
drawTextDecoration(g2d, line, x, y, fm);
}
// 移动到下一行
y += lineHeight;
}
}
/**
* 计算文本X坐标(根据对齐方式)
*
* @param text 文本
* @param fm 字体度量
* @param textAlign 对齐方式
* @return X坐标
*/
private int calculateTextX(String text, FontMetrics fm, String textAlign) {
int textWidth = fm.stringWidth(text);
switch (textAlign) {
case "CENTER":
return position.getX() + (position.getWidth() - textWidth) / 2;
case "RIGHT":
return position.getX() + position.getWidth() - textWidth;
case "LEFT":
default:
return position.getX();
}
}
/**
* 绘制文本装饰(下划线、删除线)
*
* @param g2d Graphics2D对象
* @param text 文本
* @param x 文本X坐标
* @param y 文本Y坐标
* @param fm 字体度量
*/
private void drawTextDecoration(Graphics2D g2d, String text, int x, int y, FontMetrics fm) {
String decoration = textConfig.getTextDecoration().toUpperCase();
int textWidth = fm.stringWidth(text);
switch (decoration) {
case "UNDERLINE":
// 下划线(在文本下方)
int underlineY = y + fm.getDescent() / 2;
g2d.drawLine(x, underlineY, x + textWidth, underlineY);
break;
case "LINE_THROUGH":
// 删除线(在文本中间)
int lineThroughY = y - fm.getAscent() / 2;
g2d.drawLine(x, lineThroughY, x + textWidth, lineThroughY);
break;
case "NONE":
default:
// 无装饰
break;
}
}
}

View File

@@ -1,74 +0,0 @@
package com.ycwl.basic.puzzle.element.renderer;
import lombok.Data;
import java.awt.*;
import java.util.Map;
/**
* 渲染上下文
* 封装渲染时需要的所有上下文信息
*
* @author Claude
* @since 2025-01-18
*/
@Data
public class RenderContext {
/**
* 图形上下文
*/
private Graphics2D graphics;
/**
* 动态数据(key=elementKey, value=实际值)
*/
private Map<String, String> dynamicData;
/**
* 画布宽度
*/
private Integer canvasWidth;
/**
* 画布高度
*/
private Integer canvasHeight;
/**
* 是否启用抗锯齿
*/
private boolean antiAliasing = true;
/**
* 是否启用高质量渲染
*/
private boolean highQuality = true;
public RenderContext(Graphics2D graphics, Map<String, String> dynamicData) {
this.graphics = graphics;
this.dynamicData = dynamicData;
}
public RenderContext(Graphics2D graphics, Map<String, String> dynamicData,
Integer canvasWidth, Integer canvasHeight) {
this.graphics = graphics;
this.dynamicData = dynamicData;
this.canvasWidth = canvasWidth;
this.canvasHeight = canvasHeight;
}
/**
* 获取动态数据(带默认值)
*
* @param key 数据key
* @param defaultValue 默认值
* @return 数据值
*/
public String getDynamicData(String key, String defaultValue) {
if (dynamicData == null) {
return defaultValue;
}
return dynamicData.getOrDefault(key, defaultValue);
}
}

View File

@@ -46,4 +46,9 @@ public interface MemberPuzzleMapper {
* 根据人脸ID和记录ID查询
*/
MemberPuzzleEntity getByFaceAndRecord(@Param("faceId") Long faceId, @Param("recordId") Long recordId);
/**
* 查询指定时间范围内生成且未购买的免费拼图记录
*/
List<MemberPuzzleEntity> listFreeUnpurchased(@Param("startTime") java.util.Date startTime, @Param("endTime") java.util.Date endTime);
}

View File

@@ -1,6 +1,5 @@
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;
@@ -16,23 +15,16 @@ import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
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;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Comparator;
import java.util.HashMap;
@@ -52,11 +44,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
private final PuzzleRepository puzzleRepository;
private final PuzzleGenerationRecordMapper recordMapper;
private final PuzzleImageRenderer imageRenderer;
private final PuzzleElementFillEngine fillEngine;
private final ScenicRepository scenicRepository;
private final PuzzleDuplicationDetector duplicationDetector;
private final PrinterService printerService;
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
private final FaceStatusManager faceStatusManager;
private final PuzzleRelationProcessor puzzleRelationProcessor;
@@ -64,21 +54,17 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
public PuzzleGenerateServiceImpl(
PuzzleRepository puzzleRepository,
PuzzleGenerationRecordMapper recordMapper,
@Lazy PuzzleImageRenderer imageRenderer,
@Lazy PuzzleElementFillEngine fillEngine,
@Lazy ScenicRepository scenicRepository,
@Lazy PuzzleDuplicationDetector duplicationDetector,
@Lazy PrinterService printerService,
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService,
@Lazy FaceStatusManager faceStatusManager,
@Lazy PuzzleRelationProcessor puzzleRelationProcessor) {
this.puzzleRepository = puzzleRepository;
this.recordMapper = recordMapper;
this.imageRenderer = imageRenderer;
this.fillEngine = fillEngine;
this.scenicRepository = scenicRepository;
this.duplicationDetector = duplicationDetector;
this.printerService = printerService;
this.puzzleEdgeRenderTaskService = puzzleEdgeRenderTaskService;
this.faceStatusManager = faceStatusManager;
this.puzzleRelationProcessor = puzzleRelationProcessor;
@@ -340,84 +326,6 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
return record.getId();
}
/**
* 核心生成逻辑(同步执行)
*/
private PuzzleGenerateResponse doGenerate(PuzzleGenerateRequest request) {
long startTime = System.currentTimeMillis();
log.info("开始生成拼图: templateCode={}, userId={}, faceId={}",
request.getTemplateCode(), request.getUserId(), request.getFaceId());
// 参数校验
validateRequest(request);
// 1. 查询模板和元素(使用缓存)
PuzzleTemplateEntity template = puzzleRepository.getTemplateByCode(request.getTemplateCode());
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + request.getTemplateCode());
}
if (template.getStatus() != 1) {
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
}
// 2. 校验景区隔离
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
if (elements.isEmpty()) {
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
}
// 3. 按z-index排序元素
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
Comparator.nullsFirst(Comparator.naturalOrder())));
// 4. 准备dynamicData(合并自动填充和手动数据)
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
// 5. 执行重复图片检测
// 如果所有IMAGE元素使用相同URL,抛出DuplicateImageException
duplicationDetector.detectDuplicateImages(finalDynamicData, elements);
// 6. 计算内容哈希
String contentHash = duplicationDetector.calculateContentHash(finalDynamicData);
// 7. 查询历史记录(去重核心逻辑)
PuzzleGenerationRecordEntity duplicateRecord = duplicationDetector.findDuplicateRecord(
template.getId(), contentHash, resolvedScenicId);
if (duplicateRecord != null) {
// 发现重复内容,直接返回历史记录
long duration = System.currentTimeMillis() - startTime;
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
// 直接返回历史图片URL(语义化生成成功)
return PuzzleGenerateResponse.success(
duplicateRecord.getResultImageUrl(),
duplicateRecord.getResultFileSize(),
duplicateRecord.getResultWidth(),
duplicateRecord.getResultHeight(),
(int) duration,
duplicateRecord.getId(),
true, // isDuplicate=true
duplicateRecord.getId() // originalRecordId(复用时指向自己)
);
}
// 8. 没有历史记录,创建新的生成记录
PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId);
record.setContentHash(contentHash);
recordMapper.insert(record);
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
// 9. 执行核心生成逻辑
return doGenerateInternal(request, template, resolvedScenicId, record, startTime);
}
/**
* 校验请求参数
*/
@@ -427,105 +335,6 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
}
}
/**
* 核心生成逻辑(内部方法,同步/异步共用)
* 注意:此方法会在调用线程中执行渲染和上传操作
*
* @param request 生成请求
* @param template 模板
* @param resolvedScenicId 景区ID
* @param record 生成记录(已插入数据库)
* @return 生成结果(异步模式下不关心返回值)
*/
private PuzzleGenerateResponse doGenerateInternal(PuzzleGenerateRequest request,
PuzzleTemplateEntity template,
Long resolvedScenicId,
PuzzleGenerationRecordEntity record) {
return doGenerateInternal(request, template, resolvedScenicId, record, System.currentTimeMillis());
}
/**
* 核心生成逻辑(内部方法,同步/异步共用)
*/
private PuzzleGenerateResponse doGenerateInternal(PuzzleGenerateRequest request,
PuzzleTemplateEntity template,
Long resolvedScenicId,
PuzzleGenerationRecordEntity record,
long startTime) {
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
if (elements.isEmpty()) {
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
}
// 按z-index排序元素
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
Comparator.nullsFirst(Comparator.naturalOrder())));
// 准备dynamicData
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
try {
// 渲染图片
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
// 上传图片到OSS
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("图片上传成功: url={}", imageUrl);
// 更新记录为成功
long duration = System.currentTimeMillis() - startTime;
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
recordMapper.updateSuccess(
record.getId(),
imageUrl,
fileSize,
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration
);
// 清除生成记录缓存(状态已更新)
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
record.getId(), imageUrl, duration);
// 检查是否自动添加到打印队列
if (template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
try {
Integer printRecordId = printerService.addUserPhotoFromPuzzle(
request.getUserId(),
resolvedScenicId,
request.getFaceId(),
imageUrl,
record.getId() // 拼图记录ID,用于关联 puzzle_generation_record 表
);
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
} catch (Exception e) {
log.error("自动添加到打印队列失败: recordId={}", record.getId(), e);
}
}
return PuzzleGenerateResponse.success(
imageUrl,
fileSize,
resultImage.getWidth(),
resultImage.getHeight(),
(int) duration,
record.getId(),
false,
null
);
} 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);
}
}
/**
* 创建生成记录
*/
@@ -550,53 +359,6 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
return record;
}
/**
* 上传图片到OSS
*/
private String uploadImage(BufferedImage image, String templateCode, String format, Integer quality) throws IOException {
// 确定格式
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
if (!"PNG".equals(outputFormat) && !"JPEG".equals(outputFormat) && !"JPG".equals(outputFormat)) {
outputFormat = "PNG";
}
// 转换为字节数组
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, outputFormat, baos);
byte[] imageBytes = baos.toByteArray();
// 生成文件名
String fileName = String.format("%s.%s",
UUID.randomUUID().toString().replace("-", ""),
outputFormat.toLowerCase()
);
// 使用项目现有的存储工厂上传(转换为InputStream)
try {
ByteArrayInputStream inputStream = new ByteArrayInputStream(imageBytes);
String contentType = "PNG".equals(outputFormat) ? "image/png" : "image/jpeg";
return StorageFactory.use().uploadFile(contentType, inputStream, "puzzle", templateCode, fileName);
} catch (Exception e) {
log.error("上传图片失败: fileName={}", fileName, e);
throw new IOException("图片上传失败", e);
}
}
/**
* 估算文件大小(字节)
*/
private long estimateFileSize(BufferedImage image, String format) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
ImageIO.write(image, outputFormat, baos);
return baos.size();
} catch (IOException e) {
log.warn("估算文件大小失败", e);
return 0L;
}
}
/**
* 构建dynamicData(合并自动填充和手动数据)
* 优先级: 手动传入的数据 > 自动填充的数据

View File

@@ -1,174 +0,0 @@
package com.ycwl.basic.puzzle.util;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.base.ElementFactory;
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
* 拼图图片渲染引擎(重构版)
* 核心功能:将模板和元素渲染成最终图片
*
* 重构说明:
* - 使用ElementFactory创建Element实例
* - 元素渲染逻辑委托给Element自己实现
* - 删除drawImageElement和drawTextElement方法
* - 保留背景绘制和工具方法
*
* @author Claude
* @since 2025-01-18
*/
@Slf4j
@Component
public class PuzzleImageRenderer {
/**
* 渲染拼图图片(重构版)
*
* @param template 模板配置
* @param elements 元素列表(已按z-index排序)
* @param dynamicData 动态数据(key=elementKey, value=实际值)
* @return 渲染后的图片
*/
public BufferedImage render(PuzzleTemplateEntity template,
List<PuzzleElementEntity> elements,
Map<String, String> dynamicData) {
log.info("开始渲染拼图: templateId={}, elementCount={}", template.getId(), elements.size());
// 1. 创建画布
BufferedImage canvas = new BufferedImage(
template.getCanvasWidth(),
template.getCanvasHeight(),
BufferedImage.TYPE_INT_ARGB // 使用ARGB支持透明度
);
Graphics2D g2d = canvas.createGraphics();
try {
// 2. 开启抗锯齿和优化渲染质量
enableHighQualityRendering(g2d);
// 3. 绘制背景
drawBackground(g2d, template);
// 4. 创建渲染上下文
RenderContext context = new RenderContext(
g2d,
dynamicData,
template.getCanvasWidth(),
template.getCanvasHeight()
);
// 5. 使用ElementFactory创建Element实例并渲染
for (PuzzleElementEntity entity : elements) {
try {
// 使用工厂创建Element实例(自动加载配置和验证)
BaseElement element = ElementFactory.create(entity);
// 委托给Element自己渲染
element.render(context);
log.debug("元素渲染成功: type={}, key={}", element.getElementType().getCode(), element.getElementKey());
} catch (Exception e) {
log.error("元素渲染失败: elementId={}, elementKey={}, error={}",
entity.getId(), entity.getElementKey(), e.getMessage(), e);
// 继续绘制其他元素,不中断整个渲染流程
}
}
log.info("拼图渲染完成: templateId={}, 成功渲染元素数={}", template.getId(), elements.size());
return canvas;
} finally {
g2d.dispose();
}
}
/**
* 开启高质量渲染
*/
private void enableHighQualityRendering(Graphics2D g2d) {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
}
/**
* 绘制背景
*/
private void drawBackground(Graphics2D g2d, PuzzleTemplateEntity template) {
if (template.getBackgroundType() == 0) {
// 纯色背景
String bgColor = StrUtil.isNotBlank(template.getBackgroundColor())
? template.getBackgroundColor() : "#FFFFFF";
g2d.setColor(parseColor(bgColor));
g2d.fillRect(0, 0, template.getCanvasWidth(), template.getCanvasHeight());
} else if (template.getBackgroundType() == 1 && StrUtil.isNotBlank(template.getBackgroundImage())) {
// 图片背景
try {
BufferedImage bgImage = downloadImage(template.getBackgroundImage());
Image scaledBg = bgImage.getScaledInstance(template.getCanvasWidth(), template.getCanvasHeight(), Image.SCALE_SMOOTH);
g2d.drawImage(scaledBg, 0, 0, null);
} catch (Exception e) {
log.error("绘制背景图片失败: {}", template.getBackgroundImage(), e);
// 降级为白色背景
g2d.setColor(Color.WHITE);
g2d.fillRect(0, 0, template.getCanvasWidth(), template.getCanvasHeight());
}
}
}
/**
* 下载图片(工具方法,也可被外部使用)
*/
public BufferedImage downloadImage(String imageUrl) throws IOException {
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
// 网络图片
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
return ImageIO.read(new ByteArrayInputStream(imageBytes));
} else {
// 本地文件
return ImageIO.read(new File(imageUrl));
}
}
/**
* 解析颜色(工具方法,也可被外部使用)
*/
public Color parseColor(String colorStr) {
try {
if (colorStr.startsWith("#")) {
return Color.decode(colorStr);
} else if (colorStr.startsWith("rgb(")) {
// 简单解析 rgb(r,g,b)
String rgb = colorStr.substring(4, colorStr.length() - 1);
String[] parts = rgb.split(",");
return new Color(
Integer.parseInt(parts[0].trim()),
Integer.parseInt(parts[1].trim()),
Integer.parseInt(parts[2].trim())
);
}
} catch (Exception e) {
log.warn("解析颜色失败: {}, 使用黑色", colorStr);
}
return Color.BLACK;
}
}

View File

@@ -428,6 +428,8 @@ public class WechatSubscribeNotifyConfigRepository {
if (cfg == null || !Objects.equals(cfg.getEnabled(), 1)) {
continue;
}
// 复制去重配置到模板实体中
cfg.setDedupSeconds(mapping.getDedupSeconds());
result.add(cfg);
}
return result;

View File

@@ -1,15 +0,0 @@
package com.ycwl.basic.service.mobile;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
import java.util.List;
public interface AppCouponRecordService {
List<CouponRecordEntity> queryByMemberIdAndFaceId(Long memberId, Long faceId);
CouponRecordEntity queryByMemberIdAndFaceIdAndType(Long memberId, Long faceId, Integer type);
CouponEntity claimCoupon(Long memberId, Long faceId, Integer type);
}

View File

@@ -42,7 +42,7 @@ public interface AppMemberService {
* @param userInfoUpdateDTO
* @return
*/
ApiResponse<?> update(WeChatUserInfoUpdateDTO userInfoUpdateDTO);
ApiResponse<?> update(Long memberId, WeChatUserInfoUpdateDTO userInfoUpdateDTO);
/**
* 同意用户协议

View File

@@ -1,69 +0,0 @@
package com.ycwl.basic.service.mobile.impl;
import com.ycwl.basic.mapper.CouponMapper;
import com.ycwl.basic.mapper.CouponRecordMapper;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.service.mobile.AppCouponRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.List;
@Service
public class AppCouponRecordServiceImpl implements AppCouponRecordService {
@Autowired
private CouponRecordMapper couponRecordMapper;
@Autowired
private CouponMapper couponMapper;
@Autowired
private FaceRepository faceRepository;
@Override
public List<CouponRecordEntity> queryByMemberIdAndFaceId(Long memberId, Long faceId) {
return couponRecordMapper.queryByMemberIdAndFaceId(memberId, faceId);
}
@Override
public CouponRecordEntity queryByMemberIdAndFaceIdAndType(Long memberId, Long faceId, Integer type) {
return couponRecordMapper.queryByMemberIdAndFaceIdAndType(memberId, faceId, type);
}
@Override
@Transactional(rollbackFor = Exception.class)
public CouponEntity claimCoupon(Long memberId, Long faceId, Integer type) {
// 检查是否已经领取过该类型的优惠券
CouponRecordEntity existingRecord = couponRecordMapper.queryByMemberIdAndFaceIdAndType(memberId, faceId, type);
if (existingRecord != null) {
throw new RuntimeException("该用户已经领取过此类型的优惠券");
}
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
throw new RuntimeException("人脸数据不存在");
}
// 查找可用的优惠券
Long scenicId = face.getScenicId();
CouponEntity coupon = couponMapper.selectByScenicIdAndTypeAndStatus(scenicId, type, 1);
if (coupon == null) {
throw new RuntimeException("未找到可领取的优惠券");
}
// 创建优惠券记录
CouponRecordEntity record = new CouponRecordEntity();
record.setCouponId(coupon.getId());
record.setMemberId(memberId);
record.setFaceId(faceId);
record.setStatus(0); // 有效状态
record.setCreateTime(new Date());
couponRecordMapper.insert(record);
return coupon;
}
}

View File

@@ -19,6 +19,7 @@ import com.ycwl.basic.service.mobile.AppMemberService;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
import com.ycwl.basic.utils.SnowFlakeUtil;
import com.ycwl.basic.utils.WxMpUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
@@ -138,9 +139,21 @@ public class AppMemberServiceImpl implements AppMemberService {
}
@Override
public ApiResponse<?> update(WeChatUserInfoUpdateDTO userInfoUpdateDTO) {
public ApiResponse<?> update(Long userId, WeChatUserInfoUpdateDTO userInfoUpdateDTO) {
if (StringUtils.isNotBlank(userInfoUpdateDTO.getNickname())) {
MemberRespVO member = memberMapper.getById(userId);
if (member != null && member.getScenicId() != null) {
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(member.getScenicId());
if (scenicMpConfig != null) {
boolean checkResult = WxMpUtil.msgSecCheck(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), userInfoUpdateDTO.getNickname(), member.getOpenId(), 1);
if (!checkResult) {
throw new AppException(BizCodeEnum.PARAM_ERROR.getCode(), "昵称包含违规内容,请修改");
}
}
}
}
MemberEntity memberEntity = new MemberEntity();
memberEntity.setId(Long.parseLong(BaseContextHandler.getUserId()));
memberEntity.setId(userId);
memberEntity.setNickname(userInfoUpdateDTO.getNickname());
memberEntity.setAvatarUrl(userInfoUpdateDTO.getAvatarUrl());
memberEntity.setAgreement(userInfoUpdateDTO.getAgreement());

View File

@@ -26,7 +26,6 @@ import com.ycwl.basic.mapper.*;
import com.ycwl.basic.model.mobile.goods.*;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.order.PriceObj;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
@@ -711,14 +710,13 @@ public class GoodsServiceImpl implements GoodsService {
FacePieceUpdateStatus updateStatus = faceStatusManager.getFacePieceUpdateStatus(video.getFaceId(), video.getTemplateId());
if (updateStatus == FacePieceUpdateStatus.NO_NEW_PIECES) {
log.info("无新片段: faceId={}, templateId={}", video.getFaceId(), video.getTemplateId());
result.setCanUpdate(false);
return result;
}
Long taskId = video.getTaskId();
if (taskId == null) {
log.error("视频没有关联任务: videoId={}", videoId);
log.warn("视频没有关联任务: videoId={}", videoId);
result.setCanUpdate(false);
return result;
}

View File

@@ -14,6 +14,7 @@ import com.ycwl.basic.utils.NotificationAuthUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
@@ -23,6 +24,8 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -47,15 +50,18 @@ public class WechatSubscribeNotifyTriggerService {
private final WechatSubscribeSendLogMapper sendLogMapper;
private final NotificationAuthUtils notificationAuthUtils;
private final ZtMessageProducerService ztMessageProducerService;
private final StringRedisTemplate redisTemplate;
public WechatSubscribeNotifyTriggerService(WechatSubscribeNotifyConfigService configService,
WechatSubscribeSendLogMapper sendLogMapper,
NotificationAuthUtils notificationAuthUtils,
ZtMessageProducerService ztMessageProducerService) {
ZtMessageProducerService ztMessageProducerService,
StringRedisTemplate redisTemplate) {
this.configService = configService;
this.sendLogMapper = sendLogMapper;
this.notificationAuthUtils = notificationAuthUtils;
this.ztMessageProducerService = ztMessageProducerService;
this.redisTemplate = redisTemplate;
}
/**
@@ -91,8 +97,32 @@ public class WechatSubscribeNotifyTriggerService {
continue;
}
String idempotencyKey = buildIdempotencyKey(eventKey, cfg.getTemplateKey(), request);
WechatSubscribeSendLogEntity sendLog = buildInitLog(idempotencyKey, eventKey, cfg, request);
// 计算基础幂等键
String baseHash = buildIdempotencyKey(eventKey, cfg.getTemplateKey(), request);
String dbIdempotencyKey = baseHash;
Integer dedupSeconds = cfg.getDedupSeconds();
if (dedupSeconds != null && dedupSeconds != 0) {
// 非永久去重场景
if (dedupSeconds < 0) {
// 不去重:强制生成新的幂等键
dbIdempotencyKey = baseHash + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
} else {
// 窗口期去重:依赖 Redis 检查
String redisKey = "wechat:subscribe:dedup:" + baseHash;
Boolean success = redisTemplate.opsForValue().setIfAbsent(redisKey, "1", dedupSeconds, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(success)) {
// 窗口期内已发送
skippedCount++;
continue;
}
// 允许发送,但需要新的 DB 键以记录日志(因为表中 idempotency_key 唯一)
dbIdempotencyKey = baseHash + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
}
// else: dedupSeconds == 0 或 null,使用 baseHash 作为 DB 键,利用 DB 唯一索引实现永久去重
WechatSubscribeSendLogEntity sendLog = buildInitLog(dbIdempotencyKey, eventKey, cfg, request);
if (!tryInsertSendLog(sendLog)) {
skippedCount++;
continue;

View File

@@ -1,10 +0,0 @@
package com.ycwl.basic.service.pc;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.model.pc.couponRecord.req.CouponRecordPageQueryReq;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordPageResp;
import com.ycwl.basic.utils.ApiResponse;
public interface CouponRecordService {
ApiResponse<PageInfo<CouponRecordPageResp>> pageQuery(CouponRecordPageQueryReq query);
}

View File

@@ -1,17 +0,0 @@
package com.ycwl.basic.service.pc;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.coupon.req.CouponQueryReq;
import com.ycwl.basic.model.pc.coupon.resp.CouponRespVO;
import java.util.List;
public interface CouponService {
Integer add(CouponEntity coupon);
Boolean update(CouponEntity coupon);
Boolean delete(Integer id);
CouponEntity getById(Integer id);
List<CouponRespVO> list(CouponQueryReq query);
Boolean updateStatus(Integer id);
}

View File

@@ -1,30 +0,0 @@
package com.ycwl.basic.service.pc.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.mapper.CouponRecordMapper;
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
import com.ycwl.basic.model.pc.couponRecord.req.CouponRecordPageQueryReq;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordPageResp;
import com.ycwl.basic.service.pc.CouponRecordService;
import com.ycwl.basic.utils.ApiResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CouponRecordServiceImpl extends ServiceImpl<CouponRecordMapper, CouponRecordEntity> implements CouponRecordService {
@Autowired
private CouponRecordMapper couponRecordMapper;
@Override
public ApiResponse<PageInfo<CouponRecordPageResp>> pageQuery(CouponRecordPageQueryReq query) {
PageHelper.startPage(query.getPageNum(), query.getPageSize());
List<CouponRecordPageResp> list = couponRecordMapper.selectByPageQuery(query);
PageInfo<CouponRecordPageResp> pageInfo = new PageInfo<>(list);
return ApiResponse.success(pageInfo);
}
}

View File

@@ -1,70 +0,0 @@
package com.ycwl.basic.service.pc.impl;
import com.ycwl.basic.mapper.CouponMapper;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.coupon.req.CouponQueryReq;
import com.ycwl.basic.model.pc.coupon.resp.CouponRespVO;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.pc.CouponService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class CouponServiceImpl implements CouponService {
@Autowired
private CouponMapper couponMapper;
@Autowired
private ScenicRepository scenicRepository;
@Override
public Integer add(CouponEntity coupon) {
return couponMapper.insert(coupon);
}
@Override
public Boolean update(CouponEntity coupon) {
return couponMapper.updateById(coupon) > 0;
}
@Override
public Boolean delete(Integer id) {
return couponMapper.deleteById(id) > 0;
}
@Override
public CouponEntity getById(Integer id) {
return couponMapper.selectById(id);
}
@Override
public List<CouponRespVO> list(CouponQueryReq query) {
List<CouponRespVO> list = couponMapper.selectByQuery(query);
// 批量获取景区名称
List<Long> scenicIds = list.stream()
.map(CouponRespVO::getScenicId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Long, String> scenicNames = scenicRepository.batchGetScenicNames(scenicIds);
// 设置景区名称
list.forEach(item -> {
if (item.getScenicId() != null) {
item.setScenicName(scenicNames.get(item.getScenicId()));
}
});
return list;
}
@Override
public Boolean updateStatus(Integer id) {
return couponMapper.updateStatus(id) > 0;
}
}

View File

@@ -47,13 +47,11 @@ import com.ycwl.basic.model.pc.video.entity.VideoEntity;
import com.ycwl.basic.model.repository.TaskUpdateResult;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
import com.ycwl.basic.pricing.dto.ProductItem;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.pricing.service.IPriceCalculationService;
import com.ycwl.basic.constant.FreeStatus;
import com.ycwl.basic.model.pc.puzzle.entity.MemberPuzzleEntity;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.MemberPuzzleMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
@@ -201,12 +199,12 @@ public class FaceServiceImpl implements FaceService {
@Autowired
private PuzzleGenerationRecordMapper puzzleGenerationRecordMapper;
@Autowired
private IPriceCalculationService iPriceCalculationService;
@Autowired
private PuzzleTemplateMapper puzzleTemplateMapper;
@Autowired
private PuzzleRepository puzzleRepository;
@Autowired
private MemberPuzzleMapper memberPuzzleMapper;
@Autowired
private FaceDetectLogAiCamService faceDetectLogAiCamService;
@Autowired
private OrderRepository orderRepository;
@@ -566,21 +564,16 @@ public class FaceServiceImpl implements FaceService {
}
}
}
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
ProductItem productItem = new ProductItem();
productItem.setProductType(ProductType.PHOTO_LOG);
productItem.setProductId(template.getId().toString());
productItem.setPurchaseCount(1);
productItem.setScenicId(face.getScenicId().toString());
calculationRequest.setProducts(Collections.singletonList(productItem));
calculationRequest.setUserId(face.getMemberId());
calculationRequest.setFaceId(face.getId());
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
PriceCalculationResult calculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
if (calculationResult.getFinalAmount().compareTo(BigDecimal.ZERO) > 0) {
sfpContent.setFreeCount(0);
} else {
// 从 member_puzzle 关联记录读取免费状态
if (optionalRecord.isPresent()) {
MemberPuzzleEntity memberPuzzle = memberPuzzleMapper.getByFaceAndRecord(faceId, optionalRecord.get().getId());
if (memberPuzzle != null && FreeStatus.isFree(memberPuzzle.getIsFree())) {
sfpContent.setFreeCount(1);
} else {
sfpContent.setFreeCount(0);
}
} else {
sfpContent.setFreeCount(0);
}
contentList.add(1, sfpContent);
}

View File

@@ -28,7 +28,6 @@ import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.order.OrderAppPageReq;
import com.ycwl.basic.model.mobile.order.PriceObj;
import com.ycwl.basic.model.mobile.order.RefundOrderReq;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.member.resp.MemberRespVO;
import com.ycwl.basic.model.pc.order.entity.OrderEntity;

View File

@@ -9,6 +9,10 @@ import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
import com.ycwl.basic.integration.common.manager.RenderWorkerConfigManager;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.integration.message.service.ZtMessageProducerService;
import com.ycwl.basic.integration.render.dto.job.CreatePreviewRequest;
import com.ycwl.basic.integration.render.dto.job.CreatePreviewResponse;
import com.ycwl.basic.integration.render.dto.job.MaterialDTO;
import com.ycwl.basic.integration.render.service.RenderJobIntegrationService;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.SourceRepository;
import com.ycwl.basic.utils.JacksonUtil;
@@ -115,13 +119,11 @@ public class TaskTaskServiceImpl implements TaskService {
@Autowired
private MemberRelationRepository memberRelationRepository;
@Autowired
private ZtMessageProducerService ztMessageProducerService;
@Autowired
private NotificationAuthUtils notificationAuthUtils;
@Autowired
private WechatSubscribeNotifyTriggerService notifyTriggerService;
@Autowired
private FaceStatusManager faceStatusManager;
@Autowired
private RenderJobIntegrationService renderJobIntegrationService;
private RenderWorkerEntity getWorker(@NonNull WorkerAuthReqVo req) {
String accessKey = req.getAccessKey();
@@ -436,6 +438,18 @@ public class TaskTaskServiceImpl implements TaskService {
if (!templateTaskList.isEmpty()) {
taskEntity = templateTaskList.getFirst();
isReuseOldTask = true;
if (automatic && !forceCreate) {
boolean autoReplaceVlog = scenicConfig.getBoolean("auto_replace_vlog", true);
if (!autoReplaceVlog) {
VideoEntity video = videoRepository.getVideoByTaskId(taskEntity.getId());
if (video != null) {
log.info("自动创建任务:跳过(auto_replace_vlog=false), faceId:{}, templateId:{}, existingTaskId:{}, videoId:{}",
faceId, templateId, taskEntity.getId(), video.getId());
return;
}
}
}
log.info("已有旧生成的视频:{}", taskEntity);
MemberVideoEntity taskVideoRelation = videoMapper.queryRelationByMemberTask(face.getMemberId(), taskEntity.getId());
if (taskVideoRelation != null) {
@@ -462,9 +476,13 @@ public class TaskTaskServiceImpl implements TaskService {
} else {
taskMapper.add(taskEntity);
}
// 灰度测试:创建渲染预览任务(异步,不影响主流程)
tryCreateRenderPreviewJobAsync(taskEntity.getId(), templateId, face.getScenicId(), faceId, face.getMemberId(), sourcesMap);
memberVideoEntity.setTaskId(taskEntity.getId());
} else {
TaskRespVO existingTask = list.getFirst();
log.info("重复task! faceId:{},templateId:{},taskId:{}", faceId, templateId, existingTask.getId());
videoTaskRepository.clearTaskCache(existingTask.getId());
@@ -667,4 +685,55 @@ public class TaskTaskServiceImpl implements TaskService {
public TaskRespVO taskInfo(Long taskId) {
return taskMapper.getById(taskId);
}
/**
* 灰度测试:异步创建渲染预览任务
* 不管zt-render-worker服务返回什么或者报错,都不影响现有流程
*/
private void tryCreateRenderPreviewJobAsync(Long taskId, Long templateId, Long scenicId, Long faceId, Long memberId, Map<String, List<SourceEntity>> sourcesMap) {
Thread.ofVirtual().start(() -> {
try {
log.info("[灰度测试] 开始创建渲染预览任务, taskId: {}, templateId: {}, scenicId: {}, faceId: {}",
taskId, templateId, scenicId, faceId);
CreatePreviewRequest request = new CreatePreviewRequest();
request.setTemplateId(templateId);
request.setScenicId(scenicId);
request.setFaceId(faceId);
request.setMemberId(memberId);
// 转换素材映射
Map<String, List<MaterialDTO>> materialsBySlot = new java.util.HashMap<>();
if (sourcesMap != null && !sourcesMap.isEmpty()) {
sourcesMap.forEach((slotKey, sources) -> {
List<MaterialDTO> materials = sources.stream()
.map(source -> {
MaterialDTO material = new MaterialDTO();
// 优先使用videoUrl,其次使用url
if (StringUtils.isNotBlank(source.getVideoUrl())) {
material.setUrl(source.getVideoUrl());
material.setType("video");
} else {
material.setUrl(source.getUrl());
material.setType(source.getType() != null && source.getType() == 2 ? "image" : "video");
}
material.setFacePos(source.getPosJson());
return material;
})
.toList();
materialsBySlot.put(slotKey, materials);
});
}
request.setMaterialsBySlot(materialsBySlot);
CreatePreviewResponse response = renderJobIntegrationService.createPreview(request);
log.info("[灰度测试] 渲染预览任务创建成功, taskId: {}, renderJobId: {}, playUrl: {}",
taskId, response.getJobId(), response.getPlayUrl());
} catch (Exception e) {
// 灰度测试:不管返回什么或者报错,都不影响现有流程
log.warn("[灰度测试] 渲染预览任务创建失败,不影响主流程, taskId: {}, templateId: {}, error: {}",
taskId, templateId, e.getMessage());
}
});
}
}

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