Compare commits

...

31 Commits

Author SHA1 Message Date
1df6a4bc23 refactor(order): 优化重复购买检查器的延迟初始化
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 在DuplicatePurchaseCheckerFactory类上添加@Lazy注解实现延迟加载
- 在NoCheckDuplicateChecker类上添加@Lazy注解实现延迟加载
- 在ParentResourceDuplicateChecker类上添加@Lazy注解实现延迟加载
- 在UniqueResourceDuplicateChecker类上添加@Lazy注解实现延迟加载
- 添加org.springframework.context.annotation.Lazy导入语句
- 通过延迟初始化提升应用启动性能
2026-01-05 18:12:25 +08:00
981a4ba7bd perf(logging): 移除视频切分任务中的冗余日志输出
- 移除了 VideoRecreationHandler 中的 info 级别日志
- 移除了 TaskFaceServiceImpl 中的 info 级别日志
- 移除了 TaskTaskServiceImpl 中的 info 级别日志
- 保留了 debug 级别的视频重切逻辑日志
- 减少了生产环境中的日志输出量
2026-01-05 18:04:05 +08:00
017ced34fa change(FaceMatchingOrchestrator): 修改图像输出格式和质量设置
- 将输出格式从PNG更改为JPEG
- 将图像质量从90调整为80
2026-01-05 18:03:57 +08:00
a9ae00d580 refactor(puzzle): 重构拼图同步生成逻辑
- 添加详细的方法执行日志记录
- 实现参数校验和模板查询验证
- 增加元素排序和动态数据构建
- 集成重复图片检测机制
- 添加内容去重检测和历史记录复用
- 实现边缘渲染任务创建和
2026-01-05 16:02:28 +08:00
99f75b6805 style(log): 移除日志输出语句
- 移除了 BceFaceBodyAdapter 中无法访问URL图片的警告日志
- 移除了 VideoPieceGetter 中计数器更新和进度检查的调试日志
- 清理了设备关联计数器相关的日志输出
- 移除了 placeholder 完成状态的日志记录
- 删除了进度检查相关的统计日志输出
2026-01-05 14:59:33 +08:00
295815f1fa feat(puzzle): 添加拼图渲染任务同步等待机制
- 引入 CompletableFuture 支持任务异步等待
- 创建 TaskWaitResult 类封装任务执行结果
- 实现 registerWait 和 waitForTask 方法支持同步等待
- 添加 waitFutures 缓存池管理等待任务
- 实现超时清理机制防止内存泄漏
- 提供 createAndWait 便捷方法一键创建并等待
- 在任务完成和失败时自动通知等待方
- 添加过期 future 清理机制优化内存使用
2026-01-05 14:59:25 +08:00
010bac1091 test(integration): 添加集成回退服务的单元测试
- 验证缓存存在时返回缓存值的功能
- 测试无缓存时调用远程服务并缓存结果
- 验证远程调用失败且无缓存时抛出异常
- 测试清除单个缓存项功能
- 验证清除服务所有缓存项功能
- 测试获取缓存统计信息功能
- 验证并发请求时只调用一次远程服务的互斥锁机制
2026-01-05 14:56:51 +08:00
eb9b781fd3 Merge branch 'puzzle_edge_w'
# Conflicts:
#	src/main/java/com/ycwl/basic/config/WebMvcConfig.java
2026-01-05 11:58:56 +08:00
8d3dae32f3 feat(task): 添加版本校验和任务重分配功能
- 实现版本号比较方法,支持版本号大小判断
- 添加客户端版本校验逻辑,防止低版本上报覆盖高版本缓存
- 增加任务重分配功能,在更新旧任务时解除任务分配
- 修复worker状态处理中的版本冲突问题
2026-01-05 11:54:07 +08:00
43775f550b refactor(clickhouse): 修复日期格式化器线程安全问题
- 移除静态 SimpleDateFormat 实例,避免线程安全问题
- 添加上海时区配置确保日期格式化一致性
- 创建新的日期和日期时间格式化器方法
- 修改格式化方法使用新创建的格式化器实例
- 更新每日扫描统计查询中的日期格式化逻辑
2026-01-04 14:47:37 +08:00
24f72091b3 fix(stats): 修复景点人脸识别统计数据查询逻辑
- 修正了人脸上传统计查询中景点ID的过滤方式,从子查询改为直接解析params字段
- 移除了应用统计服务中的过期缓存逻辑
- 修复了任务完成用户统计的表关联错误,从task表改为member_video表进行统计
2026-01-04 14:43:01 +08:00
cc62fb4c18 refactor(clickhouse): 优化统计查询SQL性能和代码结构
- 提取进入景区trace_id子查询逻辑到独立方法appendEnterScenicTraceIdSubQuery
- 将count函数替换为uniqExact以提高去重统计性能
- 优化视频预览统计查询,使用WITH子句提取JSON字段减少重复计算
- 简化经纪人ID列表查询,移除不必要的子查询包装
- 修复每日扫码统计查询的时间范围过滤条件
- 优化按小时和按日期的扫码会员图表查询,使用ClickHouse内置时间函数
- 在子查询中添加时间范围过滤以减少数据扫描量
2026-01-04 13:53:37 +08:00
d1962ed615 refactor(clickhouse): 将统计数据查询从 MyBatis 迁移到 JDBC 模板
- 移除 ClickHouseStatsMapper 接口及 XML 映射文件
- 使用 NamedParameterJdbcTemplate 替代 MyBatis 实现数据查询
- 添加日期格式化工具类处理 ClickHouse 时间格式
- 重构所有统计查询方法使用原生 SQL 字符串构建
- 添加 MySQL 主数据源配置确保多数据源正确配置
- 升级 ClickHouse JDBC 驱动版本到 0.8.5
- 解决 0.6.x 版本参数绑定问题通过手动 SQL 构建
- 保持原有查询逻辑不变仅改变实现方式
2026-01-04 13:17:01 +08:00
e1023b6ea8 refactor(stats): 移除统计追踪模块相关代码
- 删除 StatsBiz 业务类
- 移除 TraceController 控制器及其接口实现
- 删除 AddTraceReq 数据传输对象
- 移除 StatsEntity 和 StatsRecordEntity 实体类
- 移除 StatsInterceptor 拦截器
- 删除 StatsMapper 和 StatsRecordMapper 数据访问接口
- 移除 StatsService 服务接口及 StatsServiceImpl 实现类
- 删除 StatsUtil 工具类
2026-01-04 12:16:01 +08:00
aec5e57df7 feat(database): 迁移统计数据查询到ClickHouse
- 添加ClickHouse数据源配置和相关依赖
- 实现ClickHouse统计查询服务和MySQL兜底方案
- 新增扫码统计、订单统计等数据查询接口
- 重构分销员数据统计逻辑,整合MySQL和ClickHouse数据源
- 更新应用配置文件以支持ClickHouse启用开关
- 修改分布式任务统计以支持跨库查询场景
2026-01-04 10:34:17 +08:00
52ce26e630 feat(puzzle): 添加拼图边缘渲染功能
- 集成 PuzzleEdgeWorkerIpInterceptor 拦截器进行 IP 校验
- 添加 PuzzleEdgeWorkerSecurityProperties 配置类
- 创建 PuzzleEdgeRenderTaskController 提供边缘渲染接口
- 添加多种 DTO 类用于边缘渲染任务数据传输
- 创建 PuzzleEdgeRenderTaskEntity 实体和 Mapper 接口
- 实现 PuzzleEdgeRenderTaskService 核心服务逻辑
- 重构 PuzzleGenerateServiceImpl 使用边缘渲染服务
- 移除原有的线程池执行器和同步渲染逻辑
- 添加定时任务处理渲染超时和重试机制
- 实现自动打印队列添加功能
2026-01-03 23:47:37 +08:00
32297dc29c refactor(printer): 优化人脸素材查询逻辑
- 移除不必要的MemberSourceEntity和相关Repository依赖
- 将数据查询逻辑从Repository层迁移到Mapper层
- 添加type参数支持素材类型过滤
- 修复方法注释中的人脸ID描述错误
- 直接返回SourceEntity列表避免额外的转换操作
2026-01-03 23:46:58 +08:00
21d8c56e82 feat(puzzle): 添加拼图模块缓存仓库并集成缓存功能
- 新增 PuzzleRepository 缓存仓库类,提供模板和元素的 Redis 缓存功能
- 实现模板按 ID 和编码的双向缓存,减少数据库查询压力
- 实现元素列表按模板 ID 缓存,避免重复查询
- 在模板服务中集成缓存,更新和删除时自动清除相关缓存
- 在生成服务中使用缓存读取模板和元素数据
- 添加缓存过期机制,设置 24 小时自动过期
- 实现批量缓存清除功能,支持按模式删除缓存
2026-01-01 21:39:43 +08:00
f8374519c3 feat(puzzle): 添加拼图生成异步处理能力
- 移除 @RequiredArgsConstructor 注解,改用手动构造函数注入
- 添加 ThreadPoolExecutor 实现拼图生成异步处理
- 新增 generateAsync 方法支持异步生成拼图
- 新增 generateSync 方法支持同步生成拼图
- 重构核心生成逻辑为 doGenerateInternal 方法供同步异步共用
- 在 FaceMatchingOrchestrator 中优化拼图模板生成逻辑
- 支持根据场景选择同步或异步生成模式
- 添加线程池队列大小监控和日志记录
2026-01-01 21:26:34 +08:00
44f5008fd1 refactor(task): 优化重复任务处理逻辑
- 移除旧任务更新逻辑,简化重复任务处理流程
- 替换视频查询方法调用,使用新的存储库方法
- 保持任务缓存清除功能
- 简化日志输出信息
2026-01-01 19:55:33 +08:00
6e0ebcd1bd fix(puzzle): 优化拼图生成失败日志记录
- 修改日志记录格式,添加异常消息详情
- 保持错误响应信息的一致性
2026-01-01 19:43:13 +08:00
5caf9a0ebf refactor(face): 重构人脸服务接口和实现
- 修改 getById 方法返回类型为 FaceEntity 并直接调用仓库层
- 移除删除人脸时的用户权限检查逻辑
- 删除 contentListUseDefaultFace 方法的实现
- 从服务接口中移除 contentListUseDefaultFace 方法定义
2026-01-01 19:40:45 +08:00
06bc2c2020 refactor(scenic): 移除景区移动端控制器和服务中的分页查询功能
- 删除 AppScenicController 中的 pageQuery 接口方法
- 删除 AppScenicController 中的 deviceCountByScenicId 接口方法
- 删除 AppScenicController 中的 scenicListByLnLa 接口方法
- 从 AppScenicService 接口中移除 pageQuery 方法定义
- 从 AppScenicService 接口中移除 deviceCountByScenicId 方法定义
- 从 AppScenicService 接口中移除 scenicListByLnLa 方法定义
- 完全移除 AppScenicController 类文件
- 简化 AppScenicServiceImpl 中
2026-01-01 19:32:49 +08:00
81dc2f1b86 feat(printer): 添加景区ID参数并优化用户照片打印去重逻辑
- 在价格计算请求中添加景区ID参数
- 实现用户照片按sourceId去重机制,避免重复添加相同照片
- 查询用户在景区的已有打印记录用于去重判断
- 优化普通照片打印商品项,添加设备ID属性信息
- 过滤无效数据并去重后生成设备ID属性列表
2025-12-31 23:37:00 +08:00
41e90bab9c fix(task): 修复任务重复处理中的日志和更新逻辑
- 移除了创建任务日志中的敏感参数信息
- 更新重复任务日志以包含任务ID信息
- 移除了workerId重置逻辑,改为显式清除方法
- 修复TaskMapper中status字段的SQL语法问题
- 优化了任务参数更新的处理流程
2025-12-31 20:36:31 +08:00
b4628bd3e8 refactor(task): 优化重复任务处理逻辑
- 修复重复任务时直接使用旧任务ID的问题
- 实现重复任务的更新机制:重置workerId为空,status为0
- 添加taskParams的更新功能
- 集成任务缓存清理机制
- 修正订单购买状态检查的参数传递
2025-12-31 20:03:58 +08:00
cfb4284b7c refactor(video): 优化视频片段获取任务的设备配对处理
- 添加 Caffeine 缓存优化景区设备配对关系查询性能
- 实现设备配对关系缓存机制,避免重复数据库查询
- 重构线程安全的回调调用逻辑,使用 compareAndSet 保证原子性
- 添加调试日志的条件判断,减少不必要的日志输出
- 优化任务执行流程,调整线程池关闭和资源清理逻辑
- 实现设备配对关系加载方法,返回不可变Map提高安全性
2025-12-31 19:50:44 +08:00
5a61432dc9 refactor(orchestrator): 优化人脸匹配拼图模板生成逻辑
- 引入线程同步机制确保打印机场景下拼图模板生成完成
- 修改 asyncGeneratePuzzleTemplate 方法返回 Thread 对象便于控制
- 使用虚拟线程池优化拼图模板并发生成性能
- 简化原子计数器和异步任务相关代码实现
- 添加线程 join 等待确保关键场景执行顺序
- 修复方法返回值类型和资源管理相关问题
2025-12-31 19:50:18 +08:00
91160a1adb fix(task): 修复任务重复创建和空指针问题
- 在原位替换模式下设置taskParams为null,避免按参数匹配
- 添加isReuseOldTask标识判断是否复用旧任务
- 复用旧任务时执行更新操作而非新增操作
- 添加member和item空值检查,防止空指针异常
- 优化日志记录,提供更准确的操作信息
2025-12-30 18:12:29 +08:00
991a8b10e3 fix(printer): 解决图片类型设置逻辑问题
- 添加source为空时的图片类型判断逻辑
- 当source为空时将图片类型设置为PUZZLE
- 保持原有source不为空时的IPC类型设置逻辑
- 确保PHONE类型的设置逻辑不受影响
2025-12-30 17:49:39 +08:00
ab1e8cf7ef fix(printer): 解决图片类型设置逻辑问题
- 添加source为空时的图片类型判断逻辑
- 当source为空时将图片类型设置为PUZZLE
- 保持原有source不为空时的IPC类型设置逻辑
- 确保PHONE类型的设置逻辑不受影响
2025-12-30 17:34:30 +08:00
72 changed files with 3266 additions and 758 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,18 +4,16 @@ package com.ycwl.basic.controller.printer;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.repository.SourceRepository;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.WxMpUtil;
@@ -32,7 +30,6 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.OutputStream;
import java.util.Collections;
import java.util.List;
@IgnoreToken
@@ -46,8 +43,7 @@ public class PrinterTvController {
private final ScenicRepository scenicRepository;
private final FaceRepository faceRepository;
private final FaceService pcFaceService;
private final MemberRelationRepository memberRelationRepository;
private final SourceRepository sourceRepository;
private final SourceMapper sourceMapper;
/**
* 获取景区列表
@@ -167,18 +163,16 @@ public class PrinterTvController {
}
/**
* 根据人脸样本ID查询图像素材
* 根据人脸ID查询图像素材
*
* @param faceId 人脸样本ID
* @param faceId 人脸ID
* @param type 素材类型(默认为2-图片)
* @return 匹配的source记录
*/
@GetMapping("/{faceId}/source")
public ApiResponse<List<SourceEntity>> getSourceByFaceId(@PathVariable Long faceId, @RequestParam(name = "type", required = false, defaultValue = "2") Integer type) {
List<MemberSourceEntity> source = memberRelationRepository.listSourceByFaceRelation(faceId, type);
if (source == null) {
return ApiResponse.success(Collections.emptyList());
}
return ApiResponse.success(source.stream().map(item -> sourceRepository.getSource(item.getSourceId())).toList());
List<SourceEntity> sources = sourceMapper.listSourceByFaceRelation(faceId, type);
return ApiResponse.success(sources);
}
/**

View File

@@ -142,8 +142,8 @@ public class PuzzleGenerationOrchestrator {
generateRequest.setFaceId(faceId);
generateRequest.setBusinessType("face_matching");
generateRequest.setTemplateCode(template.getCode());
generateRequest.setOutputFormat("PNG");
generateRequest.setQuality(90);
generateRequest.setOutputFormat("JPEG");
generateRequest.setQuality(80);
generateRequest.setDynamicData(new HashMap<>(baseDynamicData));
generateRequest.setRequireRuleMatch(true);

View File

@@ -162,7 +162,7 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
return resp;
} else if (errorCode == 222204) {
// error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试
log.warn("无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
// log.warn("无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
String base64Image = downloadImageAsBase64(faceUrl);
if (base64Image != null) {
// 重试时也不需要限流,由外层调度器控制
@@ -338,7 +338,7 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
return resp;
} else if (errorCode == 222204) {
// error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试
log.warn("搜索人脸时无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
// log.warn("搜索人脸时无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
String base64Image = downloadImageAsBase64(faceUrl);
if (base64Image != null) {
try {

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import com.ycwl.basic.model.mobile.statistic.req.CommonQueryReq;
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
import com.ycwl.basic.model.mobile.statistic.resp.AppStatisticsFunnelVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.Date;
@@ -108,6 +109,18 @@ public interface StatisticsMapper {
List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query);
/**
* 统计分销员扫码次数
*/
Integer countBrokerScanCount(Long brokerId);
/**
* 按日期统计分销员扫码数据
*/
List<HashMap<String, Object>> getDailyScanStats(@Param("brokerId") Long brokerId,
@Param("startTime") Date startTime,
@Param("endTime") Date endTime);
/**
* 统计订单数量和金额(包含推送订单和现场订单)
* @param query

View File

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

View File

@@ -4,6 +4,7 @@ import com.ycwl.basic.order.strategy.IDuplicatePurchaseChecker;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.util.HashMap;
@@ -18,6 +19,7 @@ import java.util.Map;
* 2. 类型安全:根据枚举类型查找策略
* 3. 失败快速:找不到策略时抛出异常
*/
@Lazy
@Slf4j
@Service
public class DuplicatePurchaseCheckerFactory {

View File

@@ -4,6 +4,7 @@ import com.ycwl.basic.order.strategy.DuplicateCheckContext;
import com.ycwl.basic.order.strategy.IDuplicatePurchaseChecker;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
/**
@@ -13,6 +14,7 @@ import org.springframework.stereotype.Component;
* 检查逻辑:
* 不执行任何检查,直接通过
*/
@Lazy
@Slf4j
@Component
public class NoCheckDuplicateChecker implements IDuplicatePurchaseChecker {

View File

@@ -14,6 +14,7 @@ import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import java.util.List;
@@ -29,6 +30,7 @@ import java.util.List;
*
* SQL查询: WHERE order_id = ? AND product_type = ?
*/
@Lazy
@Slf4j
@Component
public class ParentResourceDuplicateChecker implements IDuplicatePurchaseChecker {

View File

@@ -14,6 +14,7 @@ import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import java.util.List;
@@ -29,6 +30,7 @@ import java.util.List;
*
* SQL查询: WHERE order_id = ? AND product_type = ? AND product_id = ?
*/
@Lazy
@Slf4j
@Component
public class UniqueResourceDuplicateChecker implements IDuplicatePurchaseChecker {

View File

@@ -42,7 +42,7 @@ public class PuzzleGenerateController {
log.warn("拼图生成参数错误: {}", e.getMessage());
return ApiResponse.fail(e.getMessage());
} catch (Exception e) {
log.error("拼图生成失败", e);
log.error("拼图生成失败:{}", e.getMessage());
return ApiResponse.fail("图片生成失败,请稍后重试");
}
}

View File

@@ -0,0 +1,24 @@
package com.ycwl.basic.puzzle.edge.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 边缘 Worker 接入安全配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "puzzle.edge.worker.security")
public class PuzzleEdgeWorkerSecurityProperties {
/**
* 是否启用访问 IP 校验
*/
private boolean enabled = true;
/**
* 允许访问的 IPv4 CIDR(默认:100.64.0.0/24)
*/
private String allowedIpCidr = "100.64.0.0/24";
}

View File

@@ -0,0 +1,55 @@
package com.ycwl.basic.puzzle.edge.controller;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskFailRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskSuccessRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeUploadUrlsResponse;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerAuthRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncResponse;
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Puzzle 边缘渲染对接接口
* 模式:边缘客户端拉取任务(含上传地址) → 上传 → 上报结果
*/
@Slf4j
@IgnoreToken
@RestController
@RequestMapping("/puzzle/render/v1")
@RequiredArgsConstructor
public class PuzzleEdgeRenderTaskController {
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
@PostMapping("/worker/sync")
public ApiResponse<PuzzleEdgeWorkerSyncResponse> sync(@RequestBody PuzzleEdgeWorkerSyncRequest req) {
return ApiResponse.success(puzzleEdgeRenderTaskService.sync(req));
}
@PostMapping("/task/{taskId}/uploadUrls")
public ApiResponse<PuzzleEdgeUploadUrlsResponse> uploadUrls(@PathVariable Long taskId,
@RequestBody PuzzleEdgeWorkerAuthRequest req) {
return ApiResponse.success(puzzleEdgeRenderTaskService.getUploadUrls(taskId, req != null ? req.getAccessKey() : null));
}
@PostMapping("/task/{taskId}/success")
public ApiResponse<String> success(@PathVariable Long taskId, @RequestBody PuzzleEdgeTaskSuccessRequest req) {
puzzleEdgeRenderTaskService.taskSuccess(taskId, req);
return ApiResponse.success("OK");
}
@PostMapping("/task/{taskId}/fail")
public ApiResponse<String> fail(@PathVariable Long taskId, @RequestBody PuzzleEdgeTaskFailRequest req) {
puzzleEdgeRenderTaskService.taskFail(taskId, req);
return ApiResponse.success("OK");
}
}

View File

@@ -0,0 +1,23 @@
package com.ycwl.basic.puzzle.edge.dto;
import lombok.Data;
import java.util.Map;
@Data
public class PuzzleEdgeRenderTaskDTO {
private Long id;
private Long recordId;
private Long templateId;
private String templateCode;
private Long scenicId;
private Long faceId;
private Integer attemptCount;
private String outputFormat;
private Integer outputQuality;
private Map<String, Object> payload;
/**
* 上传地址(预签名URL),用于 Worker 直接上传产物
*/
private PuzzleEdgeUploadUrlsResponse upload;
}

View File

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

View File

@@ -0,0 +1,14 @@
package com.ycwl.basic.puzzle.edge.dto;
import lombok.Data;
@Data
public class PuzzleEdgeTaskSuccessRequest {
private String accessKey;
private Long resultFileSize;
private Integer resultWidth;
private Integer resultHeight;
private Integer renderDurationMs;
}

View File

@@ -0,0 +1,22 @@
package com.ycwl.basic.puzzle.edge.dto;
import lombok.Data;
import java.util.Date;
import java.util.Map;
@Data
public class PuzzleEdgeUploadUrlsResponse {
private UploadTarget original;
private UploadTarget cropped;
private Date expireAt;
@Data
public static class UploadTarget {
private String method;
private String url;
private String publicUrl;
private Map<String, String> headers;
}
}

View File

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

View File

@@ -0,0 +1,12 @@
package com.ycwl.basic.puzzle.edge.dto;
import com.ycwl.basic.model.task.req.ClientStatusReqVo;
import lombok.Data;
@Data
public class PuzzleEdgeWorkerSyncRequest {
private String accessKey;
private Integer maxTasks;
private ClientStatusReqVo clientStatus;
}

View File

@@ -0,0 +1,12 @@
package com.ycwl.basic.puzzle.edge.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class PuzzleEdgeWorkerSyncResponse {
private List<PuzzleEdgeRenderTaskDTO> tasks = new ArrayList<>();
}

View File

@@ -0,0 +1,76 @@
package com.ycwl.basic.puzzle.edge.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* Puzzle 边缘渲染任务实体
* 对应表:puzzle_edge_render_task
*/
@Data
@TableName("puzzle_edge_render_task")
public class PuzzleEdgeRenderTaskEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("record_id")
private Long recordId;
@TableField("template_id")
private Long templateId;
@TableField("template_code")
private String templateCode;
@TableField("scenic_id")
private Long scenicId;
@TableField("face_id")
private Long faceId;
@TableField("content_hash")
private String contentHash;
@TableField("status")
private Integer status;
@TableField("worker_id")
private Long workerId;
@TableField("lease_expire_time")
private Date leaseExpireTime;
@TableField("attempt_count")
private Integer attemptCount;
@TableField("output_format")
private String outputFormat;
@TableField("output_quality")
private Integer outputQuality;
@TableField("original_object_key")
private String originalObjectKey;
@TableField("cropped_object_key")
private String croppedObjectKey;
@TableField("payload_json")
private String payloadJson;
@TableField("error_message")
private String errorMessage;
@TableField("create_time")
private Date createTime;
@TableField("update_time")
private Date updateTime;
}

View File

@@ -0,0 +1,51 @@
package com.ycwl.basic.puzzle.edge.interceptor;
import com.ycwl.basic.puzzle.edge.config.PuzzleEdgeWorkerSecurityProperties;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.IpUtils;
import com.ycwl.basic.utils.Ipv4CidrMatcher;
import com.ycwl.basic.utils.JacksonUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 边缘 Worker 接口访问 IP 校验
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PuzzleEdgeWorkerIpInterceptor implements HandlerInterceptor {
private static final String FORBIDDEN_MESSAGE = "非法来源IP";
private final PuzzleEdgeWorkerSecurityProperties properties;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (properties == null || !properties.isEnabled()) {
return true;
}
String clientIp = IpUtils.getIpAddr(request);
if (Ipv4CidrMatcher.matches(clientIp, properties.getAllowedIpCidr())) {
return true;
}
log.warn("拒绝边缘 Worker 请求: uri={}, ip={}, allowedIpCidr={}",
request != null ? request.getRequestURI() : null,
clientIp,
properties.getAllowedIpCidr());
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JacksonUtil.toJson(ApiResponse.buildResponse(HttpStatus.FORBIDDEN.value(), FORBIDDEN_MESSAGE)));
return false;
}
}

View File

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

View File

@@ -0,0 +1,780 @@
package com.ycwl.basic.puzzle.edge.task;
import cn.hutool.core.util.StrUtil;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.fasterxml.jackson.core.type.TypeReference;
import com.ycwl.basic.model.pc.renderWorker.entity.RenderWorkerEntity;
import com.ycwl.basic.model.task.req.ClientStatusReqVo;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeRenderTaskDTO;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskFailRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskSuccessRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeUploadUrlsResponse;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncRequest;
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncResponse;
import com.ycwl.basic.puzzle.edge.entity.PuzzleEdgeRenderTaskEntity;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.repository.RenderWorkerRepository;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.scheduling.annotation.Scheduled;
import java.util.Date;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicLong;
/**
* Puzzle 边缘渲染任务服务(中心端)
* 负责:任务创建、任务拉取、签发上传URL、回报成功/失败落库
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PuzzleEdgeRenderTaskService {
private static final int STATUS_PENDING = 0;
private static final int STATUS_RUNNING = 1;
private static final int STATUS_SUCCESS = 2;
private static final int STATUS_FAIL = 3;
private static final int MAX_SYNC_TASKS = 5;
private static final long LEASE_MILLIS = TimeUnit.SECONDS.toMillis(20);
private static final long UPLOAD_URL_EXPIRE_MILLIS = TimeUnit.HOURS.toMillis(1);
private static final int MAX_RETRY_ATTEMPTS = 3;
private static final long TASK_CACHE_EXPIRE_HOURS = 6L;
private static final long TASK_CACHE_MAX_SIZE = 20000L;
private static final long WAIT_FUTURE_EXPIRE_MILLIS = TimeUnit.MINUTES.toMillis(10);
/**
* 任务等待结果
*/
public static class TaskWaitResult {
private final boolean success;
private final String errorMessage;
private final String imageUrl;
private TaskWaitResult(boolean success, String errorMessage, String imageUrl) {
this.success = success;
this.errorMessage = errorMessage;
this.imageUrl = imageUrl;
}
public static TaskWaitResult success(String imageUrl) {
return new TaskWaitResult(true, null, imageUrl);
}
public static TaskWaitResult fail(String errorMessage) {
return new TaskWaitResult(false, errorMessage, null);
}
public boolean isSuccess() {
return success;
}
public String getErrorMessage() {
return errorMessage;
}
public String getImageUrl() {
return imageUrl;
}
}
/**
* 等待 future 的包装,包含创建时间用于过期清理
*/
private static class WaitFutureEntry {
final CompletableFuture<TaskWaitResult> future;
final long createTimeMillis;
WaitFutureEntry(CompletableFuture<TaskWaitResult> future) {
this.future = future;
this.createTimeMillis = System.currentTimeMillis();
}
boolean isExpired(long nowMillis) {
return nowMillis - createTimeMillis > WAIT_FUTURE_EXPIRE_MILLIS;
}
}
/**
* 任务内存池(单实例、允许丢失):仅用作 Worker 拉取与状态落地的中间态
*/
private final Cache<Long, PuzzleEdgeRenderTaskEntity> taskCache = Caffeine.newBuilder()
.expireAfterWrite(TASK_CACHE_EXPIRE_HOURS, TimeUnit.HOURS)
.maximumSize(TASK_CACHE_MAX_SIZE)
.build();
private final AtomicLong taskIdSequence = new AtomicLong(System.currentTimeMillis());
private final Object taskLock = new Object();
/**
* 任务等待 future 池:用于伪同步等待任务完成
*/
private final ConcurrentHashMap<Long, WaitFutureEntry> waitFutures = new ConcurrentHashMap<>();
private final PuzzleGenerationRecordMapper recordMapper;
private final PuzzleRepository puzzleRepository;
private final PrinterService printerService;
private final RenderWorkerRepository renderWorkerRepository;
public PuzzleEdgeWorkerSyncResponse sync(PuzzleEdgeWorkerSyncRequest req) {
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
ClientStatusReqVo clientStatus = req != null ? req.getClientStatus() : null;
if (clientStatus != null) {
renderWorkerRepository.setWorkerHostStatus(worker.getId(), clientStatus);
}
int maxTasks = req != null && req.getMaxTasks() != null ? req.getMaxTasks() : 1;
if (maxTasks <= 0) {
maxTasks = 1;
}
if (maxTasks > MAX_SYNC_TASKS) {
maxTasks = MAX_SYNC_TASKS;
}
PuzzleEdgeWorkerSyncResponse resp = new PuzzleEdgeWorkerSyncResponse();
for (int i = 0; i < maxTasks; i++) {
PuzzleEdgeRenderTaskEntity task = claimOne(worker.getId());
if (task == null) {
break;
}
PuzzleEdgeRenderTaskDTO dto = toTaskDTOOrFail(task, worker.getId());
if (dto != null) {
resp.getTasks().add(dto);
}
}
return resp;
}
public PuzzleEdgeUploadUrlsResponse getUploadUrls(Long taskId, String accessKey) {
RenderWorkerEntity worker = requireWorker(accessKey);
PuzzleEdgeRenderTaskEntity task = getAndCheckRunningTask(taskId, worker.getId());
return buildUploadUrls(task);
}
public void taskSuccess(Long taskId, PuzzleEdgeTaskSuccessRequest req) {
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, worker.getId());
if (task.getStatus() != null && task.getStatus() == STATUS_SUCCESS) {
return;
}
if (task.getStatus() == null || task.getStatus() != STATUS_RUNNING) {
throw new IllegalArgumentException("任务状态非法");
}
boolean updated = tryMarkSuccess(task, worker.getId());
if (!updated) {
throw new IllegalStateException("任务状态更新失败");
}
PuzzleGenerationRecordEntity record = recordMapper.getById(task.getRecordId());
if (record == null) {
log.warn("边缘渲染任务回报成功,但生成记录不存在: taskId={}, recordId={}", taskId, task.getRecordId());
return;
}
IStorageAdapter storage = StorageFactory.use();
String originalImageUrl = storage.getUrl(task.getOriginalObjectKey());
String resultImageUrl = StrUtil.isNotBlank(task.getCroppedObjectKey())
? storage.getUrl(task.getCroppedObjectKey())
: originalImageUrl;
Long resultFileSize = req != null ? req.getResultFileSize() : null;
Integer resultWidth = req != null ? req.getResultWidth() : null;
Integer resultHeight = req != null ? req.getResultHeight() : null;
Integer renderDurationMs = req != null ? req.getRenderDurationMs() : null;
recordMapper.updateSuccess(
record.getId(),
resultImageUrl,
originalImageUrl,
resultFileSize,
resultWidth,
resultHeight,
renderDurationMs
);
// 通知等待方任务完成
completeWaitFuture(taskId, TaskWaitResult.success(resultImageUrl));
PuzzleTemplateEntity template = puzzleRepository.getTemplateById(task.getTemplateId());
if (template != null && template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
try {
Integer printRecordId = printerService.addUserPhotoFromPuzzle(
record.getUserId(),
record.getScenicId(),
record.getFaceId(),
originalImageUrl,
record.getId()
);
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
} catch (Exception e) {
log.error("自动添加到打印队列失败: recordId={}", record.getId(), e);
}
}
}
public void taskFail(Long taskId, PuzzleEdgeTaskFailRequest req) {
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, worker.getId());
if (task.getStatus() != null && task.getStatus() == STATUS_FAIL) {
return;
}
if (task.getStatus() == null || task.getStatus() != STATUS_RUNNING) {
throw new IllegalArgumentException("任务状态非法");
}
String errorMessage = req != null && StrUtil.isNotBlank(req.getErrorMessage())
? req.getErrorMessage()
: "边缘渲染失败";
boolean updated = tryMarkFail(task, worker.getId(), errorMessage);
if (!updated) {
throw new IllegalStateException("任务状态更新失败");
}
recordMapper.updateFail(task.getRecordId(), errorMessage);
// 通知等待方任务失败
completeWaitFuture(taskId, TaskWaitResult.fail(errorMessage));
}
/**
* 运行超时自动失败并重试:
* - task.status=RUNNING 且 leaseExpireTime 已过期:视为本次尝试超时
* - attemptCount < MAX_RETRY_ATTEMPTS:重置为 PENDING 等待重试
* - attemptCount >= MAX_RETRY_ATTEMPTS:最终失败并落库
*/
@Scheduled(fixedDelay = 1000L)
public void timeoutFailAndRetry() {
List<Long> retryRecordIds = new ArrayList<>();
Map<Long, String> failRecordMessages = new HashMap<>();
Map<Long, String> failTaskMessages = new HashMap<>(); // taskId -> errorMessage
synchronized (taskLock) {
long now = System.currentTimeMillis();
for (PuzzleEdgeRenderTaskEntity task : taskCache.asMap().values()) {
if (task == null || task.getId() == null || task.getStatus() == null) {
continue;
}
if (task.getStatus() != STATUS_RUNNING) {
continue;
}
Date leaseExpireTime = task.getLeaseExpireTime();
if (leaseExpireTime == null || leaseExpireTime.getTime() >= now) {
continue;
}
int attemptCount = task.getAttemptCount() != null ? task.getAttemptCount() : 0;
if (attemptCount >= MAX_RETRY_ATTEMPTS) {
String errorMessage = String.format("边缘渲染任务超时(%d秒),重试次数耗尽: attemptCount=%d",
TimeUnit.MILLISECONDS.toSeconds(LEASE_MILLIS), attemptCount);
log.warn("边缘渲染任务最终失败: taskId={}, recordId={}, {}", task.getId(), task.getRecordId(), errorMessage);
task.setStatus(STATUS_FAIL);
task.setWorkerId(null);
task.setLeaseExpireTime(null);
task.setErrorMessage(errorMessage);
task.setUpdateTime(new Date(now));
if (task.getRecordId() != null) {
failRecordMessages.put(task.getRecordId(), errorMessage);
}
// 记录需要通知的任务
failTaskMessages.put(task.getId(), errorMessage);
continue;
}
log.warn("边缘渲染任务超时,准备重试: taskId={}, recordId={}, attemptCount={}",
task.getId(), task.getRecordId(), attemptCount);
task.setStatus(STATUS_PENDING);
task.setWorkerId(null);
task.setLeaseExpireTime(null);
task.setErrorMessage("边缘渲染任务超时,等待重试");
task.setUpdateTime(new Date(now));
if (task.getRecordId() != null) {
retryRecordIds.add(task.getRecordId());
}
}
}
for (Long recordId : retryRecordIds) {
incrementRecordRetryCount(recordId);
}
for (Map.Entry<Long, String> entry : failRecordMessages.entrySet()) {
recordMapper.updateFail(entry.getKey(), entry.getValue());
}
// 通知等待方任务最终失败
for (Map.Entry<Long, String> entry : failTaskMessages.entrySet()) {
completeWaitFuture(entry.getKey(), TaskWaitResult.fail(entry.getValue()));
}
// 清理过期的等待 future
cleanupExpiredWaitFutures();
}
/**
* 清理过期的等待 future,防止内存泄漏
*/
private void cleanupExpiredWaitFutures() {
long now = System.currentTimeMillis();
Iterator<Map.Entry<Long, WaitFutureEntry>> iterator = waitFutures.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Long, WaitFutureEntry> entry = iterator.next();
if (entry.getValue().isExpired(now)) {
entry.getValue().future.complete(TaskWaitResult.fail("等待超时(内部清理)"));
iterator.remove();
}
}
}
private void incrementRecordRetryCount(Long recordId) {
if (recordId == null) {
return;
}
try {
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
if (record == null) {
return;
}
int currentRetryCount = record.getRetryCount() != null ? record.getRetryCount() : 0;
PuzzleGenerationRecordEntity update = new PuzzleGenerationRecordEntity();
update.setId(recordId);
update.setRetryCount(currentRetryCount + 1);
recordMapper.update(update);
} catch (Exception e) {
log.warn("更新生成记录重试次数失败: recordId={}, error={}", recordId, e.getMessage());
}
}
/**
* 创建边缘渲染任务(供中心业务侧调用)
*/
public Long createRenderTask(PuzzleGenerationRecordEntity record,
PuzzleTemplateEntity template,
List<PuzzleElementEntity> sortedElements,
Map<String, String> finalDynamicData,
String outputFormat,
Integer quality) {
if (record == null || record.getId() == null) {
throw new IllegalArgumentException("record不能为空");
}
if (template == null || template.getId() == null) {
throw new IllegalArgumentException("template不能为空");
}
if (sortedElements == null) {
sortedElements = List.of();
}
if (finalDynamicData == null) {
finalDynamicData = Map.of();
}
String normalizedFormat = normalizeOutputFormat(outputFormat);
Integer outputQuality = quality != null ? quality : 90;
String ext = "PNG".equals(normalizedFormat) ? "png" : "jpeg";
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
String originalObjectKey = String.format("puzzle/%s/%s", template.getCode(), fileName);
String croppedObjectKey = StrUtil.isNotBlank(template.getUserArea())
? String.format("puzzle/%s_cropped/%s", template.getCode(), fileName)
: null;
Map<String, Object> payload = new HashMap<>();
payload.put("recordId", record.getId());
Map<String, Object> templatePayload = new HashMap<>();
templatePayload.put("id", template.getId());
templatePayload.put("code", template.getCode());
templatePayload.put("canvasWidth", template.getCanvasWidth());
templatePayload.put("canvasHeight", template.getCanvasHeight());
templatePayload.put("backgroundType", template.getBackgroundType());
templatePayload.put("backgroundColor", template.getBackgroundColor());
templatePayload.put("backgroundImage", template.getBackgroundImage());
templatePayload.put("userArea", template.getUserArea());
payload.put("template", templatePayload);
List<Map<String, Object>> elementPayloadList = new ArrayList<>();
for (PuzzleElementEntity e : sortedElements) {
Map<String, Object> elementPayload = new HashMap<>();
elementPayload.put("id", e.getId());
elementPayload.put("type", e.getElementType());
elementPayload.put("key", e.getElementKey());
elementPayload.put("name", e.getElementName());
elementPayload.put("x", e.getXPosition());
elementPayload.put("y", e.getYPosition());
elementPayload.put("width", e.getWidth());
elementPayload.put("height", e.getHeight());
elementPayload.put("zIndex", e.getZIndex());
elementPayload.put("rotation", e.getRotation());
elementPayload.put("opacity", e.getOpacity());
elementPayload.put("config", e.getConfig());
elementPayloadList.add(elementPayload);
}
payload.put("elements", elementPayloadList);
payload.put("dynamicData", finalDynamicData);
Map<String, Object> outputPayload = new HashMap<>();
outputPayload.put("format", normalizedFormat);
outputPayload.put("quality", outputQuality);
payload.put("output", outputPayload);
PuzzleEdgeRenderTaskEntity task = new PuzzleEdgeRenderTaskEntity();
task.setRecordId(record.getId());
task.setTemplateId(template.getId());
task.setTemplateCode(template.getCode());
task.setScenicId(record.getScenicId());
task.setFaceId(record.getFaceId());
task.setContentHash(record.getContentHash());
task.setStatus(STATUS_PENDING);
task.setAttemptCount(0);
task.setOutputFormat(normalizedFormat);
task.setOutputQuality(outputQuality);
task.setOriginalObjectKey(originalObjectKey);
task.setCroppedObjectKey(croppedObjectKey);
task.setPayloadJson(JacksonUtil.toJson(payload));
Long taskId = taskIdSequence.incrementAndGet();
Date now = new Date();
task.setId(taskId);
task.setCreateTime(now);
task.setUpdateTime(now);
taskCache.put(taskId, task);
return taskId;
}
/**
* 注册任务等待,返回用于等待的 CompletableFuture
* 调用方应在 createRenderTask 之后立即调用此方法
*
* @param taskId 任务ID
* @return CompletableFuture,可用于同步等待或异步处理
*/
public CompletableFuture<TaskWaitResult> registerWait(Long taskId) {
if (taskId == null) {
CompletableFuture<TaskWaitResult> failedFuture = new CompletableFuture<>();
failedFuture.complete(TaskWaitResult.fail("taskId不能为空"));
return failedFuture;
}
CompletableFuture<TaskWaitResult> future = new CompletableFuture<>();
waitFutures.put(taskId, new WaitFutureEntry(future));
return future;
}
/**
* 伪同步等待任务完成
* 阻塞当前线程直到任务成功、失败或超时
*
* @param taskId 任务ID
* @param timeoutMs 超时时间(毫秒)
* @return 任务结果,包含成功/失败状态和相关信息
*/
public TaskWaitResult waitForTask(Long taskId, long timeoutMs) {
if (taskId == null) {
return TaskWaitResult.fail("taskId不能为空");
}
// 检查任务是否已完成
PuzzleEdgeRenderTaskEntity task = taskCache.getIfPresent(taskId);
if (task != null) {
if (task.getStatus() != null && task.getStatus() == STATUS_SUCCESS) {
return buildSuccessResult(task);
}
if (task.getStatus() != null && task.getStatus() == STATUS_FAIL) {
return TaskWaitResult.fail(task.getErrorMessage());
}
}
// 获取或创建等待 future
WaitFutureEntry entry = waitFutures.computeIfAbsent(taskId, k -> new WaitFutureEntry(new CompletableFuture<>()));
try {
return entry.future.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
waitFutures.remove(taskId);
return TaskWaitResult.fail("等待任务超时");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
waitFutures.remove(taskId);
return TaskWaitResult.fail("等待被中断");
} catch (ExecutionException e) {
waitFutures.remove(taskId);
return TaskWaitResult.fail("等待出错: " + e.getCause().getMessage());
}
}
/**
* 创建任务并同步等待结果(便捷方法)
*/
public TaskWaitResult createAndWait(PuzzleGenerationRecordEntity record,
PuzzleTemplateEntity template,
List<PuzzleElementEntity> sortedElements,
Map<String, String> finalDynamicData,
String outputFormat,
Integer quality,
long timeoutMs) {
Long taskId = createRenderTask(record, template, sortedElements, finalDynamicData, outputFormat, quality);
registerWait(taskId);
return waitForTask(taskId, timeoutMs);
}
private TaskWaitResult buildSuccessResult(PuzzleEdgeRenderTaskEntity task) {
IStorageAdapter storage = StorageFactory.use();
String imageUrl = storage.getUrl(task.getOriginalObjectKey());
return TaskWaitResult.success(imageUrl);
}
/**
* 完成任务等待(内部调用)
*/
private void completeWaitFuture(Long taskId, TaskWaitResult result) {
WaitFutureEntry entry = waitFutures.remove(taskId);
if (entry != null && entry.future != null) {
entry.future.complete(result);
}
}
private PuzzleEdgeRenderTaskDTO toTaskDTOOrFail(PuzzleEdgeRenderTaskEntity task, Long workerId) {
try {
PuzzleEdgeRenderTaskDTO dto = new PuzzleEdgeRenderTaskDTO();
dto.setId(task.getId());
dto.setRecordId(task.getRecordId());
dto.setTemplateId(task.getTemplateId());
dto.setTemplateCode(task.getTemplateCode());
dto.setScenicId(task.getScenicId());
dto.setFaceId(task.getFaceId());
dto.setAttemptCount(task.getAttemptCount());
dto.setOutputFormat(task.getOutputFormat());
dto.setOutputQuality(task.getOutputQuality());
dto.setPayload(JacksonUtil.fromJson(task.getPayloadJson(), new TypeReference<Map<String, Object>>() {}));
dto.setUpload(buildUploadUrls(task));
return dto;
} catch (Exception e) {
String errorMessage = "任务数据组装失败: " + e.getMessage();
log.error("边缘渲染任务组装失败: taskId={}, recordId={}", task.getId(), task.getRecordId(), e);
tryMarkFail(task, workerId, errorMessage);
recordMapper.updateFail(task.getRecordId(), errorMessage);
return null;
}
}
private PuzzleEdgeUploadUrlsResponse buildUploadUrls(PuzzleEdgeRenderTaskEntity task) {
String outputFormat = normalizeOutputFormat(task.getOutputFormat());
String contentType = resolveContentType(outputFormat);
Date expireAt = new Date(System.currentTimeMillis() + UPLOAD_URL_EXPIRE_MILLIS);
IStorageAdapter storage = StorageFactory.use();
PuzzleEdgeUploadUrlsResponse resp = new PuzzleEdgeUploadUrlsResponse();
resp.setExpireAt(expireAt);
Map<String, String> headers = Map.of("Content-Type", contentType);
PuzzleEdgeUploadUrlsResponse.UploadTarget original = new PuzzleEdgeUploadUrlsResponse.UploadTarget();
original.setMethod("PUT");
original.setHeaders(headers);
original.setPublicUrl(storage.getUrl(task.getOriginalObjectKey()));
original.setUrl(storage.getUrlForUpload(expireAt, contentType, task.getOriginalObjectKey()));
resp.setOriginal(original);
if (StrUtil.isNotBlank(task.getCroppedObjectKey())) {
PuzzleEdgeUploadUrlsResponse.UploadTarget cropped = new PuzzleEdgeUploadUrlsResponse.UploadTarget();
cropped.setMethod("PUT");
cropped.setHeaders(headers);
cropped.setPublicUrl(storage.getUrl(task.getCroppedObjectKey()));
cropped.setUrl(storage.getUrlForUpload(expireAt, contentType, task.getCroppedObjectKey()));
resp.setCropped(cropped);
}
return resp;
}
private PuzzleEdgeRenderTaskEntity claimOne(Long workerId) {
synchronized (taskLock) {
long now = System.currentTimeMillis();
Long selectedTaskId = null;
for (Map.Entry<Long, PuzzleEdgeRenderTaskEntity> entry : taskCache.asMap().entrySet()) {
PuzzleEdgeRenderTaskEntity task = entry.getValue();
if (!isClaimable(task, now)) {
continue;
}
Long id = entry.getKey();
if (selectedTaskId == null || id < selectedTaskId) {
selectedTaskId = id;
}
}
if (selectedTaskId == null) {
return null;
}
PuzzleEdgeRenderTaskEntity task = taskCache.getIfPresent(selectedTaskId);
if (!isClaimable(task, now)) {
return null;
}
Date leaseExpireTime = new Date(now + LEASE_MILLIS);
task.setWorkerId(workerId);
task.setStatus(STATUS_RUNNING);
task.setLeaseExpireTime(leaseExpireTime);
task.setAttemptCount((task.getAttemptCount() != null ? task.getAttemptCount() : 0) + 1);
task.setUpdateTime(new Date(now));
return task;
}
}
private PuzzleEdgeRenderTaskEntity getAndCheckRunningTask(Long taskId, Long workerId) {
if (taskId == null) {
throw new IllegalArgumentException("taskId不能为空");
}
synchronized (taskLock) {
PuzzleEdgeRenderTaskEntity task = taskCache.getIfPresent(taskId);
if (task == null) {
throw new IllegalArgumentException("任务不存在");
}
if (task.getStatus() == null || task.getStatus() != STATUS_RUNNING) {
throw new IllegalArgumentException("任务状态非法");
}
if (task.getWorkerId() == null || !task.getWorkerId().equals(workerId)) {
throw new IllegalArgumentException("任务不属于当前worker");
}
return task;
}
}
private PuzzleEdgeRenderTaskEntity getAndCheckTaskOwned(Long taskId, Long workerId) {
if (taskId == null) {
throw new IllegalArgumentException("taskId不能为空");
}
synchronized (taskLock) {
PuzzleEdgeRenderTaskEntity task = taskCache.getIfPresent(taskId);
if (task == null) {
throw new IllegalArgumentException("任务不存在");
}
if (task.getWorkerId() == null || !task.getWorkerId().equals(workerId)) {
throw new IllegalArgumentException("任务不属于当前worker");
}
return task;
}
}
private boolean tryMarkSuccess(PuzzleEdgeRenderTaskEntity task, Long workerId) {
synchronized (taskLock) {
if (task == null || task.getId() == null) {
return false;
}
if (task.getStatus() == null || task.getStatus() != STATUS_RUNNING) {
return false;
}
if (task.getWorkerId() == null || !task.getWorkerId().equals(workerId)) {
return false;
}
task.setStatus(STATUS_SUCCESS);
task.setLeaseExpireTime(null);
task.setErrorMessage(null);
task.setUpdateTime(new Date());
return true;
}
}
private boolean tryMarkFail(PuzzleEdgeRenderTaskEntity task, Long workerId, String errorMessage) {
synchronized (taskLock) {
if (task == null || task.getId() == null) {
return false;
}
if (task.getStatus() == null || task.getStatus() != STATUS_RUNNING) {
return false;
}
if (task.getWorkerId() == null || !task.getWorkerId().equals(workerId)) {
return false;
}
task.setStatus(STATUS_FAIL);
task.setLeaseExpireTime(null);
task.setErrorMessage(errorMessage);
task.setUpdateTime(new Date());
return true;
}
}
private boolean isClaimable(PuzzleEdgeRenderTaskEntity task, long nowMillis) {
if (task == null || task.getId() == null || task.getStatus() == null) {
return false;
}
if (task.getStatus() == STATUS_PENDING) {
int attemptCount = task.getAttemptCount() != null ? task.getAttemptCount() : 0;
return attemptCount < MAX_RETRY_ATTEMPTS;
}
if (task.getStatus() != STATUS_RUNNING) {
return false;
}
Date leaseExpireTime = task.getLeaseExpireTime();
if (leaseExpireTime == null || leaseExpireTime.getTime() >= nowMillis) {
return false;
}
int attemptCount = task.getAttemptCount() != null ? task.getAttemptCount() : 0;
return attemptCount < MAX_RETRY_ATTEMPTS;
}
private RenderWorkerEntity requireWorker(String accessKey) {
if (StrUtil.isBlank(accessKey)) {
throw new IllegalArgumentException("accessKey不能为空");
}
RenderWorkerEntity worker = renderWorkerRepository.getWorkerByAccessKey(accessKey);
if (worker == null) {
throw new IllegalArgumentException("worker不存在");
}
if (worker.getStatus() == null || worker.getStatus() != 1) {
throw new IllegalArgumentException("worker未启用");
}
return worker;
}
private String normalizeOutputFormat(String format) {
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
if ("JPG".equals(outputFormat)) {
outputFormat = "JPEG";
}
if (!"PNG".equals(outputFormat) && !"JPEG".equals(outputFormat)) {
outputFormat = "PNG";
}
return outputFormat;
}
private String resolveContentType(String outputFormat) {
return "PNG".equals(outputFormat) ? "image/png" : "image/jpeg";
}
}

View File

@@ -0,0 +1,260 @@
package com.ycwl.basic.puzzle.repository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 拼图模块缓存仓库
* 提供模板和元素的缓存读取,减少数据库查询
*
* @author Claude
* @since 2025-01-01
*/
@Slf4j
@Repository
public class PuzzleRepository {
private final PuzzleTemplateMapper templateMapper;
private final PuzzleElementMapper elementMapper;
private final RedisTemplate<String, String> redisTemplate;
/**
* 模板缓存KEY(根据code)
*/
private static final String PUZZLE_TEMPLATE_BY_CODE_KEY = "puzzle:template:code:%s";
/**
* 模板缓存KEY(根据id)
*/
private static final String PUZZLE_TEMPLATE_BY_ID_KEY = "puzzle:template:id:%s";
/**
* 元素列表缓存KEY(根据templateId)
*/
private static final String PUZZLE_ELEMENTS_BY_TEMPLATE_KEY = "puzzle:elements:templateId:%s";
/**
* 缓存过期时间(小时)
*/
private static final long CACHE_EXPIRE_HOURS = 24;
public PuzzleRepository(
PuzzleTemplateMapper templateMapper,
PuzzleElementMapper elementMapper,
RedisTemplate<String, String> redisTemplate) {
this.templateMapper = templateMapper;
this.elementMapper = elementMapper;
this.redisTemplate = redisTemplate;
}
// ==================== 模板缓存 ====================
/**
* 根据模板编码获取模板(优先从缓存读取)
*
* @param code 模板编码
* @return 模板实体,不存在返回null
*/
public PuzzleTemplateEntity getTemplateByCode(String code) {
String cacheKey = String.format(PUZZLE_TEMPLATE_BY_CODE_KEY, code);
// 1. 尝试从缓存读取
Boolean hasKey = redisTemplate.hasKey(cacheKey);
if (Boolean.TRUE.equals(hasKey)) {
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
log.debug("从缓存读取模板: code={}", code);
return JacksonUtil.parseObject(cacheValue, PuzzleTemplateEntity.class);
}
}
// 2. 从数据库查询
PuzzleTemplateEntity template = templateMapper.getByCode(code);
if (template == null) {
return null;
}
// 3. 写入缓存
cacheTemplate(template);
log.debug("模板缓存写入: code={}, id={}", code, template.getId());
return template;
}
/**
* 根据模板ID获取模板(优先从缓存读取)
*
* @param id 模板ID
* @return 模板实体,不存在返回null
*/
public PuzzleTemplateEntity getTemplateById(Long id) {
String cacheKey = String.format(PUZZLE_TEMPLATE_BY_ID_KEY, id);
// 1. 尝试从缓存读取
Boolean hasKey = redisTemplate.hasKey(cacheKey);
if (Boolean.TRUE.equals(hasKey)) {
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
log.debug("从缓存读取模板: id={}", id);
return JacksonUtil.parseObject(cacheValue, PuzzleTemplateEntity.class);
}
}
// 2. 从数据库查询
PuzzleTemplateEntity template = templateMapper.getById(id);
if (template == null) {
return null;
}
// 3. 写入缓存
cacheTemplate(template);
log.debug("模板缓存写入: id={}, code={}", id, template.getCode());
return template;
}
/**
* 缓存模板(同时缓存 byId 和 byCode)
*/
private void cacheTemplate(PuzzleTemplateEntity template) {
if (template == null) {
return;
}
String json = JacksonUtil.toJSONString(template);
// 同时缓存 byId 和 byCode
String idKey = String.format(PUZZLE_TEMPLATE_BY_ID_KEY, template.getId());
String codeKey = String.format(PUZZLE_TEMPLATE_BY_CODE_KEY, template.getCode());
redisTemplate.opsForValue().set(idKey, json, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
redisTemplate.opsForValue().set(codeKey, json, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
}
/**
* 清除模板缓存
*
* @param id 模板ID
* @param code 模板编码(可为null,此时需要先查询获取)
*/
public void clearTemplateCache(Long id, String code) {
// 如果没有传code,尝试从缓存或数据库获取
if (code == null && id != null) {
String idKey = String.format(PUZZLE_TEMPLATE_BY_ID_KEY, id);
String cacheValue = redisTemplate.opsForValue().get(idKey);
if (cacheValue != null) {
PuzzleTemplateEntity template = JacksonUtil.parseObject(cacheValue, PuzzleTemplateEntity.class);
code = template.getCode();
} else {
PuzzleTemplateEntity template = templateMapper.getById(id);
if (template != null) {
code = template.getCode();
}
}
}
// 清除 byId 缓存
if (id != null) {
String idKey = String.format(PUZZLE_TEMPLATE_BY_ID_KEY, id);
redisTemplate.delete(idKey);
log.debug("清除模板缓存: id={}", id);
}
// 清除 byCode 缓存
if (code != null) {
String codeKey = String.format(PUZZLE_TEMPLATE_BY_CODE_KEY, code);
redisTemplate.delete(codeKey);
log.debug("清除模板缓存: code={}", code);
}
// 同时清除该模板的元素缓存
if (id != null) {
clearElementsCache(id);
}
}
// ==================== 元素缓存 ====================
/**
* 根据模板ID获取元素列表(优先从缓存读取)
*
* @param templateId 模板ID
* @return 元素列表
*/
public List<PuzzleElementEntity> getElementsByTemplateId(Long templateId) {
String cacheKey = String.format(PUZZLE_ELEMENTS_BY_TEMPLATE_KEY, templateId);
// 1. 尝试从缓存读取
Boolean hasKey = redisTemplate.hasKey(cacheKey);
if (Boolean.TRUE.equals(hasKey)) {
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
log.debug("从缓存读取元素列表: templateId={}", templateId);
return JacksonUtil.parseObject(cacheValue, new TypeReference<List<PuzzleElementEntity>>() {});
}
}
// 2. 从数据库查询
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(templateId);
// 3. 写入缓存(即使是空列表也要缓存,避免缓存穿透)
String json = JacksonUtil.toJSONString(elements);
redisTemplate.opsForValue().set(cacheKey, json, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
log.debug("元素列表缓存写入: templateId={}, count={}", templateId, elements.size());
return elements;
}
/**
* 清除元素缓存
*
* @param templateId 模板ID
*/
public void clearElementsCache(Long templateId) {
String cacheKey = String.format(PUZZLE_ELEMENTS_BY_TEMPLATE_KEY, templateId);
redisTemplate.delete(cacheKey);
log.debug("清除元素缓存: templateId={}", templateId);
}
// ==================== 批量清除 ====================
/**
* 清除所有拼图相关缓存(慎用)
* 使用 SCAN 命令避免 KEYS 阻塞
*/
public void clearAllPuzzleCache() {
log.warn("开始清除所有拼图缓存...");
// 使用 SCAN 删除模板缓存
deleteByPattern("puzzle:template:*");
// 使用 SCAN 删除元素缓存
deleteByPattern("puzzle:elements:*");
log.warn("拼图缓存清除完成");
}
/**
* 根据模式删除缓存
* 使用 SCAN 命令避免阻塞
*/
private void deleteByPattern(String pattern) {
try {
var keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
log.debug("删除缓存: pattern={}, count={}", pattern, keys.size());
}
} catch (Exception e) {
log.error("删除缓存失败: pattern={}", pattern, e);
}
}
}

View File

@@ -12,10 +12,29 @@ import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
public interface IPuzzleGenerateService {
/**
* 生成拼图图片
* 生成拼图图片(默认同步模式)
*
* @param request 生成请求
* @return 生成结果(包含图片URL等信息)
*/
PuzzleGenerateResponse generate(PuzzleGenerateRequest request);
/**
* 同步生成拼图图片
* <p>立即执行并阻塞等待结果返回</p>
*
* @param request 生成请求
* @return 生成结果(包含图片URL等信息)
*/
PuzzleGenerateResponse generateSync(PuzzleGenerateRequest request);
/**
* 异步生成拼图图片
* <p>提交到队列,由固定大小的线程池异步处理,不等待结果</p>
* <p>队列满时会降级为同步执行(CallerRunsPolicy)</p>
*
* @param request 生成请求
* @return 生成记录ID(可用于后续追踪状态)
*/
Long generateAsync(PuzzleGenerateRequest request);
}

View File

@@ -5,14 +5,14 @@ import cn.hutool.json.JSONUtil;
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.fill.FillResult;
import com.ycwl.basic.puzzle.fill.PuzzleElementFillEngine;
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
@@ -20,7 +20,6 @@ import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.utils.WxMpUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -47,36 +46,233 @@ import java.util.UUID;
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
private final PuzzleTemplateMapper templateMapper;
private final PuzzleElementMapper elementMapper;
private final PuzzleRepository puzzleRepository;
private final PuzzleGenerationRecordMapper recordMapper;
@Lazy
private final PuzzleImageRenderer imageRenderer;
@Lazy
private final PuzzleElementFillEngine fillEngine;
@Lazy
private final ScenicRepository scenicRepository;
@Lazy
private final PuzzleDuplicationDetector duplicationDetector;
@Lazy
private final PrinterService printerService;
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
public PuzzleGenerateServiceImpl(
PuzzleRepository puzzleRepository,
PuzzleGenerationRecordMapper recordMapper,
@Lazy PuzzleImageRenderer imageRenderer,
@Lazy PuzzleElementFillEngine fillEngine,
@Lazy ScenicRepository scenicRepository,
@Lazy PuzzleDuplicationDetector duplicationDetector,
@Lazy PrinterService printerService,
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService) {
this.puzzleRepository = puzzleRepository;
this.recordMapper = recordMapper;
this.imageRenderer = imageRenderer;
this.fillEngine = fillEngine;
this.scenicRepository = scenicRepository;
this.duplicationDetector = duplicationDetector;
this.printerService = printerService;
this.puzzleEdgeRenderTaskService = puzzleEdgeRenderTaskService;
}
@Override
public PuzzleGenerateResponse generate(PuzzleGenerateRequest request) {
// 默认使用同步模式
return generateSync(request);
}
@Override
public PuzzleGenerateResponse generateSync(PuzzleGenerateRequest request) {
long startTime = System.currentTimeMillis();
log.info("开始同步生成拼图(边缘渲染模式): templateCode={}, userId={}, faceId={}",
request.getTemplateCode(), request.getUserId(), request.getFaceId());
// 1. 参数校验
validateRequest(request);
// 2. 查询模板(使用缓存)
PuzzleTemplateEntity template = puzzleRepository.getTemplateByCode(request.getTemplateCode());
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + request.getTemplateCode());
}
if (template.getStatus() != 1) {
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
}
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
// 3. 查询并排序元素
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
if (elements.isEmpty()) {
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
}
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
Comparator.nullsFirst(Comparator.naturalOrder())));
// 4. 构建dynamicData
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
// 5. 重复图片检测(可能抛出DuplicateImageException)
duplicationDetector.detectDuplicateImages(finalDynamicData, elements);
// 6. 内容去重检测
String contentHash = duplicationDetector.calculateContentHash(finalDynamicData);
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);
return PuzzleGenerateResponse.success(
duplicateRecord.getResultImageUrl(),
duplicateRecord.getResultFileSize(),
duplicateRecord.getResultWidth(),
duplicateRecord.getResultHeight(),
(int) duration,
duplicateRecord.getId(),
true,
duplicateRecord.getId()
);
}
// 7. 创建生成记录
PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId);
record.setContentHash(contentHash);
recordMapper.insert(record);
// 8. 创建边缘渲染任务并等待完成
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
record,
template,
elements,
finalDynamicData,
request.getOutputFormat(),
request.getQuality()
);
puzzleEdgeRenderTaskService.registerWait(taskId);
log.info("同步拼图任务已提交边缘渲染,等待完成: recordId={}, taskId={}", record.getId(), taskId);
// 9. 等待任务完成(30秒超时)
PuzzleEdgeRenderTaskService.TaskWaitResult waitResult =
puzzleEdgeRenderTaskService.waitForTask(taskId, 30_000);
long duration = System.currentTimeMillis() - startTime;
if (waitResult.isSuccess()) {
log.info("同步拼图边缘渲染完成: recordId={}, taskId={}, imageUrl={}, duration={}ms",
record.getId(), taskId, waitResult.getImageUrl(), duration);
// 重新查询记录获取完整信息(边缘渲染回调已更新)
PuzzleGenerationRecordEntity updatedRecord = recordMapper.getById(record.getId());
if (updatedRecord != null && updatedRecord.getResultImageUrl() != null) {
return PuzzleGenerateResponse.success(
updatedRecord.getResultImageUrl(),
updatedRecord.getResultFileSize(),
updatedRecord.getResultWidth(),
updatedRecord.getResultHeight(),
(int) duration,
updatedRecord.getId()
);
}
// 回调可能还未完全写入,使用等待结果中的URL
return PuzzleGenerateResponse.success(
waitResult.getImageUrl(),
null,
template.getCanvasWidth(),
template.getCanvasHeight(),
(int) duration,
record.getId()
);
} else {
log.error("同步拼图边缘渲染失败: recordId={}, taskId={}, error={}, duration={}ms",
record.getId(), taskId, waitResult.getErrorMessage(), duration);
throw new RuntimeException("拼图生成失败: " + waitResult.getErrorMessage());
}
}
@Override
public Long generateAsync(PuzzleGenerateRequest request) {
long startTime = System.currentTimeMillis();
log.info("开始创建异步拼图边缘渲染任务: templateCode={}, userId={}, faceId={}",
request.getTemplateCode(), request.getUserId(), request.getFaceId());
// 1. 参数校验
validateRequest(request);
// 2. 查询模板(使用缓存)
PuzzleTemplateEntity template = puzzleRepository.getTemplateByCode(request.getTemplateCode());
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + request.getTemplateCode());
}
if (template.getStatus() != 1) {
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
}
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
// 3. 查询并排序元素
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
if (elements.isEmpty()) {
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
}
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
Comparator.nullsFirst(Comparator.naturalOrder())));
// 4. 构建dynamicData
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
// 5. 重复图片检测(可能抛出DuplicateImageException)
duplicationDetector.detectDuplicateImages(finalDynamicData, elements);
// 6. 内容去重检测
String contentHash = duplicationDetector.calculateContentHash(finalDynamicData);
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);
return duplicateRecord.getId();
}
// 7. 创建生成记录
PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId);
record.setContentHash(contentHash);
recordMapper.insert(record);
// 8. 创建边缘渲染任务
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
record,
template,
elements,
finalDynamicData,
request.getOutputFormat(),
request.getQuality()
);
long duration = System.currentTimeMillis() - startTime;
log.info("异步拼图任务已进入边缘渲染队列: recordId={}, taskId={}, templateCode={}, duration={}ms",
record.getId(), taskId, request.getTemplateCode(), duration);
return record.getId();
}
/**
* 核心生成逻辑(同步执行)
*/
private PuzzleGenerateResponse doGenerate(PuzzleGenerateRequest request) {
long startTime = System.currentTimeMillis();
log.info("开始生成拼图: templateCode={}, userId={}, faceId={}",
request.getTemplateCode(), request.getUserId(), request.getFaceId());
// 业务层校验:faceId 必填
if (request.getFaceId() == null) {
throw new IllegalArgumentException("人脸ID不能为空");
}
// 参数校验
validateRequest(request);
// 1. 查询模板和元素
PuzzleTemplateEntity template = templateMapper.getByCode(request.getTemplateCode());
// 1. 查询模板和元素(使用缓存)
PuzzleTemplateEntity template = puzzleRepository.getTemplateByCode(request.getTemplateCode());
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + request.getTemplateCode());
}
@@ -88,7 +284,7 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
// 2. 校验景区隔离
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
if (elements.isEmpty()) {
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
}
@@ -135,16 +331,66 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
record.setContentHash(contentHash);
recordMapper.insert(record);
// 9. 执行核心生成逻辑
return doGenerateInternal(request, template, resolvedScenicId, record, startTime);
}
/**
* 校验请求参数
*/
private void validateRequest(PuzzleGenerateRequest request) {
if (request.getFaceId() == null) {
throw new IllegalArgumentException("人脸ID不能为空");
}
}
/**
* 核心生成逻辑(内部方法,同步/异步共用)
* 注意:此方法会在调用线程中执行渲染和上传操作
*
* @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 {
// 9. 渲染图片
// 渲染图片
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
// 10. 上传原图到OSS(未裁切)
// 上传原图到OSS(未裁切)
String originalImageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("原图上传成功: url={}", originalImageUrl);
// 11. 处理用户区域裁切
String finalImageUrl = originalImageUrl; // 默认使用原图
// 处理用户区域裁切
String finalImageUrl = originalImageUrl;
BufferedImage finalImage = resultImage;
if (StrUtil.isNotBlank(template.getUserArea())) {
@@ -155,12 +401,11 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
log.info("裁切后图片上传成功: userArea={}, url={}", template.getUserArea(), finalImageUrl);
} catch (Exception e) {
log.error("图片裁切失败,使用原图: userArea={}", template.getUserArea(), e);
// 裁切失败时使用原图
}
}
// 12. 更新记录为成功
long duration = (int) (System.currentTimeMillis() - startTime);
// 更新记录为成功
long duration = System.currentTimeMillis() - startTime;
long fileSize = estimateFileSize(finalImage, request.getOutputFormat());
recordMapper.updateSuccess(
record.getId(),
@@ -172,23 +417,22 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
(int) duration
);
log.info("拼图生成成功(新生成): recordId={}, originalUrl={}, finalUrl={}, duration={}ms",
log.info("拼图生成成功: recordId={}, originalUrl={}, finalUrl={}, duration={}ms",
record.getId(), originalImageUrl, finalImageUrl, duration);
// 13. 检查是否自动添加到打印队列
// 检查是否自动添加到打印队列
if (template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
try {
Integer printRecordId = printerService.addUserPhotoFromPuzzle(
request.getUserId(),
resolvedScenicId,
request.getFaceId(),
originalImageUrl, // 使用原图URL添加到打印队列
originalImageUrl,
record.getId()
);
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
} catch (Exception e) {
log.error("自动添加到打印队列失败: recordId={}", record.getId(), e);
// 添加失败不影响拼图生成流程
}
}
@@ -199,13 +443,12 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
finalImage.getHeight(),
(int) duration,
record.getId(),
false, // isDuplicate=false
null // originalRecordId=null
false,
null
);
} catch (Exception e) {
log.error("拼图生成失败: templateCode={}", request.getTemplateCode(), e);
// 更新记录为失败
recordMapper.updateFail(record.getId(), e.getMessage());
throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
}

View File

@@ -12,6 +12,7 @@ import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
import com.ycwl.basic.puzzle.service.IPuzzleTemplateService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -37,6 +38,7 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
private final PuzzleTemplateMapper templateMapper;
private final PuzzleElementMapper elementMapper;
private final PuzzleRepository puzzleRepository;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -70,6 +72,7 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
}
// 如果修改了编码,检查新编码是否已存在
String oldCode = existing.getCode();
if (request.getCode() != null && !request.getCode().equals(existing.getCode())) {
int count = templateMapper.countByCode(request.getCode(), id);
if (count > 0) {
@@ -82,6 +85,12 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
entity.setId(id);
templateMapper.update(entity);
// 清除缓存(如果修改了code,需要同时清除新旧code的缓存)
puzzleRepository.clearTemplateCache(id, oldCode);
if (request.getCode() != null && !request.getCode().equals(oldCode)) {
puzzleRepository.clearTemplateCache(null, request.getCode());
}
log.info("拼图模板更新成功: id={}", id);
}
@@ -100,6 +109,9 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
templateMapper.deleteById(id);
elementMapper.deleteByTemplateId(id);
// 清除缓存
puzzleRepository.clearTemplateCache(id, existing.getCode());
log.info("拼图模板删除成功: id={}, 同时删除了关联的元素", id);
}
@@ -196,7 +208,10 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
// 4. 插入数据库
elementMapper.insert(entity);
log.info("元素添加成功: id={}, type={}, key={}",
// 5. 清除元素缓存
puzzleRepository.clearElementsCache(request.getTemplateId());
log.info("元素添加成功: id={}, type={}, key={}",
entity.getId(), entity.getElementType(), entity.getElementKey());
return entity.getId();
}
@@ -225,6 +240,8 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
// 3. 批量插入
if (!entityList.isEmpty()) {
elementMapper.batchInsert(entityList);
// 4. 清除元素缓存
puzzleRepository.clearElementsCache(templateId);
log.info("批量添加元素成功: templateId={}, count={}", templateId, entityList.size());
}
}
@@ -293,6 +310,9 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
log.info("批量替换元素完成: templateId={}, deleted={}, updated={}, inserted={}",
templateId, deletedCount, updatedCount, insertedCount);
// 7. 清除元素缓存
puzzleRepository.clearElementsCache(templateId);
}
@Override
@@ -314,6 +334,9 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
entity.setId(id);
elementMapper.update(entity);
// 4. 清除元素缓存
puzzleRepository.clearElementsCache(existing.getTemplateId());
log.info("元素更新成功: id={}", id);
}
@@ -329,6 +352,10 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
}
elementMapper.deleteById(id);
// 清除元素缓存
puzzleRepository.clearElementsCache(existing.getTemplateId());
log.info("元素删除成功: id={}", id);
}

View File

@@ -21,17 +21,11 @@ import java.util.List;
* @Date:2024/12/6 10:23
*/
public interface AppScenicService {
ApiResponse<PageInfo<ScenicEntity>> pageQuery(ScenicReqQuery scenicReqQuery);
ApiResponse<ScenicDeviceCountVO> deviceCountByScenicId(Long scenicId);
ApiResponse<ScenicRespVO> getDetails(Long id);
ApiResponse<ScenicLoginRespVO> login(ScenicLoginReq scenicLoginReq) throws Exception;
ApiResponse<ScenicRegisterRespVO> register(ScenicRegisterReq scenicRegisterReq);
List<ScenicAppVO> scenicListByLnLa(ScenicIndexVO scenicIndexVO);
ApiResponse<List<DeviceRespVO>> getDevices(Long scenicId);
}

View File

@@ -69,34 +69,6 @@ public class AppScenicServiceImpl implements AppScenicService {
@Autowired
private ScenicRepository scenicRepository;
@Override
public ApiResponse<PageInfo<ScenicEntity>> pageQuery(ScenicReqQuery scenicReqQuery) {
ScenicReqQuery query = new ScenicReqQuery();
query.setPageSize(1000);
query.setStatus("1");
List<ScenicV2DTO> scenicList = scenicRepository.list(query);
List<ScenicEntity> list = scenicList.stream().map(scenic -> {
return scenicRepository.getScenic(Long.valueOf(scenic.getId()));
}).toList();
PageInfo<ScenicEntity> pageInfo = new PageInfo<>(list);
return ApiResponse.success(pageInfo);
}
@Override
public ApiResponse<ScenicDeviceCountVO> deviceCountByScenicId(Long scenicId) {
JwtInfo worker = JwtTokenUtil.getWorker();
// 通过zt-device服务获取设备统计
PageResponse<DeviceV2DTO> deviceListResponse = deviceIntegrationService.getScenicActiveDevices(scenicId, 1, 1000);
ScenicDeviceCountVO scenicDeviceCountVO = new ScenicDeviceCountVO();
if (deviceListResponse != null && deviceListResponse.getList() != null) {
scenicDeviceCountVO.setTotalDeviceCount(deviceListResponse.getList().size());
} else {
scenicDeviceCountVO.setTotalDeviceCount(0);
}
return ApiResponse.success(scenicDeviceCountVO);
}
@Override
public ApiResponse<ScenicRespVO> getDetails(Long id) {
ScenicEntity scenic = scenicRepository.getScenic(id);
@@ -218,95 +190,6 @@ public class AppScenicServiceImpl implements AppScenicService {
}
}
@Override
public List<ScenicAppVO> scenicListByLnLa(ScenicIndexVO scenicIndexVO) {
// 参数校验
if (scenicIndexVO == null) {
log.warn("scenicListByLnLa 接收到空参数");
return Collections.emptyList();
}
if (scenicIndexVO.getLatitude() == null || scenicIndexVO.getLongitude() == null) {
log.warn("scenicListByLnLa 缺少必要的经纬度参数, latitude={}, longitude={}",
scenicIndexVO.getLatitude(), scenicIndexVO.getLongitude());
return Collections.emptyList();
}
// 从 scenicRepository 获取所有景区(1000个)
ScenicReqQuery query = new ScenicReqQuery();
query.setPageNum(1);
query.setPageSize(1000);
List<ScenicV2DTO> scenicList = scenicRepository.list(query);
if (scenicList == null || scenicList.isEmpty()) {
log.info("未查询到任何景区数据");
return Collections.emptyList();
}
List<ScenicAppVO> list = new ArrayList<>();
// 为每个景区获取详细信息(包含经纬度)
for (ScenicV2DTO scenicDTO : scenicList) {
try {
// ID 格式校验
if (StringUtils.isBlank(scenicDTO.getId())) {
log.warn("景区 ID 为空,跳过该景区");
continue;
}
// 获取景区详细信息(包含经纬度)
ScenicEntity scenicEntity = scenicRepository.getScenic(Long.parseLong(scenicDTO.getId()));
if (scenicEntity == null) {
log.warn("景区详情查询失败, scenicId={}", scenicDTO.getId());
continue;
}
if (scenicEntity.getLatitude() == null || scenicEntity.getLongitude() == null) {
log.warn("景区缺少经纬度信息, scenicId={}, scenicName={}",
scenicEntity.getId(), scenicEntity.getName());
continue;
}
// 计算距离
BigDecimal distance = calculateDistance(
scenicIndexVO.getLatitude(),
scenicIndexVO.getLongitude(),
scenicEntity.getLatitude(),
scenicEntity.getLongitude()
);
// 根据距离和范围筛选景区
if (scenicEntity.getRadius() != null &&
distance.compareTo(scenicEntity.getRadius().multiply(BigDecimal.valueOf(1_000L))) < 0) {
// 转换为 ScenicAppVO
ScenicAppVO scenicAppVO = new ScenicAppVO();
scenicAppVO.setId(scenicEntity.getId());
scenicAppVO.setName(scenicEntity.getName());
scenicAppVO.setCoverUrl(scenicEntity.getCoverUrl());
scenicAppVO.setRadius(scenicEntity.getRadius());
scenicAppVO.setDistance(distance);
// 获取设备数量
List<DeviceV2DTO> devices = deviceRepository.getAllDeviceByScenicId(scenicEntity.getId());
scenicAppVO.setDeviceNum(devices != null ? devices.size() : 0);
list.add(scenicAppVO);
}
} catch (NumberFormatException e) {
log.error("景区 ID 格式错误,无法转换为 Long 类型, scenicId={}, error={}",
scenicDTO.getId(), e.getMessage());
} catch (Exception e) {
log.error("处理景区信息时发生异常, scenicId={}, error={}",
scenicDTO != null ? scenicDTO.getId() : "unknown", e.getMessage(), e);
}
}
log.info("根据经纬度筛选景区完成, 输入坐标=({}, {}), 符合条件的景区数量={}",
scenicIndexVO.getLatitude(), scenicIndexVO.getLongitude(), list.size());
return list;
}
@Override
public ApiResponse<List<DeviceRespVO>> getDevices(Long scenicId) {
PageResponse<DeviceV2DTO> deviceV2ListResponse = deviceIntegrationService.listDevices(1, 1000, null, null, null, 1, scenicId, null);

View File

@@ -3,6 +3,7 @@ package com.ycwl.basic.service.mobile.impl;
import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateUnit;
import cn.hutool.core.date.DateUtil;
import com.ycwl.basic.clickhouse.service.StatsQueryService;
import com.ycwl.basic.utils.JacksonUtil;
import com.ycwl.basic.enums.StatisticEnum;
import com.ycwl.basic.mapper.StatisticsMapper;
@@ -41,6 +42,9 @@ public class AppStatisticsServiceImpl implements AppStatisticsService {
@Autowired
private StatisticsMapper statisticsMapper;
@Autowired
private StatsQueryService statsQueryService;
/**
* 支付订单金额、预览_支付转化率、扫码_付费用户转化率
@@ -210,19 +214,19 @@ public class AppStatisticsServiceImpl implements AppStatisticsService {
// Integer cameraShotOfMemberNum=statisticsMapper.countCameraShotOfMember(query);
//扫码访问人数
// 扫小程序码或景区码进入访问的用户数,包括授权用户(使用OpenID进行精准统计)和未授权用户(使用 UUID统计访问)。但当用户授权时,获取OpenID并与UUID关联,删除本地UUID,避免重复记录。
Integer scanCodeVisitorOfMemberNum=statisticsMapper.countScanCodeOfMember(query);
Integer scanCodeVisitorOfMemberNum=statsQueryService.countScanCodeOfMember(query);
//上传头像(人脸)人数
// 上传了人脸的用户数(包括本地临时ID和获取到OpenID的,同一设备微信获取到OpenID要覆盖掉之前生成的临时ID),上传多张人脸都只算一个人。
Integer uploadFaceOfMemberNum=statisticsMapper.countUploadFaceOfMember(query);
Integer uploadFaceOfMemberNum=statsQueryService.countUploadFaceOfMember(query);
//推送订阅人数
// 只要点了允许通知,哪怕只勾选1条订阅都算
Integer pushOfMemberNum =statisticsMapper.countPushOfMember(query);
Integer pushOfMemberNum =statsQueryService.countPushOfMember(query);
//生成视频人数
// 生成过Vlog视频的用户ID数,要注意屏蔽掉以前没有片段也能生成的情况
Integer completeVideoOfMemberNum =statisticsMapper.countCompleteVideoOfMember(query);
Integer completeVideoOfMemberNum =statsQueryService.countCompleteVideoOfMember(query);
//预览视频人数
// 购买前播放了5秒的视频条数。
Integer previewVideoOfMemberNum =statisticsMapper.countPreviewVideoOfMember(query);
Integer previewVideoOfMemberNum =statsQueryService.countPreviewVideoOfMember(query);
if (previewVideoOfMemberNum==null){
previewVideoOfMemberNum=0;
}
@@ -233,13 +237,13 @@ public class AppStatisticsServiceImpl implements AppStatisticsService {
Integer payOfMemberNum =statisticsMapper.countPayOfMember(query);
//总访问人数
// 通过任何途径访问到小程序的总人数,包括授权用户和未授权用户。
Integer totalVisitorOfMemberNum =statisticsMapper.countTotalVisitorOfMember(query);
Integer totalVisitorOfMemberNum =statsQueryService.countTotalVisitorOfMember(query);
// Integer totalVisitorOfMemberNum =scanCodeVisitorOfMemberNum;
//生成视频条数
// 仅指代生成的Vlog条数,不包含录像原片。
Integer completeOfVideoNum =statisticsMapper.countCompleteOfVideo(query);
Integer completeOfVideoNum =statsQueryService.countCompleteOfVideo(query);
//预览视频条数
Integer previewOfVideoNum =statisticsMapper.countPreviewOfVideo(query);
Integer previewOfVideoNum =statsQueryService.countPreviewOfVideo(query);
//支付订单数
Integer payOfOrderNum =statisticsMapper.countPayOfOrder(query);
//支付订单金额

View File

@@ -40,8 +40,6 @@ public interface FaceService {
List<ContentPageVO> faceContentList(Long faceId);
ApiResponse<List<ContentPageVO>> contentListUseDefaultFace();
void bindFace(Long faceId, Long memberId);
String bindWxaCode(Long faceId);

View File

@@ -1,7 +1,9 @@
package com.ycwl.basic.service.pc.impl;
import cn.hutool.core.date.DateUtil;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.clickhouse.service.StatsQueryService;
import com.ycwl.basic.mapper.BrokerRecordMapper;
import com.ycwl.basic.model.pc.broker.entity.BrokerRecord;
import com.ycwl.basic.model.pc.broker.req.BrokerRecordReqQuery;
@@ -11,8 +13,9 @@ import com.ycwl.basic.service.pc.BrokerRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.List;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
/**
* @Author:longbinbin
@@ -22,6 +25,8 @@ import java.util.List;
public class BrokerRecordServiceImpl implements BrokerRecordService {
@Autowired
private BrokerRecordMapper brokerRecordMapper;
@Autowired
private StatsQueryService statsQueryService;
@Override
public PageInfo<BrokerRecordRespVO> pageQuery(BrokerRecordReqQuery brokerRecordReqQuery) {
@@ -58,7 +63,52 @@ public class BrokerRecordServiceImpl implements BrokerRecordService {
@Override
public List<DailySummaryRespVO> getDailySummaryByBrokerId(Long brokerId, Date startTime, Date endTime) {
return brokerRecordMapper.getDailySummaryByBrokerId(brokerId, startTime, endTime);
// 从 MySQL 获取订单数据
List<HashMap<String, Object>> orderStats = brokerRecordMapper.getDailyOrderStats(brokerId, startTime, endTime);
// 从 ClickHouse/MySQL 获取扫码数据
List<HashMap<String, Object>> scanStats = statsQueryService.getDailyScanStats(brokerId, startTime, endTime);
// 将扫码数据转换为 Map 便于查找
Map<String, Long> scanCountByDate = new HashMap<>();
if (scanStats != null) {
for (HashMap<String, Object> stat : scanStats) {
Object dateObj = stat.get("date");
String dateKey = dateObj != null ? DateUtil.formatDate((Date) dateObj) : null;
Object scanCountObj = stat.get("scanCount");
Long scanCount = scanCountObj != null ? ((Number) scanCountObj).longValue() : 0L;
if (dateKey != null) {
scanCountByDate.put(dateKey, scanCount);
}
}
}
// 合并数据
List<DailySummaryRespVO> result = new ArrayList<>();
for (HashMap<String, Object> orderStat : orderStats) {
DailySummaryRespVO vo = new DailySummaryRespVO();
Object dateObj = orderStat.get("date");
if (dateObj instanceof Date) {
vo.setDate((Date) dateObj);
}
String dateKey = dateObj != null ? DateUtil.formatDate((Date) dateObj) : null;
vo.setScanCount(scanCountByDate.getOrDefault(dateKey, 0L));
Object orderCountObj = orderStat.get("orderCount");
vo.setOrderCount(orderCountObj != null ? ((Number) orderCountObj).longValue() : 0L);
Object totalOrderPriceObj = orderStat.get("totalOrderPrice");
vo.setTotalOrderPrice(totalOrderPriceObj != null ? new BigDecimal(totalOrderPriceObj.toString()) : BigDecimal.ZERO);
Object totalBrokerPriceObj = orderStat.get("totalBrokerPrice");
vo.setTotalBrokerPrice(totalBrokerPriceObj != null ? new BigDecimal(totalBrokerPriceObj.toString()) : BigDecimal.ZERO);
result.add(vo);
}
return result;
}
}

View File

@@ -2,6 +2,7 @@ package com.ycwl.basic.service.pc.impl;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.clickhouse.service.StatsQueryService;
import com.ycwl.basic.mapper.BrokerMapper;
import com.ycwl.basic.model.pc.broker.entity.BrokerEntity;
import com.ycwl.basic.model.pc.broker.req.BrokerReqQuery;
@@ -27,12 +28,14 @@ public class BrokerServiceImpl implements BrokerService {
private BrokerMapper brokerMapper;
@Autowired
private ScenicRepository scenicRepository;
@Autowired
private StatsQueryService statsQueryService;
@Override
public PageInfo<BrokerRespVO> pageQuery(BrokerReqQuery brokerReqQuery) {
PageHelper.startPage(brokerReqQuery.getPageNum(),brokerReqQuery.getPageSize());
List<BrokerRespVO> list = brokerMapper.list(brokerReqQuery);
// 批量获取景区名称
List<Long> scenicIds = list.stream()
.map(BrokerRespVO::getScenicId)
@@ -40,14 +43,17 @@ public class BrokerServiceImpl implements BrokerService {
.distinct()
.collect(Collectors.toList());
Map<Long, String> scenicNames = scenicRepository.batchGetScenicNames(scenicIds);
// 设置景区名称
// 设置景区名称和扫码次数
list.forEach(item -> {
if (item.getScenicId() != null) {
item.setScenicName(scenicNames.get(item.getScenicId()));
}
// 从 ClickHouse/MySQL 查询分销员扫码次数
Integer scanCount = statsQueryService.countBrokerScanCount(item.getId());
item.setBrokerScanCount(scanCount != null ? scanCount.longValue() : 0L);
});
PageInfo<BrokerRespVO> pageInfo = new PageInfo(list);
return pageInfo;
}
@@ -55,7 +61,7 @@ public class BrokerServiceImpl implements BrokerService {
@Override
public List<BrokerRespVO> list(BrokerReqQuery brokerReqQuery) {
List<BrokerRespVO> list = brokerMapper.list(brokerReqQuery);
// 批量获取景区名称
List<Long> scenicIds = list.stream()
.map(BrokerRespVO::getScenicId)
@@ -63,14 +69,17 @@ public class BrokerServiceImpl implements BrokerService {
.distinct()
.collect(Collectors.toList());
Map<Long, String> scenicNames = scenicRepository.batchGetScenicNames(scenicIds);
// 设置景区名称
// 设置景区名称和扫码次数
list.forEach(item -> {
if (item.getScenicId() != null) {
item.setScenicName(scenicNames.get(item.getScenicId()));
}
// 从 ClickHouse/MySQL 查询分销员扫码次数
Integer scanCount = statsQueryService.countBrokerScanCount(item.getId());
item.setBrokerScanCount(scanCount != null ? scanCount.longValue() : 0L);
});
return list;
}

View File

@@ -677,13 +677,6 @@ public class FaceServiceImpl implements FaceService {
return contentList;
}
@Override
public ApiResponse<List<ContentPageVO>> contentListUseDefaultFace() {
FaceRespVO lastFaceByUserId = faceMapper.findLastFaceByUserId(BaseContextHandler.getUserId());
List<ContentPageVO> contentPageVOS = faceContentList(lastFaceByUserId.getId());
return ApiResponse.success(contentPageVOS);
}
@Override
public void bindFace(Long faceId, Long memberId) {
FaceEntity face = faceRepository.getFace(faceId);

View File

@@ -33,6 +33,7 @@ import com.ycwl.basic.service.pc.processor.VideoRecreationHandler;
import com.ycwl.basic.service.task.TaskFaceService;
import com.ycwl.basic.service.task.TaskService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.Strings;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@@ -40,7 +41,11 @@ import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
@@ -146,7 +151,7 @@ public class FaceMatchingOrchestrator {
processSourceRelations(context, searchResult, faceId, isNew);
// 步骤7: 异步生成拼图模板
asyncGeneratePuzzleTemplate(context.face.getScenicId(), faceId, context.face.getMemberId());
asyncGeneratePuzzleTemplate(context.face.getScenicId(), faceId, context.face.getMemberId(), scene);
return searchResult;
@@ -354,8 +359,10 @@ public class FaceMatchingOrchestrator {
/**
* 步骤8: 异步生成拼图模板
* 在人脸匹配完成后,异步为该景区的所有启用的拼图模板生成图片
*
* @return
*/
private void asyncGeneratePuzzleTemplate(Long scenicId, Long faceId, Long memberId) {
private void asyncGeneratePuzzleTemplate(Long scenicId, Long faceId, Long memberId, String scene) {
if (redisTemplate.hasKey("puzzle_generated:face:" + faceId)) {
return;
}
@@ -363,91 +370,66 @@ public class FaceMatchingOrchestrator {
"puzzle_generated:face:" + faceId,
"1",
60 * 10, TimeUnit.SECONDS);
new Thread(() -> {
try {
log.info("开始异步生成景区拼图模板: scenicId={}, faceId={}", scenicId, faceId);
try {
log.info("开始异步生成景区拼图模板: scenicId={}, faceId={}", scenicId, faceId);
// 查询该景区所有启用状态的拼图模板
List<PuzzleTemplateDTO> templateList = puzzleTemplateService.listTemplates(
scenicId, null, 1); // 查询启用状态的模板
// 查询该景区所有启用状态的拼图模板
List<PuzzleTemplateDTO> templateList = puzzleTemplateService.listTemplates(
scenicId, null, 1); // 查询启用状态的模板
if (templateList == null || templateList.isEmpty()) {
log.info("景区不存在启用的拼图模板,跳过生成: scenicId={}", scenicId);
return;
}
log.info("景区存在 {} 个启用的拼图模板,开始逐个生成: scenicId={}", templateList.size(), scenicId);
// 获取人脸信息用于动态数据
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.warn("人脸信息不存在,无法生成拼图: faceId={}", faceId);
return;
}
ScenicV2DTO scenicBasic = scenicRepository.getScenicBasic(face.getScenicId());
// 准备公共动态数据
Map<String, String> baseDynamicData = new HashMap<>();
if (face.getFaceUrl() != null) {
baseDynamicData.put("faceImage", face.getFaceUrl());
baseDynamicData.put("userAvatar", face.getFaceUrl());
}
baseDynamicData.put("faceId", String.valueOf(faceId));
baseDynamicData.put("scenicName", scenicBasic.getName());
baseDynamicData.put("scenicText", scenicBasic.getName());
baseDynamicData.put("dateStr", DateUtil.format(new Date(), "yyyy.MM.dd"));
// 使用虚拟线程池并行生成所有模板
java.util.concurrent.atomic.AtomicInteger successCount = new java.util.concurrent.atomic.AtomicInteger(0);
java.util.concurrent.atomic.AtomicInteger failCount = new java.util.concurrent.atomic.AtomicInteger(0);
try (java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
// 为每个模板创建一个异步任务
List<java.util.concurrent.CompletableFuture<Void>> futures = templateList.stream()
.map(template -> java.util.concurrent.CompletableFuture.runAsync(() -> {
try {
log.info("开始生成拼图: scenicId={}, templateCode={}, templateName={}",
scenicId, template.getCode(), template.getName());
// 构建生成请求
PuzzleGenerateRequest generateRequest = new PuzzleGenerateRequest();
generateRequest.setScenicId(scenicId);
generateRequest.setUserId(memberId);
generateRequest.setFaceId(faceId);
generateRequest.setBusinessType("face_matching");
generateRequest.setTemplateCode(template.getCode());
generateRequest.setOutputFormat("PNG");
generateRequest.setQuality(90);
generateRequest.setDynamicData(new HashMap<>(baseDynamicData));
generateRequest.setRequireRuleMatch(true);
// 调用拼图生成服务
PuzzleGenerateResponse response = puzzleGenerateService.generate(generateRequest);
log.info("拼图生成成功: scenicId={}, templateCode={}, imageUrl={}",
scenicId, template.getCode(), response.getImageUrl());
successCount.incrementAndGet();
} catch (Exception e) {
log.error("拼图生成失败: scenicId={}, templateCode={}, templateName={}",
scenicId, template.getCode(), template.getName(), e);
failCount.incrementAndGet();
}
}, executor))
.toList();
// 等待所有任务完成
java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join();
}
log.info("景区拼图模板批量生成完成: scenicId={}, 总数={}, 成功={}, 失败={}",
scenicId, templateList.size(), successCount.get(), failCount.get());
} catch (Exception e) {
// 异步任务失败不影响主流程,仅记录日志
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
if (templateList == null || templateList.isEmpty()) {
log.info("景区不存在启用的拼图模板,跳过生成: scenicId={}", scenicId);
return;
}
}, "PuzzleTemplateGenerator-" + scenicId).start();
log.info("景区存在 {} 个启用的拼图模板,开始逐个生成: scenicId={}", templateList.size(), scenicId);
// 获取人脸信息用于动态数据
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.warn("人脸信息不存在,无法生成拼图: faceId={}", faceId);
return;
}
ScenicV2DTO scenicBasic = scenicRepository.getScenicBasic(face.getScenicId());
// 准备公共动态数据
Map<String, String> baseDynamicData = new HashMap<>();
if (face.getFaceUrl() != null) {
baseDynamicData.put("faceImage", face.getFaceUrl());
baseDynamicData.put("userAvatar", face.getFaceUrl());
}
baseDynamicData.put("faceId", String.valueOf(faceId));
baseDynamicData.put("scenicName", scenicBasic.getName());
baseDynamicData.put("scenicText", scenicBasic.getName());
baseDynamicData.put("dateStr", DateUtil.format(new Date(), "yyyy.MM.dd"));
templateList
.forEach(template -> {
log.info("开始生成拼图: scenicId={}, templateCode={}, templateName={}",
scenicId, template.getCode(), template.getName());
// 构建生成请求
PuzzleGenerateRequest generateRequest = new PuzzleGenerateRequest();
generateRequest.setScenicId(scenicId);
generateRequest.setUserId(memberId);
generateRequest.setFaceId(faceId);
generateRequest.setBusinessType("face_matching");
generateRequest.setTemplateCode(template.getCode());
generateRequest.setOutputFormat("JPEG");
generateRequest.setQuality(80);
generateRequest.setDynamicData(new HashMap<>(baseDynamicData));
generateRequest.setRequireRuleMatch(true);
if (template.getAutoAddPrint() > 0 && Strings.CI.equals(scene, "printer")) {
puzzleGenerateService.generateSync(generateRequest);
} else {
puzzleGenerateService.generateAsync(generateRequest);
}
});
} catch (Exception e) {
// 异步任务失败不影响主流程,仅记录日志
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
}
return;
}
/**

View File

@@ -93,7 +93,6 @@ public class VideoRecreationHandler {
}).toList()
.stream().map(FaceSampleEntity::getId).toList();
log.info("视频切分任务: faceId={}, 原始数量={}, 筛选后数量={}", faceId, faceSampleList.size(), faceSampleIds.size());
log.debug("视频重切逻辑:视频数量 {}, 照片数量 {}", videoCount, photoCount);
// 只有照片数量大于视频数量时才创建重切任务

View File

@@ -400,17 +400,20 @@ public class PrinterServiceImpl implements PrinterService {
obj.setGoodsType(3);
// 按照 sourceId 分类照片
// sourceId > 0: 普通照片打印 (PHOTO_PRINT)
// sourceId > 0 且 source 表存在: 普通照片打印 (PHOTO_PRINT)
// sourceId > 0 且 source 表不存在: 拼图打印 (PUZZLE),归类为特效照片价格
// sourceId == null: 手机照片打印 (PHOTO_PRINT_MU)
// sourceId == 0: 特效照片打印 (PHOTO_PRINT_FX)
long normalCount = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() != null && item.getSourceId() > 0)
.filter(item -> sourceMapper.getById(item.getSourceId()) != null)
.mapToInt(MemberPrintResp::getQuantity)
.sum();
List<String> normalAttrs = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() != null && item.getSourceId() > 0)
.filter(item -> sourceMapper.getById(item.getSourceId()) != null)
.map(MemberPrintResp::getSourceId)
.map(id -> {
SourceEntity source = sourceRepository.getSource(id);
@@ -436,7 +439,15 @@ public class PrinterServiceImpl implements PrinterService {
.mapToInt(MemberPrintResp::getQuantity)
.sum();
long totalCount = normalCount + mobileCount + effectCount;
// 拼图:sourceId > 0 但 source 表中不存在(即来自 puzzle_generation_record 表)
long puzzleCount = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() != null && item.getSourceId() > 0)
.filter(item -> sourceMapper.getById(item.getSourceId()) == null)
.mapToInt(MemberPrintResp::getQuantity)
.sum();
long totalCount = normalCount + mobileCount + effectCount + puzzleCount;
if (totalCount == 0) {
// 如果没有照片,返回零价格
@@ -449,6 +460,7 @@ public class PrinterServiceImpl implements PrinterService {
// 构建价格计算请求
PriceCalculationRequest request = new PriceCalculationRequest();
request.setUserId(memberId);
request.setScenicId(scenicId);
// 创建商品项列表
List<ProductItem> productItems = new ArrayList<>();
@@ -479,15 +491,15 @@ public class PrinterServiceImpl implements PrinterService {
}
// 添加特效照片打印商品项 (sourceId == 0)
if (effectCount > 0) {
if (effectCount > 0 || puzzleCount > 0) {
ProductItem effectPhotoItem = new ProductItem();
effectPhotoItem.setProductType(ProductType.PHOTO_PRINT_FX);
effectPhotoItem.setProductId(scenicId.toString());
effectPhotoItem.setQuantity(Long.valueOf(effectCount).intValue());
effectPhotoItem.setQuantity(Long.valueOf(effectCount + puzzleCount).intValue());
effectPhotoItem.setPurchaseCount(1);
effectPhotoItem.setScenicId(scenicId.toString());
productItems.add(effectPhotoItem);
log.debug("特效照片打印数量: {}", effectCount);
log.debug("特效照片打印数量: {}", effectCount + puzzleCount);
}
request.setProducts(productItems);
@@ -548,7 +560,27 @@ public class PrinterServiceImpl implements PrinterService {
@Override
public List<Integer> addUserPhotoFromSource(Long memberId, Long scenicId, FromSourceReq req, Long faceId) {
List<Integer> resultIds = new ArrayList<>();
// 预先查询用户在该景区的已有打印记录,用于去重
List<MemberPrintResp> existingPhotos = printerMapper.listRelationByFaceId(memberId, scenicId, faceId);
Map<Long, Integer> sourceIdToMemberPrintId = new HashMap<>();
if (existingPhotos != null) {
for (MemberPrintResp photo : existingPhotos) {
if (photo.getSourceId() != null) {
sourceIdToMemberPrintId.put(photo.getSourceId(), photo.getId());
}
}
}
req.getIds().forEach(id -> {
// 检查该sourceId是否已经添加过
if (sourceIdToMemberPrintId.containsKey(id)) {
Integer existingId = sourceIdToMemberPrintId.get(id);
log.info("sourceId={}已存在于打印列表中,返回已有记录: memberPrintId={}", id, existingId);
resultIds.add(existingId);
return;
}
SourceRespVO byId = sourceMapper.getById(id);
if (byId == null) {
resultIds.add(null);
@@ -660,12 +692,14 @@ public class PrinterServiceImpl implements PrinterService {
List<MemberPrintResp> userPhotoList = getUserPhotoList(memberId, scenicId, faceId);
// 按照 sourceId 分类照片
// sourceId > 0: 普通照片打印 (PHOTO_PRINT)
// sourceId > 0 且 source 表存在: 普通照片打印 (PHOTO_PRINT)
// sourceId > 0 且 source 表不存在: 拼图打印 (PUZZLE),归类为特效照片价格
// sourceId == null: 手机照片打印 (PHOTO_PRINT_MU)
// sourceId == 0: 特效照片打印 (PHOTO_PRINT_FX)
long normalCount = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() != null && item.getSourceId() > 0)
.filter(item -> sourceMapper.getById(item.getSourceId()) != null)
.mapToInt(MemberPrintResp::getQuantity)
.sum();
@@ -681,7 +715,14 @@ public class PrinterServiceImpl implements PrinterService {
.mapToInt(MemberPrintResp::getQuantity)
.sum();
long totalCount = normalCount + mobileCount + effectCount;
// 拼图:sourceId > 0 但 source 表中不存在(即来自 puzzle_generation_record 表)
long puzzleCount = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() != null && item.getSourceId() > 0)
.filter(item -> sourceMapper.getById(item.getSourceId()) == null)
.mapToInt(MemberPrintResp::getQuantity)
.sum();
long totalCount = normalCount + mobileCount + effectCount + puzzleCount;
if (totalCount == 0) {
throw new BaseException("没有可打印的照片");
@@ -718,12 +759,30 @@ public class PrinterServiceImpl implements PrinterService {
// 添加普通照片打印商品项 (sourceId > 0)
if (normalCount > 0) {
List<String> normalAttrs = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() != null && item.getSourceId() > 0)
.filter(item -> sourceMapper.getById(item.getSourceId()) != null)
.map(MemberPrintResp::getSourceId)
.map(id -> {
SourceEntity source = sourceRepository.getSource(id);
if (source == null) {
return null;
}
return source.getDeviceId();
})
.filter(Objects::nonNull)
.distinct()
.map(String::valueOf)
.toList();
ProductItem normalPhotoItem = new ProductItem();
normalPhotoItem.setProductType(ProductType.PHOTO_PRINT);
normalPhotoItem.setProductId(scenicId.toString());
normalPhotoItem.setQuantity(Long.valueOf(normalCount).intValue());
normalPhotoItem.setPurchaseCount(1);
normalPhotoItem.setScenicId(scenicId.toString());
normalPhotoItem.setAttributeKeys(normalAttrs);
productItems.add(normalPhotoItem);
log.debug("创建订单-普通照片打印数量: {}", normalCount);
}
@@ -741,15 +800,15 @@ public class PrinterServiceImpl implements PrinterService {
}
// 添加特效照片打印商品项 (sourceId == 0)
if (effectCount > 0) {
if (effectCount > 0 || puzzleCount > 0) {
ProductItem effectPhotoItem = new ProductItem();
effectPhotoItem.setProductType(ProductType.PHOTO_PRINT_FX);
effectPhotoItem.setProductId(scenicId.toString());
effectPhotoItem.setQuantity(Long.valueOf(effectCount).intValue());
effectPhotoItem.setQuantity(Long.valueOf(effectCount + puzzleCount).intValue());
effectPhotoItem.setPurchaseCount(1);
effectPhotoItem.setScenicId(scenicId.toString());
productItems.add(effectPhotoItem);
log.debug("创建订单-特效照片打印数量: {}", effectCount);
log.debug("创建订单-特效照片打印数量: {}", effectCount + puzzleCount);
}
request.setProducts(productItems);
@@ -889,7 +948,11 @@ public class PrinterServiceImpl implements PrinterService {
SourceEntity source = null;
if (item.getSourceId() != null && item.getSourceId() > 0) {
source = sourceMapper.getEntity(item.getSourceId());
context.setSource(ImageSource.IPC);
if (source == null) {
context.setImageType(ImageType.PUZZLE); // 特殊
} else {
context.setSource(ImageSource.IPC);
}
} else if (item.getSourceId() == null) {
context.setSource(ImageSource.PHONE);
} else {
@@ -1220,7 +1283,7 @@ public class PrinterServiceImpl implements PrinterService {
resp.setFaceId(faceId);
resp.setScenicId(scenicId);
try {
faceService.matchFaceId(faceId);
faceService.matchFaceId(faceId, true, "printer");
if (existingFace == null) {
autoAddPhotosToPreferPrint(faceId);
}

View File

@@ -185,7 +185,6 @@ public class TaskFaceServiceImpl implements TaskFaceService {
return entry.getValue().stream();
}).toList()
.stream().map(FaceSampleEntity::getId).toList();
log.info("视频切分任务: faceId={}, 原始数量={}, 筛选后数量={}", faceId, sampleListIds.size(), faceSampleIds.size());
VideoPieceGetter.Task task = new VideoPieceGetter.Task();
task.faceId = faceEntity.getId();
task.faceSampleIds = faceSampleIds;

View File

@@ -146,6 +146,34 @@ public class TaskTaskServiceImpl implements TaskService {
return worker;
}
/**
* 比较两个版本号
* @param v1 版本号1
* @param v2 版本号2
* @return 负数表示 v1 < v2,0 表示相等,正数表示 v1 > v2
*/
private int compareVersion(String v1, String v2) {
String[] parts1 = v1.split("\\.");
String[] parts2 = v2.split("\\.");
int maxLen = Math.max(parts1.length, parts2.length);
for (int i = 0; i < maxLen; i++) {
int num1 = i < parts1.length ? parseVersionPart(parts1[i]) : 0;
int num2 = i < parts2.length ? parseVersionPart(parts2[i]) : 0;
if (num1 != num2) {
return num1 - num2;
}
}
return 0;
}
private int parseVersionPart(String part) {
try {
return Integer.parseInt(part.replaceAll("[^0-9]", ""));
} catch (NumberFormatException e) {
return 0;
}
}
private boolean isWorkerSelfHostedScenic(Long scenicId) {
String cacheKey = String.format(WORKER_SELF_HOSTED_CACHE_KEY, scenicId);
String cachedValue = redisTemplate.opsForValue().get(cacheKey);
@@ -174,6 +202,13 @@ public class TaskTaskServiceImpl implements TaskService {
worker.setStatus(null);
// get status
ClientStatusReqVo clientStatus = req.getClientStatus();
// 版本校验:上报版本低于缓存版本时认为 worker 异常
ClientStatusReqVo cachedStatus = repository.getWorkerHostStatus(worker.getId());
if (cachedStatus != null && clientStatus != null
&& cachedStatus.getVersion() != null && clientStatus.getVersion() != null
&& compareVersion(clientStatus.getVersion(), cachedStatus.getVersion()) < 0) {
return null;
}
repository.setWorkerHostStatus(worker.getId(), clientStatus);
TaskSyncRespVo resp = new TaskSyncRespVo();
// Template
@@ -283,7 +318,6 @@ public class TaskTaskServiceImpl implements TaskService {
return entry.getValue().stream();
}).toList()
.stream().map(FaceSampleEntity::getId).toList();
log.info("视频切分任务: faceId={}, 原始数量={}, 筛选后数量={}", faceId, faceSampleList.size(), faceSampleIds.size());
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(faceRespVO.getScenicId());
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(faceRespVO.getScenicId());
if (templateList == null || templateList.isEmpty()) {
@@ -355,7 +389,6 @@ public class TaskTaskServiceImpl implements TaskService {
return entry.getValue().stream();
}).toList()
.stream().map(FaceSampleEntity::getId).collect(Collectors.toList());
log.info("视频切分任务: faceId={}, 原始数量={}, 筛选后数量={}", faceId, faceSampleList.size(), faceSampleIds.size());
VideoPieceGetter.Task task = new VideoPieceGetter.Task();
task.faceId = faceId;
task.faceSampleIds = faceSampleIds;
@@ -399,15 +432,18 @@ public class TaskTaskServiceImpl implements TaskService {
memberVideoEntity.setTemplateId(templateId);
memberVideoEntity.setIsBuy(0);
if (list.isEmpty()) {
log.info("创建任务! faceId:{},templateId:{},taskParams:{}", faceId, templateId, sourcesMap);
log.info("创建任务! faceId:{},templateId:{}", faceId, templateId);
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
TaskEntity taskEntity = null;
boolean isReuseOldTask = false;
if (Integer.valueOf(0).equals(scenicConfig.getInteger("template_new_video_type"))) {
log.info("景区{}启用:templateNewVideoType:全新视频原位替换", face.getScenicId());
taskReqQuery.setTemplateId(templateId);
taskReqQuery.setTaskParams(null); // 原位替换模式下,不按taskParams匹配
List<TaskEntity> templateTaskList = taskMapper.listEntity(taskReqQuery);
if (!templateTaskList.isEmpty()) {
taskEntity = templateTaskList.getFirst();
isReuseOldTask = true;
log.info("已有旧生成的视频:{}", taskEntity);
MemberVideoEntity taskVideoRelation = videoMapper.queryRelationByMemberTask(face.getMemberId(), taskEntity.getId());
if (taskVideoRelation != null) {
@@ -425,17 +461,25 @@ public class TaskTaskServiceImpl implements TaskService {
taskEntity.setTemplateId(templateId);
taskEntity.setAutomatic(automatic ? 1 : 0);
}
taskEntity.setWorkerId(null);
taskEntity.setStatus(0);
taskEntity.setTaskParams(JacksonUtil.toJSONString(sourcesMap));
taskMapper.add(taskEntity);
if (isReuseOldTask) {
taskMapper.update(taskEntity);
taskMapper.deassign(taskEntity.getId());
log.info("更新旧任务! taskId:{}", taskEntity.getId());
} else {
taskMapper.add(taskEntity);
}
memberVideoEntity.setTaskId(taskEntity.getId());
} else {
log.info("重复task! faceId:{},templateId:{},taskParams:{}", faceId, templateId, sourcesMap);
memberVideoEntity.setTaskId(list.getFirst().getId());
VideoEntity video = videoMapper.findByTaskId(list.getFirst().getId());
TaskRespVO existingTask = list.getFirst();
log.info("重复task! faceId:{},templateId:{},taskId:{}", faceId, templateId, existingTask.getId());
videoTaskRepository.clearTaskCache(existingTask.getId());
memberVideoEntity.setTaskId(existingTask.getId());
VideoEntity video = videoRepository.getVideoByTaskId(existingTask.getId());
if (video != null) {
IsBuyRespVO isBuy = orderBiz.isBuy(list.getFirst().getScenicId(), face.getMemberId(), face.getId(), 0, video.getId());
IsBuyRespVO isBuy = orderBiz.isBuy(existingTask.getScenicId(), face.getMemberId(), face.getId(), 0, video.getId());
if (isBuy.isBuy()) {
memberVideoEntity.setIsBuy(1);
memberVideoEntity.setOrderId(isBuy.getOrderId());
@@ -590,6 +634,10 @@ public class TaskTaskServiceImpl implements TaskService {
public void sendVideoGeneratedServiceNotification(Long taskId, Long memberId) {
MemberVideoEntity item = videoMapper.queryRelationByMemberTask(memberId, taskId);
MemberRespVO member = memberMapper.getById(memberId);
if (member == null || item == null) {
log.warn("sendVideoGeneratedServiceNotification member or item is null, memberId:{}, taskId:{}", memberId, taskId);
return;
}
String openId = member.getOpenId();
MpConfigEntity scenicMp = scenicRepository.getScenicMpConfig(member.getScenicId());
if (StringUtils.isNotBlank(openId) && scenicMp != null) {

View File

@@ -1,7 +0,0 @@
package com.ycwl.basic.stats.biz;
import org.springframework.stereotype.Component;
@Component
public class StatsBiz {
}

View File

@@ -1,46 +0,0 @@
package com.ycwl.basic.stats.controller;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.stats.dto.AddTraceReq;
import com.ycwl.basic.stats.service.StatsService;
import com.ycwl.basic.stats.util.StatsUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@RestController
@RequestMapping("/trace/v1")
public class TraceController {
@Autowired
private StatsService statsService;
@IgnoreToken
@PostMapping("/start")
public void startTrace(HttpServletRequest request, HttpServletResponse response) {
String traceId = request.getHeader("traceId");
if (traceId == null || traceId.isEmpty()) {
traceId = StatsUtil.createUuid();
response.setHeader("Set-TraceId", traceId);
}
statsService.addStats(traceId, null);
}
@IgnoreToken
@PostMapping("/add")
public void addTrace(HttpServletRequest request, HttpServletResponse response, @RequestBody AddTraceReq req) {
String traceId = request.getHeader("traceId");
if (traceId == null || traceId.isEmpty()) {
traceId = StatsUtil.createUuid();
response.setHeader("Set-TraceId", traceId);
}
if (StringUtils.isEmpty(req.getParams())) {
req.setParams(null);
}
statsService.addRecord(traceId, req.getAction(), req.getIdentifier(), req.getParams());
}
}

View File

@@ -1,10 +0,0 @@
package com.ycwl.basic.stats.dto;
import lombok.Data;
@Data
public class AddTraceReq {
private String action;
private String identifier;
private String params;
}

View File

@@ -1,18 +0,0 @@
package com.ycwl.basic.stats.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("t_stats")
public class StatsEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String traceId;
private Long memberId;
private Date createTime;
}

View File

@@ -1,20 +0,0 @@
package com.ycwl.basic.stats.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("t_stats_record")
public class StatsRecordEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String traceId;
private String action;
private String identifier;
private String params;
private Date createTime;
}

View File

@@ -1,65 +0,0 @@
package com.ycwl.basic.stats.interceptor;
import com.ycwl.basic.annotation.IgnoreLogReq;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.stats.service.StatsService;
import com.ycwl.basic.stats.util.StatsUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.HashSet;
@Component
public class StatsInterceptor implements HandlerInterceptor {
@Lazy
@Autowired
private StatsService statsService;
// 在请求处理前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
// HandlerMethod handlerMethod = (HandlerMethod) handler;
// request.setAttribute("startTime", System.currentTimeMillis());
// String requestURI = request.getRequestURI();
// String method = request.getMethod();
String traceId = request.getHeader("traceId");
if (StringUtils.isEmpty(traceId)) {
return true;
// traceId = StatsUtil.createUuid();
// response.setHeader("Set-TraceId", traceId);
// statsService.addStats(traceId, null);
}
// if (handlerMethod.getMethodAnnotation(IgnoreLogReq.class) == null) {
// statsService.addRecord(traceId, "REQUEST",method + " " + requestURI, null);
// }
if (StringUtils.isNotEmpty(BaseContextHandler.getUserId())) {
statsService.updateStats(traceId, Long.valueOf(BaseContextHandler.getUserId()));
}
// 返回 true 继续后续流程,false 终止请求
return true;
}
// 控制器方法执行后,渲染视图前
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
// 请求完全完成后执行(无论是否异常)
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// long startTime = (Long) request.getAttribute("startTime");
// long endTime = System.currentTimeMillis();
// System.out.println("【AfterCompletion】请求结束,耗时:" + (endTime - startTime) + "ms");
}
}

View File

@@ -1,9 +0,0 @@
package com.ycwl.basic.stats.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.stats.entity.StatsEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StatsMapper extends BaseMapper<StatsEntity> {
}

View File

@@ -1,9 +0,0 @@
package com.ycwl.basic.stats.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ycwl.basic.stats.entity.StatsRecordEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StatsRecordMapper extends BaseMapper<StatsRecordEntity> {
}

View File

@@ -1,9 +0,0 @@
package com.ycwl.basic.stats.service;
public interface StatsService {
void addStats(String traceId, Long memberId);
void updateStats(String traceId, Long memberId);
void addRecord(String traceId, String action, String identifier, String params);
}

View File

@@ -1,53 +0,0 @@
package com.ycwl.basic.stats.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ycwl.basic.stats.entity.StatsEntity;
import com.ycwl.basic.stats.entity.StatsRecordEntity;
import com.ycwl.basic.stats.mapper.StatsMapper;
import com.ycwl.basic.stats.mapper.StatsRecordMapper;
import com.ycwl.basic.stats.service.StatsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class StatsServiceImpl implements StatsService {
@Lazy
@Autowired
private StatsMapper statsMapper;
@Lazy
@Autowired
private StatsRecordMapper statsRecordMapper;
@Override
public void addStats(String traceId, Long memberId) {
StatsEntity entity = new StatsEntity();
entity.setTraceId(traceId);
entity.setMemberId(memberId);
entity.setCreateTime(new Date());
statsMapper.insert(entity);
}
@Override
public void updateStats(String traceId, Long memberId) {
StatsEntity entity = new StatsEntity();
entity.setMemberId(memberId);
LambdaQueryWrapper<StatsEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(StatsEntity::getTraceId, traceId);
statsMapper.update(entity, queryWrapper);
}
@Override
public void addRecord(String traceId, String action, String identifier, String params) {
StatsRecordEntity entity = new StatsRecordEntity();
entity.setTraceId(traceId);
entity.setAction(action);
entity.setIdentifier(identifier);
entity.setParams(params);
entity.setCreateTime(new Date());
statsRecordMapper.insert(entity);
}
}

View File

@@ -1,9 +0,0 @@
package com.ycwl.basic.stats.util;
import java.util.UUID;
public class StatsUtil {
public static String createUuid() {
return UUID.randomUUID().toString().replace("-", "");
}
}

View File

@@ -36,6 +36,9 @@ import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
@@ -43,7 +46,9 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
@@ -84,6 +89,16 @@ public class VideoPieceGetter {
@Autowired
private FaceStatusManager faceStatusManager;
/**
* 景区设备配对关系缓存
* key: scenicId
* value: Map<deviceId, pairDeviceId>,空Map表示该景区无配对关系
*/
private final Cache<Long, Map<Long, Long>> pairDeviceCache = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.maximumSize(500)
.build();
@Data
public static class Task {
public List<Long> faceSampleIds = new ArrayList<>();
@@ -153,17 +168,8 @@ public class VideoPieceGetter {
task.callback.onInvoke();
return;
}
Map<Long, Long> pairDeviceMap = new ConcurrentHashMap<>();
Long scenicId = list.getFirst().getScenicId();
List<DeviceV2DTO> allDeviceByScenicId = deviceRepository.getAllDeviceByScenicId(scenicId);
allDeviceByScenicId.forEach(device -> {
Long deviceId = device.getId();
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId);
Long pairDevice = deviceConfig.getLong("pair_device");
if (pairDevice != null) {
pairDeviceMap.putIfAbsent(deviceId, pairDevice);
}
});
Map<Long, Long> pairDeviceMap = pairDeviceCache.get(scenicId, this::loadPairDeviceMap);
Map<Long, List<FaceSampleEntity>> collection = list.stream()
.filter(faceSample -> {
if (templatePlaceholder != null) {
@@ -176,12 +182,14 @@ public class VideoPieceGetter {
templatePlaceholder.forEach(deviceId -> {
currentUnFinPlaceholder.computeIfAbsent(deviceId, k -> new AtomicInteger(0)).incrementAndGet();
});
log.debug("[Placeholder初始化] 有templateId,初始化完成:placeholder总数={}, 不同设备数={}, 详细计数={}",
templatePlaceholder.size(),
currentUnFinPlaceholder.size(),
currentUnFinPlaceholder.entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue().get())
.collect(Collectors.joining(", ")));
if (log.isDebugEnabled()) {
log.debug("[Placeholder初始化] 有templateId,初始化完成:placeholder总数={}, 不同设备数={}, 详细计数={}",
templatePlaceholder.size(),
currentUnFinPlaceholder.size(),
currentUnFinPlaceholder.entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue().get())
.collect(Collectors.joining(", ")));
}
} else {
collection.keySet().forEach(deviceId -> {
currentUnFinPlaceholder.put(deviceId.toString(), new AtomicInteger(1));
@@ -190,16 +198,8 @@ public class VideoPieceGetter {
currentUnFinPlaceholder.size());
}
collection.values().forEach(faceSampleList -> {
executor.execute(() -> {
AtomicBoolean isFirst = new AtomicBoolean(true);
faceSampleList.forEach(faceSample -> {
if (!isFirst.get()) {
try {
Thread.sleep(1000);
} catch (InterruptedException ignore) {
}
}
isFirst.set(false);
faceSampleList.forEach(faceSample -> {
executor.execute(() -> {
// 处理关联设备:如果当前设备是某个主设备的配对设备,也处理主设备
if (pairDeviceMap.containsValue(faceSample.getDeviceId())) {
pairDeviceMap.entrySet().stream()
@@ -212,12 +212,12 @@ public class VideoPieceGetter {
AtomicInteger pairCount = currentUnFinPlaceholder.get(pairDeviceId.toString());
if (pairCount != null) {
int remaining = pairCount.decrementAndGet();
log.info("[计数器更新] 关联设备 {} 计数器递减,剩余={}, currentUnFinPlaceholder总数={}",
pairDeviceId, remaining, currentUnFinPlaceholder.size());
// log.info("[计数器更新] 关联设备 {} 计数器递减,剩余={}, currentUnFinPlaceholder总数={}",
// pairDeviceId, remaining, currentUnFinPlaceholder.size());
if (remaining <= 0) {
currentUnFinPlaceholder.remove(pairDeviceId.toString());
log.debug("[Placeholder完成] 设备 {} 的placeholder已满足并移除,剩余设备数={}",
pairDeviceId, currentUnFinPlaceholder.size());
// log.debug("[Placeholder完成] 设备 {} 的placeholder已满足并移除,剩余设备数={}",
// pairDeviceId, currentUnFinPlaceholder.size());
}
}
}
@@ -229,55 +229,45 @@ public class VideoPieceGetter {
AtomicInteger count = currentUnFinPlaceholder.get(faceSample.getDeviceId().toString());
if (count != null) {
int remaining = count.decrementAndGet();
log.info("[计数器更新] 设备 {} 计数器递减,剩余={}, currentUnFinPlaceholder总数={}",
faceSample.getDeviceId(), remaining, currentUnFinPlaceholder.size());
// log.info("[计数器更新] 设备 {} 计数器递减,剩余={}, currentUnFinPlaceholder总数={}",
// faceSample.getDeviceId(), remaining, currentUnFinPlaceholder.size());
if (remaining <= 0) {
currentUnFinPlaceholder.remove(faceSample.getDeviceId().toString());
log.debug("[Placeholder完成] 设备 {} 的placeholder已满足并移除,剩余设备数={}",
faceSample.getDeviceId(), currentUnFinPlaceholder.size());
// log.debug("[Placeholder完成] 设备 {} 的placeholder已满足并移除,剩余设备数={}",
// faceSample.getDeviceId(), currentUnFinPlaceholder.size());
}
}
// 如果有templateId,检查是否所有placeholder都已满足
if (templatePlaceholder != null) {
int totalPlaceholderCount = templatePlaceholder.size();
int remainingCount = currentUnFinPlaceholder.values().stream()
.mapToInt(AtomicInteger::get)
.sum();
log.info("[进度检查] 当前进度:已完成 {}/{},剩余 {} 个placeholder未满足,剩余设备数={}",
totalPlaceholderCount - remainingCount, totalPlaceholderCount, remainingCount,
currentUnFinPlaceholder.size());
if (currentUnFinPlaceholder.isEmpty()) {
if (!invoke.get()) {
invoke.set(true);
// 使用 compareAndSet 保证原子性,避免多线程重复调用 callback
if (invoke.compareAndSet(false, true)) {
log.info("[Callback调用] 所有placeholder已满足,currentUnFinPlaceholder为空,提前调用callback");
task.getCallback().onInvoke();
}
}
}
if (task.faceId != null) {
// 经过切片后,可能有新的人脸切片生成,需要更新人脸状态
templateRepository.getTemplateListByScenicId(scenicId).forEach(template -> {
faceStatusManager.markHasNewPieces(task.faceId, template.getId());
});
}
});
if (task.faceId != null) {
// 经过切片后,可能有新的人脸切片生成,需要更新人脸状态
templateRepository.getTemplateListByScenicId(scenicId).forEach(template -> {
faceStatusManager.markHasNewPieces(task.faceId, template.getId());
});
}
});
});
try {
Thread.sleep(1000L);
log.info("executor等待被结束![A:{}/T:{}/F:{}]", executor.getActiveCount(), executor.getTaskCount(), executor.getCompletedTaskCount());
executor.shutdown();
executor.awaitTermination(3, TimeUnit.MINUTES);
log.info("executor已结束![A:{}/T:{}/F:{}]", executor.getActiveCount(), executor.getTaskCount(), executor.getCompletedTaskCount());
executor.close();
} catch (InterruptedException e) {
log.info("executor已中断![A:{}/T:{}/F:{}]", executor.getActiveCount(), executor.getTaskCount(), executor.getCompletedTaskCount());
} finally {
executor.close();
if (null != task.getCallback()) {
if (!invoke.get()) {
invoke.set(true);
// 使用 compareAndSet 保证原子性,避免多线程重复调用 callback
if (invoke.compareAndSet(false, true)) {
log.info("[Callback调用] 兜底调用callback,currentUnFinPlaceholder剩余设备数={}",
currentUnFinPlaceholder.size());
task.getCallback().onInvoke();
@@ -735,4 +725,27 @@ public class VideoPieceGetter {
}
}
/**
* 加载景区的设备配对关系
* @param scenicId 景区ID
* @return 设备配对关系Map,无配对关系时返回空Map(而非null,避免重复查询)
*/
private Map<Long, Long> loadPairDeviceMap(Long scenicId) {
List<DeviceV2DTO> allDeviceByScenicId = deviceRepository.getAllDeviceByScenicId(scenicId);
if (allDeviceByScenicId == null || allDeviceByScenicId.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, Long> pairDeviceMap = new HashMap<>();
allDeviceByScenicId.forEach(device -> {
Long deviceId = device.getId();
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId);
Long pairDevice = deviceConfig.getLong("pair_device");
if (pairDevice != null) {
pairDeviceMap.putIfAbsent(deviceId, pairDevice);
}
});
log.debug("加载景区 {} 设备配对关系,共 {} 对", scenicId, pairDeviceMap.size());
return pairDeviceMap.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(pairDeviceMap);
}
}

View File

@@ -0,0 +1,91 @@
package com.ycwl.basic.utils;
import org.apache.commons.lang3.StringUtils;
/**
* IPv4 CIDR 匹配工具
*/
public final class Ipv4CidrMatcher {
private Ipv4CidrMatcher() {
}
/**
* 判断 IPv4 是否命中 CIDR(如:100.64.0.0/24)。
* - 若 cidr 不包含 '/',则按“完全相等”处理。
* - 仅支持 IPv4;IPv6 返回 false。
*/
public static boolean matches(String ip, String cidr) {
if (StringUtils.isBlank(ip) || StringUtils.isBlank(cidr)) {
return false;
}
String cleanedIp = ip.trim();
if (cleanedIp.contains(":")) {
return false;
}
String cleanedCidr = cidr.trim();
int slashIndex = cleanedCidr.indexOf('/');
if (slashIndex < 0) {
return cleanedIp.equals(cleanedCidr);
}
String networkPart = cleanedCidr.substring(0, slashIndex).trim();
String prefixPart = cleanedCidr.substring(slashIndex + 1).trim();
Integer ipInt = parseIpv4ToInt(cleanedIp);
Integer networkInt = parseIpv4ToInt(networkPart);
if (ipInt == null || networkInt == null) {
return false;
}
Integer prefixLength = parsePrefixLength(prefixPart);
if (prefixLength == null) {
return false;
}
int mask = prefixLength == 0 ? 0 : (-1 << (32 - prefixLength));
return (ipInt & mask) == (networkInt & mask);
}
private static Integer parsePrefixLength(String prefixPart) {
try {
int prefixLength = Integer.parseInt(prefixPart);
if (prefixLength < 0 || prefixLength > 32) {
return null;
}
return prefixLength;
} catch (Exception e) {
return null;
}
}
private static Integer parseIpv4ToInt(String ip) {
if (StringUtils.isBlank(ip)) {
return null;
}
String[] parts = ip.trim().split("\\.");
if (parts.length != 4) {
return null;
}
long result = 0;
for (String part : parts) {
int value;
try {
value = Integer.parseInt(part);
} catch (Exception e) {
return null;
}
if (value < 0 || value > 255) {
return null;
}
result = (result << 8) | value;
}
return (int) result;
}
}

View File

@@ -8,6 +8,17 @@ spring:
lifecycle:
timeout-per-shutdown-phase: 60s
# ClickHouse 配置
clickhouse:
enabled: true # true=ClickHouse, false=MySQL兜底
datasource:
jdbc-url: jdbc:clickhouse://100.64.0.7:8123/zt
username: default
password: ZhEnTuAi
driver-class-name: com.clickhouse.jdbc.ClickHouseDriver
maximum-pool-size: 10
minimum-idle: 2
# Feign配置(简化版,基于Nacos服务发现)
feign:
client:
@@ -44,4 +55,12 @@ logging:
com.ycwl.basic.integration.scenic.client: DEBUG
zhipu:
api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L
api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L
# 边缘 Worker 接口安全(仅允许 100.64.0.0/24 网段访问)
puzzle:
edge:
worker:
security:
enabled: true
allowed-ip-cidr: 100.64.0.0/24

View File

@@ -8,10 +8,29 @@ spring:
lifecycle:
timeout-per-shutdown-phase: 60s
# ClickHouse 配置
clickhouse:
enabled: true # 设置为 true 启用 ClickHouse,false 使用 MySQL 兜底
datasource:
jdbc-url: jdbc:clickhouse://100.64.0.7:8123/zt
username: default
password: ZhEnTuAi
driver-class-name: com.clickhouse.jdbc.ClickHouseDriver
maximum-pool-size: 20
minimum-idle: 5
# 生产环境日志级别
logging:
level:
com.ycwl.basic.integration.scenic.client: WARN
zhipu:
api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L
api-key: a331e0fcf3f74518818b8e5129b79058.RXuUxUUjKdcxbF4L
# 边缘 Worker 接口安全(仅允许 100.64.0.0/24 网段访问)
puzzle:
edge:
worker:
security:
enabled: true
allowed-ip-cidr: 100.64.0.0/24

View File

@@ -36,7 +36,6 @@
</delete>
<select id="list" resultType="com.ycwl.basic.model.pc.broker.resp.BrokerRespVO">
select b.id, scenic_id, b.`name`, b.phone, b.broker_enable, b.broker_rate, b.status,
(select count(1) from t_stats_record s where s.action = "CODE_SCAN" and s.identifier = b.id) as broker_scan_count,
(select count(1) from broker_record r where r.broker_id = b.id) as broker_order_count,
(select sum(order_price) from broker_record r where r.broker_id = b.id) as broker_order_amount,
(select min(r.create_time) from broker_record r where r.broker_id = b.id) as first_broker_date,

View File

@@ -107,4 +107,30 @@
</set>
where id = #{id}
</update>
<!-- 按日期统计分销员订单数据(不含扫码统计) -->
<select id="getDailyOrderStats" resultType="java.util.HashMap">
WITH RECURSIVE
date_series AS (SELECT DATE(#{startTime}) AS date
UNION ALL
SELECT DATE_ADD(date, INTERVAL 1 DAY)
FROM date_series
WHERE date &lt; DATE(#{endTime}))
SELECT ds.date,
COALESCE(os.orderCount, 0) AS orderCount,
COALESCE(os.totalOrderPrice, 0) AS totalOrderPrice,
COALESCE(os.totalBrokerPrice, 0) AS totalBrokerPrice
FROM date_series ds
LEFT JOIN (
SELECT DATE(create_time) AS date,
COUNT(DISTINCT id) AS orderCount,
COALESCE(SUM(order_price), 0) AS totalOrderPrice,
COALESCE(SUM(broker_price), 0) AS totalBrokerPrice
FROM broker_record
WHERE broker_id = #{brokerId}
AND DATE(create_time) BETWEEN DATE(#{startTime}) AND DATE(#{endTime})
GROUP BY DATE(create_time)
) os ON ds.date = os.date
ORDER BY ds.date
</select>
</mapper>

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.puzzle.edge.mapper.PuzzleEdgeRenderTaskMapper">
<resultMap id="BaseResultMap" type="com.ycwl.basic.puzzle.edge.entity.PuzzleEdgeRenderTaskEntity">
<id column="id" property="id"/>
<result column="record_id" property="recordId"/>
<result column="template_id" property="templateId"/>
<result column="template_code" property="templateCode"/>
<result column="scenic_id" property="scenicId"/>
<result column="face_id" property="faceId"/>
<result column="content_hash" property="contentHash"/>
<result column="status" property="status"/>
<result column="worker_id" property="workerId"/>
<result column="lease_expire_time" property="leaseExpireTime"/>
<result column="attempt_count" property="attemptCount"/>
<result column="output_format" property="outputFormat"/>
<result column="output_quality" property="outputQuality"/>
<result column="original_object_key" property="originalObjectKey"/>
<result column="cropped_object_key" property="croppedObjectKey"/>
<result column="payload_json" property="payloadJson"/>
<result column="error_message" property="errorMessage"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
</resultMap>
<sql id="Base_Column_List">
id, record_id, template_id, template_code, scenic_id, face_id, content_hash,
status, worker_id, lease_expire_time, attempt_count,
output_format, output_quality,
original_object_key, cropped_object_key,
payload_json, error_message,
create_time, update_time
</sql>
<select id="getById" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM puzzle_edge_render_task
WHERE id = #{id}
LIMIT 1
</select>
<insert id="insert" parameterType="com.ycwl.basic.puzzle.edge.entity.PuzzleEdgeRenderTaskEntity"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO puzzle_edge_render_task (
record_id, template_id, template_code, scenic_id, face_id, content_hash,
status, worker_id, lease_expire_time, attempt_count,
output_format, output_quality,
original_object_key, cropped_object_key,
payload_json, error_message,
create_time, update_time
) VALUES (
#{recordId}, #{templateId}, #{templateCode}, #{scenicId}, #{faceId}, #{contentHash},
#{status}, #{workerId}, #{leaseExpireTime}, #{attemptCount},
#{outputFormat}, #{outputQuality},
#{originalObjectKey}, #{croppedObjectKey},
#{payloadJson}, #{errorMessage},
NOW(), NOW()
)
</insert>
<select id="findNextClaimableTaskId" resultType="java.lang.Long">
SELECT id
FROM puzzle_edge_render_task
WHERE status = 0
OR (status = 1 AND lease_expire_time IS NOT NULL AND lease_expire_time &lt; NOW())
ORDER BY id ASC
LIMIT 1
</select>
<update id="claimTask">
UPDATE puzzle_edge_render_task
SET worker_id = #{workerId},
status = 1,
lease_expire_time = #{leaseExpireTime},
attempt_count = attempt_count + 1,
update_time = NOW()
WHERE id = #{taskId}
AND (
status = 0
OR (status = 1 AND lease_expire_time IS NOT NULL AND lease_expire_time &lt; NOW())
)
</update>
<update id="markSuccess">
UPDATE puzzle_edge_render_task
SET status = 2,
lease_expire_time = NULL,
error_message = NULL,
update_time = NOW()
WHERE id = #{taskId}
AND worker_id = #{workerId}
AND status = 1
</update>
<update id="markFail">
UPDATE puzzle_edge_render_task
SET status = 3,
lease_expire_time = NULL,
error_message = #{errorMessage},
update_time = NOW()
WHERE id = #{taskId}
AND worker_id = #{workerId}
AND status = 1
</update>
</mapper>

View File

@@ -512,4 +512,15 @@
SELECT COUNT(*) FROM member_source
WHERE face_id = #{faceId} AND `type` = #{type} AND is_free = 1
</select>
<select id="listSourceByFaceRelation" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
SELECT s.*
FROM member_source ms
INNER JOIN source s ON ms.source_id = s.id
WHERE ms.face_id = #{faceId}
<if test="type != null">
AND ms.type = #{type}
</if>
ORDER BY s.create_time DESC
</select>
</mapper>

View File

@@ -531,4 +531,24 @@
order by r.create_time desc limit 1
</select>
<!-- 统计分销员扫码次数 -->
<select id="countBrokerScanCount" resultType="java.lang.Integer">
SELECT count(1) AS count
FROM t_stats_record
WHERE action = 'CODE_SCAN'
AND identifier = #{brokerId}
</select>
<!-- 按日期统计分销员扫码数据 -->
<select id="getDailyScanStats" resultType="java.util.HashMap">
SELECT
DATE(create_time) AS date,
COUNT(DISTINCT id) AS scanCount
FROM t_stats_record
WHERE action = 'CODE_SCAN'
AND identifier = #{brokerId}
AND DATE(create_time) BETWEEN DATE(#{startTime}) AND DATE(#{endTime})
GROUP BY DATE(create_time)
</select>
</mapper>

View File

@@ -15,7 +15,7 @@
<if test="scenicId!= null">scenic_id = #{scenicId}, </if>
<if test="taskParams!= null">task_params = #{taskParams}, </if>
<if test="videoUrl!= null">video_url = #{videoUrl}, </if>
<if test="status!= null">status = #{status}, </if>
<if test="status!= null">`status` = #{status}, </if>
<if test="result!= null">result = #{result}, </if>
</set>
where id = #{id}
@@ -151,4 +151,25 @@
order by create_time desc
limit 1
</select>
<!-- 根据 face_id 列表统计已完成任务的用户数 -->
<select id="countCompletedTaskMembersByFaceIds" resultType="java.lang.Integer">
SELECT COUNT(DISTINCT mv.member_id) AS count
FROM member_video mv
WHERE mv.face_id IN
<foreach collection="faceIds" item="faceId" open="(" separator="," close=")">
#{faceId}
</foreach>
</select>
<!-- 根据 face_id 列表统计已完成任务数 -->
<select id="countCompletedTasksByFaceIds" resultType="java.lang.Integer">
SELECT COUNT(1) AS count
FROM task
WHERE status = 1
AND face_id IN
<foreach collection="faceIds" item="faceId" open="(" separator="," close=")">
#{faceId}
</foreach>
</select>
</mapper>

View File

@@ -0,0 +1,151 @@
package com.ycwl.basic.integration.common.service;
import com.ycwl.basic.integration.common.config.IntegrationProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class IntegrationFallbackServiceTest {
private IntegrationFallbackService service;
@BeforeEach
void setUp() {
service = new IntegrationFallbackService(new IntegrationProperties());
}
@Test
void shouldReturnCachedValueWhenCacheExists() {
String serviceName = "zt-scenic";
String cacheKey = "k1";
// 第一次调用,缓存结果
service.executeWithFallback(serviceName, cacheKey, () -> "cached", String.class);
// 第二次调用,应直接返回缓存,不调用远程
AtomicInteger remoteCalls = new AtomicInteger();
String result = service.executeWithFallback(serviceName, cacheKey, () -> {
remoteCalls.incrementAndGet();
return "remote";
}, String.class);
assertEquals("cached", result);
assertEquals(0, remoteCalls.get());
}
@Test
void shouldCallRemoteWhenNoCacheAndCacheResult() {
String serviceName = "zt-scenic";
String cacheKey = "k2";
AtomicInteger remoteCalls = new AtomicInteger();
String result = service.executeWithFallback(serviceName, cacheKey, () -> {
remoteCalls.incrementAndGet();
return "remote";
}, String.class);
assertEquals("remote", result);
assertEquals(1, remoteCalls.get());
assertTrue(service.hasFallbackCache(serviceName, cacheKey));
}
@Test
void shouldThrowWhenRemoteFailsAndNoCache() {
String serviceName = "zt-scenic";
String cacheKey = "k3";
RuntimeException ex = assertThrows(RuntimeException.class, () ->
service.executeWithFallback(serviceName, cacheKey, () -> {
throw new RuntimeException("boom");
}, String.class)
);
assertEquals("boom", ex.getMessage());
}
@Test
void shouldClearCache() {
String serviceName = "zt-scenic";
String cacheKey = "k4";
service.executeWithFallback(serviceName, cacheKey, () -> "value", String.class);
assertTrue(service.hasFallbackCache(serviceName, cacheKey));
service.clearFallbackCache(serviceName, cacheKey);
assertFalse(service.hasFallbackCache(serviceName, cacheKey));
}
@Test
void shouldClearAllCache() {
String serviceName = "zt-scenic";
service.executeWithFallback(serviceName, "key1", () -> "v1", String.class);
service.executeWithFallback(serviceName, "key2", () -> "v2", String.class);
service.clearAllFallbackCache(serviceName);
assertFalse(service.hasFallbackCache(serviceName, "key1"));
assertFalse(service.hasFallbackCache(serviceName, "key2"));
}
@Test
void shouldGetStats() {
String serviceName = "zt-device";
service.executeWithFallback(serviceName, "d1", () -> "v1", String.class);
service.executeWithFallback(serviceName, "d2", () -> "v2", String.class);
IntegrationFallbackService.FallbackCacheStats stats = service.getFallbackCacheStats(serviceName);
assertEquals(serviceName, stats.getServiceName());
assertEquals(2, stats.getTotalCacheCount());
assertEquals(1, stats.getCacheTtlMinutes());
}
@Test
void shouldOnlyCallRemoteOnceWithConcurrentRequests() throws InterruptedException {
String serviceName = "zt-scenic";
String cacheKey = "concurrent-test";
int threadCount = 10;
AtomicInteger remoteCalls = new AtomicInteger();
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
// 启动多个线程同时请求
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch.await(); // 等待同时开始
service.executeWithFallback(serviceName, cacheKey, () -> {
remoteCalls.incrementAndGet();
try {
Thread.sleep(50); // 模拟远程调用耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "result";
}, String.class);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneLatch.countDown();
}
});
}
startLatch.countDown(); // 同时放行所有线程
doneLatch.await(); // 等待所有线程完成
executor.shutdown();
// 互斥锁生效:只有一个线程真正调用了远程
assertEquals(1, remoteCalls.get());
}
}