You've already forked FrameTour-BE
Compare commits
31 Commits
2a3b4ca19f
...
1df6a4bc23
| Author | SHA1 | Date | |
|---|---|---|---|
| 1df6a4bc23 | |||
| 981a4ba7bd | |||
| 017ced34fa | |||
| a9ae00d580 | |||
| 99f75b6805 | |||
| 295815f1fa | |||
| 010bac1091 | |||
| eb9b781fd3 | |||
| 8d3dae32f3 | |||
| 43775f550b | |||
| 24f72091b3 | |||
| cc62fb4c18 | |||
| d1962ed615 | |||
| e1023b6ea8 | |||
| aec5e57df7 | |||
| 52ce26e630 | |||
| 32297dc29c | |||
| 21d8c56e82 | |||
| f8374519c3 | |||
| 44f5008fd1 | |||
| 6e0ebcd1bd | |||
| 5caf9a0ebf | |||
| 06bc2c2020 | |||
| 81dc2f1b86 | |||
| 41e90bab9c | |||
| b4628bd3e8 | |||
| cfb4284b7c | |||
| 5a61432dc9 | |||
| 91160a1adb | |||
| 991a8b10e3 | |||
| ab1e8cf7ef |
8
pom.xml
8
pom.xml
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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/**");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("图片生成失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ycwl.basic.puzzle.edge.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PuzzleEdgeTaskFailRequest {
|
||||
private String accessKey;
|
||||
private String errorMessage;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ycwl.basic.puzzle.edge.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PuzzleEdgeWorkerAuthRequest {
|
||||
private String accessKey;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
//支付订单金额
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 只有照片数量大于视频数量时才创建重切任务
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.ycwl.basic.stats.biz;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class StatsBiz {
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("-", "");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
91
src/main/java/com/ycwl/basic/utils/Ipv4CidrMatcher.java
Normal file
91
src/main/java/com/ycwl/basic/utils/Ipv4CidrMatcher.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 < 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>
|
||||
108
src/main/resources/mapper/PuzzleEdgeRenderTaskMapper.xml
Normal file
108
src/main/resources/mapper/PuzzleEdgeRenderTaskMapper.xml
Normal 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 < 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 < 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user