You've already forked FrameTour-BE
Compare commits
65 Commits
1df6a4bc23
...
4a07f5bba9
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a07f5bba9 | |||
| 1f7e6d69f4 | |||
| 50aaf7cb1a | |||
| f2c739160a | |||
| 2efc66292e | |||
| 0eced869fa | |||
| aa2611d369 | |||
| 6a8f679540 | |||
| 4fac129c3a | |||
| 830dd17071 | |||
| 83c831887e | |||
| 5ab2882777 | |||
| a5a9ff09f2 | |||
| 83e47ed843 | |||
| e9a4c26a83 | |||
| 8c76a4fb03 | |||
| 8198b0c537 | |||
| 0235d1d121 | |||
| 8d5a10cce1 | |||
| eba727b446 | |||
| 27a18096b5 | |||
| d15d070cb4 | |||
| fb4568721a | |||
| 63d31d69a9 | |||
| 2fb6aa42cf | |||
| fed92c5445 | |||
| 6d774e4d76 | |||
| 57b71c309e | |||
| 93e28828ad | |||
| f8c6604a8a | |||
| 3bd658cc1f | |||
| 7b417aa4f1 | |||
| 6ca7dceb0e | |||
| 0b3dd19de5 | |||
| e56c2e6642 | |||
| 482789b523 | |||
| d902b480b8 | |||
| fc0d5fed9b | |||
| 31b9220a32 | |||
| c4b78f1b09 | |||
| c9cc90c842 | |||
| 02f1392355 | |||
| d02aca9bf1 | |||
| 05e269a305 | |||
| 74c146c104 | |||
| 42000df311 | |||
| 8b7f3d8eae | |||
| 6e345f2da4 | |||
| d7c2c5b830 | |||
| 07593694c8 | |||
| 3ff76a0bea | |||
| 5952390093 | |||
| e896f58d82 | |||
| 3291371dd7 | |||
| 917668da0c | |||
| d3884c8aa2 | |||
| a652124a93 | |||
| 54cdee333d | |||
| 286062a81a | |||
| e0856a1b9c | |||
| 123a081eab | |||
| 95e86fb996 | |||
| 6c3a413778 | |||
| da2286bc80 | |||
| f1a2958251 |
@@ -1,6 +1,7 @@
|
||||
package com.ycwl.basic.biz;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import com.ycwl.basic.clickhouse.service.StatsQueryService;
|
||||
import com.ycwl.basic.mapper.BrokerMapper;
|
||||
import com.ycwl.basic.mapper.BrokerRecordMapper;
|
||||
import com.ycwl.basic.mapper.StatisticsMapper;
|
||||
@@ -34,7 +35,7 @@ public class BrokerBiz {
|
||||
@Autowired
|
||||
private ScenicRepository scenicRepository;
|
||||
@Autowired
|
||||
private StatisticsMapper statisticsMapper;
|
||||
private StatsQueryService statsQueryService;
|
||||
|
||||
public void processOrder(Long orderId) {
|
||||
log.info("开始处理订单分佣,订单ID:{}", orderId);
|
||||
@@ -52,7 +53,7 @@ public class BrokerBiz {
|
||||
if (scenicConfig.getInteger("sample_store_day") != null) {
|
||||
expireDay = scenicConfig.getInteger("sample_store_day");
|
||||
}
|
||||
List<Long> brokerIdList = statisticsMapper.getBrokerIdListForUser(order.getMemberId(), DateUtil.offsetDay(DateUtil.beginOfDay(order.getCreateAt()), -expireDay), order.getCreateAt());
|
||||
List<Long> brokerIdList = statsQueryService.getBrokerIdListForUser(order.getMemberId(), DateUtil.offsetDay(DateUtil.beginOfDay(order.getCreateAt()), -expireDay), order.getCreateAt());
|
||||
if (brokerIdList == null || brokerIdList.isEmpty()) {
|
||||
log.info("用户与推客无关,订单ID:{}", orderId);
|
||||
return;
|
||||
|
||||
@@ -42,6 +42,13 @@ public class FaceStatusManager {
|
||||
*/
|
||||
private final Cache<String, Integer> templateRenderCache;
|
||||
|
||||
/**
|
||||
* 拼图素材版本缓存
|
||||
* 键:faceId:puzzleTemplateId -> 当时的图片源数量
|
||||
* 用于判断拼图模板的素材是否发生变化,避免重复生成
|
||||
*/
|
||||
private final Cache<String, Integer> puzzleSourceVersionCache;
|
||||
|
||||
@Autowired
|
||||
private TaskMapper taskMapper;
|
||||
|
||||
@@ -61,6 +68,11 @@ public class FaceStatusManager {
|
||||
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
|
||||
.maximumSize(10000)
|
||||
.build();
|
||||
|
||||
this.puzzleSourceVersionCache = Caffeine.newBuilder()
|
||||
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
|
||||
.maximumSize(10000)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ==================== 切片状态相关方法 ====================
|
||||
@@ -293,4 +305,80 @@ public class FaceStatusManager {
|
||||
log.debug("批量删除模板渲染状态缓存: faceId={}, count={}", faceId, count);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 拼图素材版本相关方法 ====================
|
||||
|
||||
/**
|
||||
* 标记拼图素材版本(记录当前的图片源数量)
|
||||
* 在拼图生成成功后调用,用于后续判断素材是否变化
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @param puzzleTemplateId 拼图模板ID(全局唯一)
|
||||
* @param sourceCount 当前的图片源数量
|
||||
*/
|
||||
public void markPuzzleSourceVersion(Long faceId, Long puzzleTemplateId, int sourceCount) {
|
||||
if (faceId == null || puzzleTemplateId == null) {
|
||||
log.warn("标记拼图素材版本参数为空: faceId={}, puzzleTemplateId={}", faceId, puzzleTemplateId);
|
||||
return;
|
||||
}
|
||||
String key = faceId + ":" + puzzleTemplateId;
|
||||
puzzleSourceVersionCache.put(key, sourceCount);
|
||||
log.debug("标记拼图素材版本: faceId={}, puzzleTemplateId={}, sourceCount={}", faceId, puzzleTemplateId, sourceCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断拼图素材是否发生变化
|
||||
* 通过比较当前的图片源数量与缓存中记录的数量
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @param puzzleTemplateId 拼图模板ID(全局唯一)
|
||||
* @param currentSourceCount 当前的图片源数量
|
||||
* @return true=素材已变化(需要重新生成),false=素材未变化(可以跳过生成)
|
||||
*/
|
||||
public boolean isPuzzleSourceChanged(Long faceId, Long puzzleTemplateId, int currentSourceCount) {
|
||||
if (faceId == null || puzzleTemplateId == null) {
|
||||
log.warn("判断拼图素材变化参数为空: faceId={}, puzzleTemplateId={}", faceId, puzzleTemplateId);
|
||||
return true; // 参数不合法时默认认为有变化
|
||||
}
|
||||
|
||||
String key = faceId + ":" + puzzleTemplateId;
|
||||
Integer cachedCount = puzzleSourceVersionCache.getIfPresent(key);
|
||||
|
||||
if (cachedCount == null) {
|
||||
// 缓存不存在,认为有变化(首次生成或缓存过期)
|
||||
log.debug("拼图素材版本缓存不存在,需要生成: faceId={}, puzzleTemplateId={}", faceId, puzzleTemplateId);
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean changed = !cachedCount.equals(currentSourceCount);
|
||||
if (changed) {
|
||||
log.debug("拼图素材已变化: faceId={}, puzzleTemplateId={}, cachedCount={}, currentCount={}",
|
||||
faceId, puzzleTemplateId, cachedCount, currentSourceCount);
|
||||
} else {
|
||||
log.debug("拼图素材未变化,可跳过生成: faceId={}, puzzleTemplateId={}, sourceCount={}",
|
||||
faceId, puzzleTemplateId, currentSourceCount);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使指定人脸的所有拼图素材版本缓存失效
|
||||
* 当人脸的图片关联发生变化时调用(如人脸匹配后新增了关联)
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
*/
|
||||
public void invalidatePuzzleSourceVersion(Long faceId) {
|
||||
if (faceId == null) {
|
||||
return;
|
||||
}
|
||||
String prefix = faceId + ":";
|
||||
long count = puzzleSourceVersionCache.asMap().keySet().stream()
|
||||
.filter(key -> key.startsWith(prefix))
|
||||
.peek(puzzleSourceVersionCache::invalidate)
|
||||
.count();
|
||||
if (count > 0) {
|
||||
log.debug("批量使拼图素材版本缓存失效: faceId={}, count={}", faceId, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ycwl.basic.biz;
|
||||
|
||||
import com.ycwl.basic.clickhouse.service.StatsQueryService;
|
||||
import com.ycwl.basic.enums.StatisticEnum;
|
||||
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||
import com.ycwl.basic.mapper.OrderMapper;
|
||||
@@ -66,8 +67,10 @@ public class OrderBiz {
|
||||
private PrinterService printerService;
|
||||
@Autowired
|
||||
private IPriceCalculationService iPriceCalculationService;
|
||||
@Autowired
|
||||
private StatsQueryService statsQueryService;
|
||||
|
||||
public PriceObj queryPrice(Long scenicId, int goodsType, Long goodsId) {
|
||||
public PriceObj queryPrice(Long scenicId, Long memberId, int goodsType, Long goodsId) {
|
||||
PriceObj priceObj = new PriceObj();
|
||||
priceObj.setGoodsType(goodsType);
|
||||
priceObj.setGoodsId(goodsId);
|
||||
@@ -99,8 +102,10 @@ public class OrderBiz {
|
||||
vlogProductItem.setQuantity(videoTaskRepository.getTaskLensNum(video.getTaskId()));
|
||||
vlogProductItem.setScenicId(scenicId.toString());
|
||||
vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem));
|
||||
vlogCalculationRequest.setUserId(memberId);
|
||||
vlogCalculationRequest.setFaceId(priceObj.getFaceId());
|
||||
vlogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
vlogCalculationRequest.setAutoUseCoupon(true);
|
||||
PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest);
|
||||
priceObj.setPrice(vlogCalculationResult.getFinalAmount());
|
||||
priceObj.setSlashPrice(vlogCalculationResult.getOriginalAmount());
|
||||
@@ -120,13 +125,33 @@ public class OrderBiz {
|
||||
if (face != null) {
|
||||
calculationRequest.setUserId(face.getMemberId());
|
||||
}
|
||||
calculationRequest.setUserId(memberId);
|
||||
calculationRequest.setFaceId(goodsId);
|
||||
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
calculationRequest.setAutoUseCoupon(true);
|
||||
PriceCalculationResult priceCalculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
||||
priceObj.setPrice(priceCalculationResult.getFinalAmount());
|
||||
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
|
||||
priceObj.setFaceId(goodsId);
|
||||
break;
|
||||
case 5:
|
||||
PriceCalculationRequest plogCalculationRequest = new PriceCalculationRequest();
|
||||
ProductItem plogProductItem = new ProductItem();
|
||||
plogProductItem.setProductType(ProductType.PHOTO_LOG);
|
||||
plogProductItem.setProductId(scenicId.toString());
|
||||
plogProductItem.setPurchaseCount(1);
|
||||
plogProductItem.setScenicId(scenicId.toString());
|
||||
plogCalculationRequest.setProducts(Collections.singletonList(plogProductItem));
|
||||
plogCalculationRequest.setUserId(memberId);
|
||||
plogCalculationRequest.setFaceId(goodsId);
|
||||
plogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
plogCalculationRequest.setAutoUseCoupon(true);
|
||||
PriceCalculationResult plogPriceCalculationResult = iPriceCalculationService.calculatePrice(plogCalculationRequest);
|
||||
priceObj.setPrice(plogPriceCalculationResult.getFinalAmount());
|
||||
priceObj.setSlashPrice(plogPriceCalculationResult.getOriginalAmount());
|
||||
priceObj.setFaceId(goodsId);
|
||||
priceObj.setScenicId(scenicId);
|
||||
break;
|
||||
case 13:
|
||||
PriceCalculationRequest aiCamCalculationRequest = new PriceCalculationRequest();
|
||||
ProductItem aiCamProductItem = new ProductItem();
|
||||
@@ -135,7 +160,10 @@ public class OrderBiz {
|
||||
aiCamProductItem.setPurchaseCount(1);
|
||||
aiCamProductItem.setScenicId(scenicId.toString());
|
||||
aiCamCalculationRequest.setProducts(Collections.singletonList(aiCamProductItem));
|
||||
aiCamCalculationRequest.setUserId(memberId);
|
||||
aiCamCalculationRequest.setFaceId(goodsId);
|
||||
aiCamCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
aiCamCalculationRequest.setAutoUseCoupon(true);
|
||||
PriceCalculationResult aiCamPriceCalculationResult = iPriceCalculationService.calculatePrice(aiCamCalculationRequest);
|
||||
priceObj.setPrice(aiCamPriceCalculationResult.getFinalAmount());
|
||||
priceObj.setSlashPrice(aiCamPriceCalculationResult.getOriginalAmount());
|
||||
@@ -190,7 +218,7 @@ public class OrderBiz {
|
||||
}
|
||||
}
|
||||
}
|
||||
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId);
|
||||
PriceObj priceObj = queryPrice(scenicId, memberId, goodsType, goodsId);
|
||||
if (priceObj == null) {
|
||||
return respVO;
|
||||
}
|
||||
@@ -229,7 +257,7 @@ public class OrderBiz {
|
||||
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
||||
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
|
||||
statisticsRecordAddReq.setMemberId(order.getMemberId());
|
||||
Long enterType = statisticsMapper.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());
|
||||
Long enterType = statsQueryService.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());
|
||||
if(!Long.valueOf(1014).equals(enterType)){//
|
||||
statisticsRecordAddReq.setType(StatisticEnum.ON_SITE_PAYMENT.code);
|
||||
}else {
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.ycwl.basic.product.capability.ProductTypeCapability;
|
||||
import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||
import com.ycwl.basic.repository.OrderRepository;
|
||||
@@ -50,6 +51,8 @@ public class PriceBiz {
|
||||
@Autowired
|
||||
private PuzzleTemplateMapper puzzleTemplateMapper;
|
||||
@Autowired
|
||||
private PuzzleRepository puzzleRepository;
|
||||
@Autowired
|
||||
private IProductTypeCapabilityManagementService productTypeCapabilityManagementService;
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
@@ -74,8 +77,8 @@ public class PriceBiz {
|
||||
goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
|
||||
}
|
||||
}
|
||||
// 拼图
|
||||
puzzleTemplateMapper.list(scenicId, null, 1).forEach(puzzleTemplate -> {
|
||||
// 拼图(使用缓存)
|
||||
puzzleRepository.listTemplateByScenic(scenicId).forEach(puzzleTemplate -> {
|
||||
GoodsListRespVO goods = new GoodsListRespVO();
|
||||
goods.setGoodsId(puzzleTemplate.getId());
|
||||
goods.setGoodsName(puzzleTemplate.getName());
|
||||
@@ -131,7 +134,7 @@ public class PriceBiz {
|
||||
|
||||
case "PHOTO_LOG":
|
||||
// 从 template 表查询pLog模板
|
||||
List<PuzzleTemplateEntity> puzzleList = puzzleTemplateMapper.list(scenicId, null, null);
|
||||
List<PuzzleTemplateEntity> puzzleList = puzzleRepository.listTemplateByScenic(scenicId);
|
||||
puzzleList.stream()
|
||||
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
|
||||
.forEach(goodsList::add);
|
||||
|
||||
@@ -79,12 +79,26 @@ public interface StatsQueryService {
|
||||
List<HashMap<String, Object>> getDailyScanStats(Long brokerId, Date startTime, Date endTime);
|
||||
|
||||
/**
|
||||
* 按小时统计扫码人数
|
||||
* 按小时统计扫码人数(仅返回统计数据,不含订单)
|
||||
* 返回格式: [{t: "MM-dd HH", count: "xxx"}, ...]
|
||||
*/
|
||||
List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按日期统计扫码人数
|
||||
* 按日期统计扫码人数(仅返回统计数据,不含订单)
|
||||
* 返回格式: [{t: "MM-dd", count: "xxx"}, ...]
|
||||
*/
|
||||
List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按小时统计访问打印样片页面人数
|
||||
* 返回格式: [{t: "MM-dd HH", count: "xxx"}, ...]
|
||||
*/
|
||||
List<HashMap<String, String>> printerFromSampleChartByHour(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按日期统计访问打印样片页面人数
|
||||
* 返回格式: [{t: "MM-dd", count: "xxx"}, ...]
|
||||
*/
|
||||
List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,9 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'LOAD' ");
|
||||
sql.append("AND r.identifier = 'pages/videoSynthesis/buy' ");
|
||||
sql.append("AND JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
|
||||
sql.append("AND r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
sql.append("AND JSONExtractString(r.params, 'share') = '' ");
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
@@ -167,7 +169,9 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'FACE_UPLOAD' ");
|
||||
sql.append("AND JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
|
||||
sql.append("AND r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
@@ -183,7 +187,9 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
||||
sql.append("SELECT DISTINCT r.identifier FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'FACE_UPLOAD' ");
|
||||
sql.append("AND JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
|
||||
sql.append("AND r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
@@ -357,8 +363,6 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
||||
sql.append(") ");
|
||||
sql.append("AND r.action = 'LAUNCH' ");
|
||||
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
|
||||
sql.append("AND s.create_time BETWEEN ").append(formatDateTime(query.getStartTime()));
|
||||
sql.append(" AND ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
sql.append("GROUP BY toStartOfHour(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfHour(s.create_time)");
|
||||
|
||||
@@ -382,8 +386,64 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
||||
sql.append(") ");
|
||||
sql.append("AND r.action = 'LAUNCH' ");
|
||||
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
|
||||
sql.append("AND s.create_time BETWEEN ").append(formatDateTime(query.getStartTime()));
|
||||
sql.append(" AND ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
sql.append("GROUP BY toStartOfDay(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfDay(s.create_time)");
|
||||
|
||||
return getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
map.put("t", rs.getString("t"));
|
||||
map.put("count", rs.getString("count"));
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, String>> printerFromSampleChartByHour(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT formatDateTime(toStartOfHour(s.create_time), '%m-%d %H') AS t, ");
|
||||
sql.append(" uniqExact(s.member_id) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'LOAD' ");
|
||||
sql.append("AND r.identifier = 'pages/printer/hello' ");
|
||||
if (query.getScenicId() != null) {
|
||||
sql.append("AND JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
|
||||
}
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
sql.append("AND r.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
}
|
||||
sql.append("GROUP BY toStartOfHour(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfHour(s.create_time)");
|
||||
|
||||
return getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
map.put("t", rs.getString("t"));
|
||||
map.put("count", rs.getString("count"));
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT formatDateTime(toStartOfDay(s.create_time), '%m-%d') AS t, ");
|
||||
sql.append(" uniqExact(s.member_id) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'LOAD' ");
|
||||
sql.append("AND r.identifier = 'pages/printer/hello' ");
|
||||
if (query.getScenicId() != null) {
|
||||
sql.append("AND JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
|
||||
}
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
sql.append("AND r.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
}
|
||||
sql.append("GROUP BY toStartOfDay(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfDay(s.create_time)");
|
||||
|
||||
|
||||
@@ -98,4 +98,14 @@ public class MySqlStatsQueryServiceImpl implements StatsQueryService {
|
||||
public List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query) {
|
||||
return statisticsMapper.scanCodeMemberChartByDate(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, String>> printerFromSampleChartByHour(CommonQueryReq query) {
|
||||
return statisticsMapper.printerFromSampleChartByHour(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query) {
|
||||
return statisticsMapper.printerFromSampleChartByDate(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,9 +96,8 @@ public class AppFaceController {
|
||||
// 绑定人脸
|
||||
@PostMapping("/{faceId}/bind")
|
||||
public ApiResponse<String> bind(@PathVariable Long faceId) {
|
||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||
Long userId = worker.getUserId();
|
||||
faceService.bindFace(faceId, userId);
|
||||
// dummy item
|
||||
faceService.matchFaceId(faceId, true);
|
||||
return ApiResponse.success("OK");
|
||||
}
|
||||
|
||||
|
||||
@@ -53,12 +53,6 @@ public class AppGoodsController {
|
||||
return ApiResponse.success(count);
|
||||
}
|
||||
|
||||
@PostMapping("/sourceGoodsList/preview")
|
||||
public ApiResponse<List<GoodsUrlVO>> sourceGoodsListPreview(@RequestBody GoodsReqQuery query) {
|
||||
List<GoodsUrlVO> goodsUrlList = goodsService.sourceGoodsListPreview(query);
|
||||
return ApiResponse.success(goodsUrlList);
|
||||
}
|
||||
|
||||
@PostMapping("/sourceGoodsList/download")
|
||||
public ApiResponse<List<GoodsUrlVO>> sourceGoodsListDownload(@RequestBody GoodsReqQuery query) {
|
||||
List<GoodsUrlVO> goodsUrlList = goodsService.sourceGoodsListDownload(query);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import com.ycwl.basic.biz.OrderBiz;
|
||||
import com.ycwl.basic.constant.SourceType;
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
@@ -11,7 +10,7 @@ import com.ycwl.basic.pricing.dto.ProductItem;
|
||||
import com.ycwl.basic.pricing.enums.ProductType;
|
||||
import com.ycwl.basic.pricing.service.IPriceCalculationService;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
@@ -32,7 +31,7 @@ import java.util.stream.Collectors;
|
||||
@RequiredArgsConstructor
|
||||
public class AppPuzzleController {
|
||||
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
private final PuzzleRepository puzzleRepository;
|
||||
private final FaceRepository faceRepository;
|
||||
private final IPriceCalculationService iPriceCalculationService;
|
||||
private final PrinterService printerService;
|
||||
@@ -46,7 +45,7 @@ public class AppPuzzleController {
|
||||
if (faceId == null) {
|
||||
return ApiResponse.fail("faceId不能为空");
|
||||
}
|
||||
int count = recordMapper.countByFaceId(faceId);
|
||||
int count = puzzleRepository.countRecordsByFaceId(faceId);
|
||||
return ApiResponse.success(count);
|
||||
}
|
||||
|
||||
@@ -58,7 +57,7 @@ public class AppPuzzleController {
|
||||
if (faceId == null) {
|
||||
return ApiResponse.fail("faceId不能为空");
|
||||
}
|
||||
List<PuzzleGenerationRecordEntity> records = recordMapper.listByFaceId(faceId);
|
||||
List<PuzzleGenerationRecordEntity> records = puzzleRepository.getRecordsByFaceId(faceId);
|
||||
List<ContentPageVO> result = records.stream()
|
||||
.map(this::convertToContentPageVO)
|
||||
.collect(Collectors.toList());
|
||||
@@ -73,7 +72,7 @@ public class AppPuzzleController {
|
||||
if (recordId == null) {
|
||||
return ApiResponse.fail("recordId不能为空");
|
||||
}
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
@@ -89,7 +88,7 @@ public class AppPuzzleController {
|
||||
if (recordId == null) {
|
||||
return ApiResponse.fail("recordId不能为空");
|
||||
}
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
@@ -108,7 +107,7 @@ public class AppPuzzleController {
|
||||
if (recordId == null) {
|
||||
return ApiResponse.fail("recordId不能为空");
|
||||
}
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
@@ -142,14 +141,14 @@ public class AppPuzzleController {
|
||||
}
|
||||
|
||||
// 查询拼图记录
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
|
||||
// 检查是否有图片URL
|
||||
String resultImageUrl = record.getResultImageUrl();
|
||||
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
|
||||
String imageUrl = record.getResultImageUrl();
|
||||
if (imageUrl == null || imageUrl.isEmpty()) {
|
||||
return ApiResponse.fail("该拼图记录没有可用的图片URL");
|
||||
}
|
||||
|
||||
@@ -164,8 +163,8 @@ public class AppPuzzleController {
|
||||
face.getMemberId(),
|
||||
face.getScenicId(),
|
||||
record.getFaceId(),
|
||||
resultImageUrl,
|
||||
0L // 打印特有
|
||||
imageUrl,
|
||||
recordId // 拼图记录ID,用于关联 puzzle_record 表
|
||||
);
|
||||
|
||||
if (memberPrintId == null) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import com.ycwl.basic.mapper.TemplateMapper;
|
||||
import com.ycwl.basic.model.pc.template.entity.TemplateEntity;
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.repository.TemplateRepository;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -11,6 +12,10 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 移动端模板接口
|
||||
*/
|
||||
@@ -20,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class AppTemplateController {
|
||||
|
||||
private final TemplateRepository templateRepository;
|
||||
private final PuzzleRepository puzzleRepository;
|
||||
|
||||
/**
|
||||
* 根据模板ID获取封面URL
|
||||
@@ -45,4 +51,38 @@ public class AppTemplateController {
|
||||
|
||||
return ApiResponse.success(coverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据景区ID获取所有模板封面URL列表(用于前端预缓存)
|
||||
*
|
||||
* @param scenicId 景区ID
|
||||
* @return 模板封面URL列表
|
||||
*/
|
||||
@GetMapping("/scenic/{scenicId}/covers")
|
||||
@IgnoreToken
|
||||
public ApiResponse<List<String>> getScenicTemplateCoverUrls(@PathVariable("scenicId") Long scenicId) {
|
||||
if (scenicId == null) {
|
||||
return ApiResponse.fail("景区ID不能为空");
|
||||
}
|
||||
|
||||
List<String> coverUrls = new ArrayList<>();
|
||||
|
||||
// 获取普通模板封面
|
||||
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(scenicId);
|
||||
templateList.stream()
|
||||
.map(TemplateRespVO::getCoverUrl)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(url -> !url.isEmpty())
|
||||
.forEach(coverUrls::add);
|
||||
|
||||
// 获取拼图模板封面(使用缓存)
|
||||
List<PuzzleTemplateEntity> puzzleTemplateList = puzzleRepository.listTemplateByScenic(scenicId);
|
||||
puzzleTemplateList.stream()
|
||||
.map(PuzzleTemplateEntity::getCoverImage)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(url -> !url.isEmpty())
|
||||
.forEach(coverUrls::add);
|
||||
|
||||
return ApiResponse.success(coverUrls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.ycwl.basic.controller.mobile.notify;
|
||||
|
||||
import com.ycwl.basic.model.mobile.notify.req.BatchRemainingCountReq;
|
||||
import com.ycwl.basic.model.mobile.notify.req.NotificationAuthRecordReq;
|
||||
import com.ycwl.basic.model.mobile.notify.resp.NotificationAuthRecordResp;
|
||||
import com.ycwl.basic.model.mobile.notify.resp.ScenicTemplateAuthResp;
|
||||
import com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationEntity;
|
||||
import com.ycwl.basic.service.UserNotificationAuthorizationService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||
@@ -14,7 +16,9 @@ import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户通知授权记录Controller (移动端API)
|
||||
@@ -41,7 +45,8 @@ public class UserNotificationAuthController {
|
||||
@PostMapping("/record")
|
||||
public ApiResponse<NotificationAuthRecordResp> recordAuthorization(
|
||||
@RequestBody NotificationAuthRecordReq req) {
|
||||
log.debug("记录用户通知授权: templateIds={}, scenicId={}", req.getTemplateIds(), req.getScenicId());
|
||||
log.debug("记录用户通知授权: templateIds={}, scenicId={}, requestId={}",
|
||||
req.getTemplateIds(), req.getScenicId(), req.getRequestId());
|
||||
|
||||
try {
|
||||
// 获取当前用户ID
|
||||
@@ -50,7 +55,7 @@ public class UserNotificationAuthController {
|
||||
// 调用批量授权记录方法
|
||||
List<UserNotificationAuthorizationService.AuthorizationRecord> records =
|
||||
userNotificationAuthorizationService.batchRecordAuthorization(
|
||||
memberId, req.getTemplateIds(), req.getScenicId());
|
||||
memberId, req.getTemplateIds(), req.getScenicId(), req.getRequestId());
|
||||
|
||||
NotificationAuthRecordResp resp = new NotificationAuthRecordResp();
|
||||
|
||||
@@ -93,98 +98,42 @@ public class UserNotificationAuthController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取景区通知模板ID及用户授权余额
|
||||
* 复制AppWxNotifyController中的逻辑,并额外返回用户对应的授权余额
|
||||
* 批量查询用户授权余额
|
||||
* 返回 Map<wechatTemplateId, remainingCount>
|
||||
*/
|
||||
@GetMapping("/{scenicId}/templates")
|
||||
public ApiResponse<ScenicTemplateAuthResp> getScenicTemplatesWithAuth(@PathVariable("scenicId") Long scenicId) {
|
||||
log.debug("获取景区通知模板ID及用户授权余额: scenicId={}", scenicId);
|
||||
@PostMapping("/batch-remaining")
|
||||
public ApiResponse<Map<String, Integer>> batchGetRemainingCount(
|
||||
@RequestBody BatchRemainingCountReq req) {
|
||||
log.debug("批量查询用户授权余额: templateIds={}, scenicId={}",
|
||||
req.getTemplateIds(), req.getScenicId());
|
||||
|
||||
try {
|
||||
// 获取当前用户ID
|
||||
Long memberId = JwtTokenUtil.getWorker().getUserId();
|
||||
|
||||
// 获取景区的所有模板ID(复制自AppWxNotifyController的逻辑)
|
||||
List<String> templateIds = new ArrayList<>() {{
|
||||
String videoGeneratedTemplateId = scenicRepository.getVideoGeneratedTemplateId(scenicId);
|
||||
if (StringUtils.isNotBlank(videoGeneratedTemplateId)) {
|
||||
add(videoGeneratedTemplateId);
|
||||
}
|
||||
String videoDownloadTemplateId = scenicRepository.getVideoDownloadTemplateId(scenicId);
|
||||
if (StringUtils.isNotBlank(videoDownloadTemplateId)) {
|
||||
add(videoDownloadTemplateId);
|
||||
}
|
||||
String videoPreExpireTemplateId = scenicRepository.getVideoPreExpireTemplateId(scenicId);
|
||||
if (StringUtils.isNotBlank(videoPreExpireTemplateId)) {
|
||||
add(videoPreExpireTemplateId);
|
||||
}
|
||||
}};
|
||||
|
||||
// 构建响应对象
|
||||
ScenicTemplateAuthResp resp = new ScenicTemplateAuthResp();
|
||||
resp.setScenicId(scenicId);
|
||||
|
||||
// 查询每个模板的授权余额信息
|
||||
List<ScenicTemplateAuthResp.TemplateAuthInfo> templateAuthInfos = new ArrayList<>();
|
||||
for (String templateId : templateIds) {
|
||||
ScenicTemplateAuthResp.TemplateAuthInfo templateAuthInfo =
|
||||
new ScenicTemplateAuthResp.TemplateAuthInfo();
|
||||
templateAuthInfo.setTemplateId(templateId);
|
||||
|
||||
if (templateId.equals(scenicRepository.getVideoGeneratedTemplateId(scenicId))) {
|
||||
templateAuthInfo.setTitle("视频生成通知");
|
||||
templateAuthInfo.setDescription("当视频生成完成时,我们将提醒您");
|
||||
} else if (templateId.equals(scenicRepository.getVideoDownloadTemplateId(scenicId))) {
|
||||
templateAuthInfo.setTitle("视频下载通知");
|
||||
templateAuthInfo.setDescription("当您的视频未购买时,我们将提醒您");
|
||||
} else if (templateId.equals(scenicRepository.getVideoPreExpireTemplateId(scenicId))) {
|
||||
templateAuthInfo.setTitle("视频即将过期通知");
|
||||
templateAuthInfo.setDescription("当您的视频即将过期时,我们将提醒您及时下载");
|
||||
} else {
|
||||
templateAuthInfo.setTitle("未知模板类型");
|
||||
templateAuthInfo.setDescription("未知的模板类型");
|
||||
}
|
||||
|
||||
// 获取授权详情
|
||||
try {
|
||||
com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationEntity authEntity =
|
||||
userNotificationAuthorizationService.checkAuthorization(memberId, templateId, scenicId);
|
||||
|
||||
if (authEntity != null) {
|
||||
templateAuthInfo.setAuthorizationCount(authEntity.getAuthorizationCount());
|
||||
templateAuthInfo.setConsumedCount(authEntity.getConsumedCount());
|
||||
templateAuthInfo.setRemainingCount(authEntity.getRemainingCount());
|
||||
templateAuthInfo.setHasAuthorization(authEntity.getRemainingCount() != null && authEntity.getRemainingCount() > 0);
|
||||
} else {
|
||||
// 没有授权记录
|
||||
templateAuthInfo.setAuthorizationCount(0);
|
||||
templateAuthInfo.setConsumedCount(0);
|
||||
templateAuthInfo.setRemainingCount(0);
|
||||
templateAuthInfo.setHasAuthorization(false);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取模板授权信息失败: templateId={}, scenicId={}, memberId={}, error={}",
|
||||
templateId, scenicId, memberId, e.getMessage());
|
||||
|
||||
// 获取失败时设置为无授权
|
||||
templateAuthInfo.setAuthorizationCount(0);
|
||||
templateAuthInfo.setConsumedCount(0);
|
||||
templateAuthInfo.setRemainingCount(0);
|
||||
templateAuthInfo.setHasAuthorization(false);
|
||||
}
|
||||
|
||||
templateAuthInfos.add(templateAuthInfo);
|
||||
if (memberId == null) {
|
||||
return ApiResponse.fail("用户未登录");
|
||||
}
|
||||
|
||||
resp.setTemplates(templateAuthInfos);
|
||||
if (CollectionUtils.isEmpty(req.getTemplateIds())) {
|
||||
return ApiResponse.success(new HashMap<>());
|
||||
}
|
||||
|
||||
log.debug("成功获取景区通知模板ID及用户授权余额: scenicId={}, templateCount={}, memberId={}",
|
||||
scenicId, templateIds.size(), memberId);
|
||||
Map<String, UserNotificationAuthorizationEntity> authMap =
|
||||
userNotificationAuthorizationService.batchCheckAuthorization(
|
||||
memberId, req.getTemplateIds(), req.getScenicId());
|
||||
|
||||
return ApiResponse.success(resp);
|
||||
// 转换为 templateId -> remainingCount
|
||||
Map<String, Integer> result = new HashMap<>();
|
||||
for (String templateId : req.getTemplateIds()) {
|
||||
UserNotificationAuthorizationEntity entity = authMap.get(templateId);
|
||||
int remaining = (entity != null && entity.getRemainingCount() != null)
|
||||
? entity.getRemainingCount() : 0;
|
||||
result.put(templateId, remaining);
|
||||
}
|
||||
|
||||
return ApiResponse.success(result);
|
||||
} catch (Exception e) {
|
||||
log.error("获取景区通知模板ID及用户授权余额失败: scenicId={}", scenicId, e);
|
||||
return ApiResponse.fail("获取授权信息失败: " + e.getMessage());
|
||||
log.error("批量查询用户授权余额失败", e);
|
||||
return ApiResponse.fail("查询失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.ycwl.basic.controller.mobile.notify;
|
||||
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeAllScenesResp;
|
||||
import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeSceneTemplatesResp;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
|
||||
import com.ycwl.basic.service.notify.WechatSubscribeNotifyConfigService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||
import com.ycwl.basic.utils.NotificationAuthUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 微信小程序订阅消息:场景模板查询(移动端API)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/notify/subscribe")
|
||||
@Slf4j
|
||||
public class WechatSubscribeNotifyController {
|
||||
|
||||
private final WechatSubscribeNotifyConfigService configService;
|
||||
private final NotificationAuthUtils notificationAuthUtils;
|
||||
|
||||
public WechatSubscribeNotifyController(WechatSubscribeNotifyConfigService configService,
|
||||
NotificationAuthUtils notificationAuthUtils) {
|
||||
this.configService = configService;
|
||||
this.notificationAuthUtils = notificationAuthUtils;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取“场景”下可申请授权的模板列表(支持按 scenicId 覆盖模板ID/开关/文案)
|
||||
*/
|
||||
@GetMapping("/scenic/{scenicId}/scenes/{sceneKey}/templates")
|
||||
@IgnoreToken
|
||||
public ApiResponse<WechatSubscribeSceneTemplatesResp> listSceneTemplates(@PathVariable("scenicId") Long scenicId,
|
||||
@PathVariable("sceneKey") String sceneKey) {
|
||||
if (scenicId == null) {
|
||||
return ApiResponse.fail("scenicId不能为空");
|
||||
}
|
||||
if (StringUtils.isBlank(sceneKey)) {
|
||||
return ApiResponse.fail("sceneKey不能为空");
|
||||
}
|
||||
|
||||
Long memberId = JwtTokenUtil.getWorker().getUserId();
|
||||
List<WechatSubscribeTemplateConfigEntity> configs = configService.listSceneTemplateConfigs(scenicId, sceneKey);
|
||||
|
||||
WechatSubscribeSceneTemplatesResp resp = new WechatSubscribeSceneTemplatesResp();
|
||||
resp.setScenicId(scenicId);
|
||||
resp.setSceneKey(sceneKey);
|
||||
if (memberId == null) {
|
||||
return ApiResponse.success(resp);
|
||||
}
|
||||
|
||||
List<WechatSubscribeSceneTemplatesResp.TemplateInfo> templates = new ArrayList<>();
|
||||
for (WechatSubscribeTemplateConfigEntity cfg : configs) {
|
||||
if (cfg == null || StringUtils.isBlank(cfg.getWechatTemplateId())) {
|
||||
continue;
|
||||
}
|
||||
String title = StringUtils.isNotBlank(cfg.getTitleTemplate())
|
||||
? cfg.getTitleTemplate()
|
||||
: cfg.getTemplateKey();
|
||||
int remaining = notificationAuthUtils.getRemainingCount(memberId, cfg.getWechatTemplateId(), scenicId);
|
||||
|
||||
WechatSubscribeSceneTemplatesResp.TemplateInfo info = new WechatSubscribeSceneTemplatesResp.TemplateInfo();
|
||||
info.setTemplateKey(cfg.getTemplateKey());
|
||||
info.setWechatTemplateId(cfg.getWechatTemplateId());
|
||||
info.setTitle(title);
|
||||
info.setDescription(cfg.getDescription());
|
||||
info.setRemainingCount(remaining);
|
||||
info.setHasAuthorization(remaining > 0);
|
||||
templates.add(info);
|
||||
}
|
||||
resp.setTemplates(templates);
|
||||
|
||||
log.debug("场景模板查询: scenicId={}, sceneKey={}, memberId={}, templateCount={}",
|
||||
scenicId, sceneKey, memberId, Objects.requireNonNullElse(templates.size(), 0));
|
||||
return ApiResponse.success(resp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取景区下所有场景及其模板列表(静态配置,带缓存)
|
||||
* 不含用户授权信息,用户授权信息通过 /api/mobile/notify/auth/batch-remaining 接口获取
|
||||
*/
|
||||
@GetMapping("/scenic/{scenicId}/scenes")
|
||||
@IgnoreToken
|
||||
public ApiResponse<WechatSubscribeAllScenesResp> listAllSceneTemplates(@PathVariable("scenicId") Long scenicId) {
|
||||
if (scenicId == null) {
|
||||
return ApiResponse.fail("scenicId不能为空");
|
||||
}
|
||||
|
||||
WechatSubscribeAllScenesResp resp = configService.getAllScenesWithTemplatesCached(scenicId);
|
||||
log.debug("所有场景模板查询: scenicId={}, sceneCount={}",
|
||||
scenicId, resp.getScenes() != null ? resp.getScenes().size() : 0);
|
||||
return ApiResponse.success(resp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||
import com.ycwl.basic.model.pc.device.req.VideoContinuityReportReq;
|
||||
import com.ycwl.basic.repository.DeviceRepository;
|
||||
import com.ycwl.basic.task.DeviceVideoContinuityCheckTask;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -37,7 +36,6 @@ public class DeviceVideoContinuityController {
|
||||
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final DeviceVideoContinuityCheckTask checkTask;
|
||||
private final DeviceRepository deviceRepository;
|
||||
|
||||
/**
|
||||
@@ -78,15 +76,7 @@ public class DeviceVideoContinuityController {
|
||||
@PostMapping("/{deviceId}/check")
|
||||
public ApiResponse<DeviceVideoContinuityCache> manualCheck(@PathVariable Long deviceId) {
|
||||
log.info("手动触发设备 {} 的视频连续性检查", deviceId);
|
||||
|
||||
try {
|
||||
DeviceVideoContinuityCache result = checkTask.manualCheck(deviceId);
|
||||
return ApiResponse.success(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("手动检查设备 {} 视频连续性失败", deviceId, e);
|
||||
return ApiResponse.buildResponse(500, null, "检查失败: " + e.getMessage());
|
||||
}
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,7 +64,9 @@ public class SourceController {
|
||||
Map<String, Object> result = printerService.createVirtualOrder(
|
||||
request.getSourceId(),
|
||||
request.getScenicId(),
|
||||
request.getPrinterId()
|
||||
request.getPrinterId(),
|
||||
request.getNeedEnhance(),
|
||||
request.getPrintImgUrl()
|
||||
);
|
||||
return ApiResponse.success(result);
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.ycwl.basic.controller.pc;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplatePageReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplateSaveReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplatePageReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplateSaveReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSendLogPageReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigPageReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigSaveReq;
|
||||
import com.ycwl.basic.service.pc.WechatSubscribeNotifyAdminService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 微信小程序订阅消息:配置管理(管理后台)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/wechatSubscribeNotify/v1")
|
||||
@RequiredArgsConstructor
|
||||
public class WechatSubscribeNotifyAdminController {
|
||||
|
||||
private final WechatSubscribeNotifyAdminService adminService;
|
||||
|
||||
// ========================= 模板配置 =========================
|
||||
|
||||
@PostMapping("/templateConfig/page")
|
||||
public ApiResponse<PageInfo<WechatSubscribeTemplateConfigEntity>> pageTemplateConfig(
|
||||
@RequestBody WechatSubscribeTemplateConfigPageReq req) {
|
||||
return adminService.pageTemplateConfig(req);
|
||||
}
|
||||
|
||||
@GetMapping("/templateConfig/detail/{id}")
|
||||
public ApiResponse<WechatSubscribeTemplateConfigEntity> getTemplateConfig(@PathVariable("id") Long id) {
|
||||
return adminService.getTemplateConfig(id);
|
||||
}
|
||||
|
||||
@PostMapping("/templateConfig/save")
|
||||
public ApiResponse<Boolean> saveTemplateConfig(@RequestBody WechatSubscribeTemplateConfigSaveReq req) {
|
||||
return adminService.saveTemplateConfig(req);
|
||||
}
|
||||
|
||||
@DeleteMapping("/templateConfig/delete/{id}")
|
||||
public ApiResponse<Boolean> deleteTemplateConfig(@PathVariable("id") Long id) {
|
||||
return adminService.deleteTemplateConfig(id);
|
||||
}
|
||||
|
||||
// ========================= 场景映射 =========================
|
||||
|
||||
@PostMapping("/sceneTemplate/page")
|
||||
public ApiResponse<PageInfo<WechatSubscribeSceneTemplateEntity>> pageSceneTemplate(
|
||||
@RequestBody WechatSubscribeSceneTemplatePageReq req) {
|
||||
return adminService.pageSceneTemplate(req);
|
||||
}
|
||||
|
||||
@GetMapping("/sceneTemplate/detail/{id}")
|
||||
public ApiResponse<WechatSubscribeSceneTemplateEntity> getSceneTemplate(@PathVariable("id") Long id) {
|
||||
return adminService.getSceneTemplate(id);
|
||||
}
|
||||
|
||||
@PostMapping("/sceneTemplate/save")
|
||||
public ApiResponse<Boolean> saveSceneTemplate(@RequestBody WechatSubscribeSceneTemplateSaveReq req) {
|
||||
return adminService.saveSceneTemplate(req);
|
||||
}
|
||||
|
||||
@DeleteMapping("/sceneTemplate/delete/{id}")
|
||||
public ApiResponse<Boolean> deleteSceneTemplate(@PathVariable("id") Long id) {
|
||||
return adminService.deleteSceneTemplate(id);
|
||||
}
|
||||
|
||||
// ========================= 事件映射 =========================
|
||||
|
||||
@PostMapping("/eventTemplate/page")
|
||||
public ApiResponse<PageInfo<WechatSubscribeEventTemplateEntity>> pageEventTemplate(
|
||||
@RequestBody WechatSubscribeEventTemplatePageReq req) {
|
||||
return adminService.pageEventTemplate(req);
|
||||
}
|
||||
|
||||
@GetMapping("/eventTemplate/detail/{id}")
|
||||
public ApiResponse<WechatSubscribeEventTemplateEntity> getEventTemplate(@PathVariable("id") Long id) {
|
||||
return adminService.getEventTemplate(id);
|
||||
}
|
||||
|
||||
@PostMapping("/eventTemplate/save")
|
||||
public ApiResponse<Boolean> saveEventTemplate(@RequestBody WechatSubscribeEventTemplateSaveReq req) {
|
||||
return adminService.saveEventTemplate(req);
|
||||
}
|
||||
|
||||
@DeleteMapping("/eventTemplate/delete/{id}")
|
||||
public ApiResponse<Boolean> deleteEventTemplate(@PathVariable("id") Long id) {
|
||||
return adminService.deleteEventTemplate(id);
|
||||
}
|
||||
|
||||
// ========================= 发送日志 =========================
|
||||
|
||||
@PostMapping("/sendLog/page")
|
||||
public ApiResponse<PageInfo<WechatSubscribeSendLogEntity>> pageSendLog(@RequestBody WechatSubscribeSendLogPageReq req) {
|
||||
return adminService.pageSendLog(req);
|
||||
}
|
||||
|
||||
@GetMapping("/sendLog/detail/{id}")
|
||||
public ApiResponse<WechatSubscribeSendLogEntity> getSendLog(@PathVariable("id") Long id) {
|
||||
return adminService.getSendLog(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,46 +120,8 @@ public class PrinterTvController {
|
||||
*/
|
||||
@GetMapping("/face/{faceId}/qrcode")
|
||||
public void getFaceQrcode(@PathVariable("faceId") Long faceId, HttpServletResponse response) throws Exception {
|
||||
File qrcode = new File("qrcode_face_" + faceId + ".jpg");
|
||||
try {
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
response.setStatus(404);
|
||||
return;
|
||||
}
|
||||
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(face.getScenicId());
|
||||
if (scenicMpConfig == null) {
|
||||
response.setStatus(500);
|
||||
return;
|
||||
}
|
||||
WxMpUtil.generateUnlimitedWXAQRCode(
|
||||
scenicMpConfig.getAppId(),
|
||||
scenicMpConfig.getAppSecret(),
|
||||
"pages/videoSynthesis/bind_face",
|
||||
faceId.toString(),
|
||||
qrcode
|
||||
);
|
||||
|
||||
// 设置响应头
|
||||
response.setContentType("image/jpeg");
|
||||
response.setHeader("Content-Disposition", "inline; filename=\"" + qrcode.getName() + "\"");
|
||||
|
||||
// 将二维码文件写入响应输出流
|
||||
try (FileInputStream fis = new FileInputStream(qrcode);
|
||||
OutputStream os = response.getOutputStream()) {
|
||||
byte[] buffer = new byte[1024];
|
||||
int bytesRead;
|
||||
while ((bytesRead = fis.read(buffer)) != -1) {
|
||||
os.write(buffer, 0, bytesRead);
|
||||
}
|
||||
os.flush();
|
||||
}
|
||||
} finally {
|
||||
// 删除临时文件
|
||||
if (qrcode.exists()) {
|
||||
qrcode.delete();
|
||||
}
|
||||
}
|
||||
String url = pcFaceService.bindWxaCode(faceId);
|
||||
response.sendRedirect(url);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,5 +159,36 @@ public class PrinterTvController {
|
||||
return ApiResponse.success(resp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过人脸样本ID重定向到人脸图片URL
|
||||
*
|
||||
* @param faceSampleId 人脸样本ID
|
||||
* @param response HTTP响应
|
||||
*/
|
||||
@GetMapping("/faceSample/{faceSampleId}/url")
|
||||
public void redirectToFaceSampleUrl(@PathVariable Long faceSampleId, HttpServletResponse response) throws Exception {
|
||||
FaceSampleEntity faceSample = faceRepository.getFaceSample(faceSampleId);
|
||||
if (faceSample == null || faceSample.getFaceUrl() == null) {
|
||||
response.setStatus(404);
|
||||
return;
|
||||
}
|
||||
response.sendRedirect(faceSample.getFaceUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过人脸ID重定向到人脸图片URL
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @param response HTTP响应
|
||||
*/
|
||||
@GetMapping("/face/{faceId}/url")
|
||||
public void redirectToFaceUrl(@PathVariable Long faceId, HttpServletResponse response) throws Exception {
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null || face.getFaceUrl() == null) {
|
||||
response.setStatus(404);
|
||||
return;
|
||||
}
|
||||
response.sendRedirect(face.getFaceUrl());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ public class PuzzleGenerationOrchestrator {
|
||||
|
||||
} catch (Exception e) {
|
||||
// 异步任务失败不影响主流程,仅记录日志
|
||||
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
|
||||
log.error("异步生成拼图模板失败: scenicId={}, faceId={}, e={}", scenicId, faceId, e.getMessage());
|
||||
}
|
||||
}, "PuzzleTemplateGenerator-" + scenicId + "-" + faceId).start();
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ public class DeleteOldRelationsStage extends AbstractPipelineStage<FaceMatchingC
|
||||
@Autowired
|
||||
private MemberRelationRepository memberRelationRepository;
|
||||
|
||||
@Autowired
|
||||
private com.ycwl.basic.biz.FaceStatusManager faceStatusManager;
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "DeleteOldRelations";
|
||||
@@ -60,6 +63,7 @@ public class DeleteOldRelationsStage extends AbstractPipelineStage<FaceMatchingC
|
||||
|
||||
// 3. 清除缓存
|
||||
memberRelationRepository.clearSCacheByFace(faceId);
|
||||
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
|
||||
|
||||
log.debug("人脸旧关系数据删除完成:faceId={}", faceId);
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ public class PersistRelationsStage extends AbstractPipelineStage<FaceMatchingCon
|
||||
@Autowired
|
||||
private MemberRelationRepository memberRelationRepository;
|
||||
|
||||
@Autowired
|
||||
private com.ycwl.basic.biz.FaceStatusManager faceStatusManager;
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "PersistRelations";
|
||||
@@ -87,6 +90,7 @@ public class PersistRelationsStage extends AbstractPipelineStage<FaceMatchingCon
|
||||
|
||||
// 4. 清除缓存
|
||||
memberRelationRepository.clearSCacheByFace(faceId);
|
||||
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
|
||||
|
||||
return StageResult.success(String.format("持久化了%d条关联关系", validFiltered.size()));
|
||||
|
||||
|
||||
@@ -168,7 +168,6 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
|
||||
// 重试时也不需要限流,由外层调度器控制
|
||||
JSONObject retryResponse = client.addUser(base64Image, "BASE64", dbName, entityId, options);
|
||||
if (retryResponse.getInt("error_code") == 0) {
|
||||
log.info("使用base64重试添加人脸成功,entityId: {}", entityId);
|
||||
AddFaceResp resp = new AddFaceResp();
|
||||
resp.setScore(100f);
|
||||
return resp;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.ycwl.basic.image.pipeline.stages;
|
||||
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeService;
|
||||
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
|
||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -41,4 +43,28 @@ public class WatermarkConfig {
|
||||
*/
|
||||
@Builder.Default
|
||||
private final Double scale = 1.0;
|
||||
|
||||
/**
|
||||
* 边缘端水印服务(可选)
|
||||
* 如果设置,将优先尝试使用边缘端处理
|
||||
*/
|
||||
private final WatermarkEdgeService edgeService;
|
||||
|
||||
/**
|
||||
* 存储适配器(边缘端处理时需要)
|
||||
* 用于上传原图和二维码到临时位置
|
||||
*/
|
||||
private final IStorageAdapter storageAdapter;
|
||||
|
||||
/**
|
||||
* 是否启用边缘端处理
|
||||
*/
|
||||
@Builder.Default
|
||||
private final boolean edgeEnabled = true;
|
||||
|
||||
/**
|
||||
* 边缘端处理超时时间(毫秒)
|
||||
*/
|
||||
@Builder.Default
|
||||
private final long edgeTimeoutMs = 10_000L;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ycwl.basic.image.pipeline.stages;
|
||||
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
||||
import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeService;
|
||||
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
|
||||
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
|
||||
import com.ycwl.basic.image.watermark.operator.IOperator;
|
||||
@@ -10,6 +11,7 @@ import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||
import com.ycwl.basic.pipeline.core.StageResult;
|
||||
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
@@ -21,6 +23,7 @@ import java.util.List;
|
||||
/**
|
||||
* 水印处理Stage
|
||||
* 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印
|
||||
* 支持边缘端渲染(可选)
|
||||
*/
|
||||
@Slf4j
|
||||
@StageConfig(
|
||||
@@ -127,6 +130,19 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
File watermarkedFile = context.getTempFileManager()
|
||||
.createTempFile("watermark_" + type.getType(), "." + fileExt);
|
||||
|
||||
// 尝试边缘端处理
|
||||
if (shouldUseEdgeProcessing(type)) {
|
||||
File edgeResult = tryEdgeProcessing(context, type, currentFile, watermarkedFile);
|
||||
if (edgeResult != null && edgeResult.exists()) {
|
||||
context.updateProcessedFile(edgeResult);
|
||||
log.info("边缘端水印应用成功: type={}, size={}KB", type.getType(), edgeResult.length() / 1024);
|
||||
return StageResult.success(String.format("水印(边缘端): %s (%dKB)",
|
||||
type.getType(), edgeResult.length() / 1024));
|
||||
}
|
||||
log.warn("边缘端水印处理失败,降级到本地处理: type={}", type.getType());
|
||||
}
|
||||
|
||||
// 本地处理(降级或直接使用)
|
||||
WatermarkInfo watermarkInfo = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
|
||||
|
||||
IOperator operator = ImageWatermarkFactory.get(type);
|
||||
@@ -143,6 +159,46 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
type.getType(), result.length() / 1024));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应使用边缘端处理
|
||||
*/
|
||||
private boolean shouldUseEdgeProcessing(ImageWatermarkOperatorEnum type) {
|
||||
if (!config.isEdgeEnabled()) {
|
||||
return false;
|
||||
}
|
||||
WatermarkEdgeService edgeService = config.getEdgeService();
|
||||
if (edgeService == null) {
|
||||
return false;
|
||||
}
|
||||
IStorageAdapter storageAdapter = config.getStorageAdapter();
|
||||
return storageAdapter != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试使用边缘端处理
|
||||
*
|
||||
* @return 处理后的文件,失败返回 null
|
||||
*/
|
||||
private File tryEdgeProcessing(PhotoProcessContext context,
|
||||
ImageWatermarkOperatorEnum type,
|
||||
File currentFile,
|
||||
File watermarkedFile) {
|
||||
try {
|
||||
WatermarkEdgeService edgeService = config.getEdgeService();
|
||||
IStorageAdapter storageAdapter = config.getStorageAdapter();
|
||||
|
||||
// 构建水印信息用于边缘端处理
|
||||
WatermarkInfo info = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
|
||||
|
||||
// 调用边缘端服务处理,传递 processId 作为 recordId
|
||||
return edgeService.processWatermarkFromFile(info, type, storageAdapter, context.getProcessId());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("边缘端水印处理异常: type={}, error={}", type.getType(), e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建水印参数
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.ycwl.basic.image.watermark;
|
||||
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
|
||||
import com.ycwl.basic.image.watermark.exception.ImageWatermarkUnsupportedException;
|
||||
import com.ycwl.basic.image.watermark.operator.IOperator;
|
||||
import com.ycwl.basic.image.watermark.operator.DefaultImageWatermarkOperator;
|
||||
import com.ycwl.basic.image.watermark.operator.LeicaWatermarkOperator;
|
||||
import com.ycwl.basic.image.watermark.operator.NormalWatermarkOperator;
|
||||
import com.ycwl.basic.image.watermark.operator.PrinterDefaultWatermarkOperator;
|
||||
@@ -18,11 +17,11 @@ public class ImageWatermarkFactory {
|
||||
}
|
||||
public static IOperator get(ImageWatermarkOperatorEnum type) {
|
||||
return switch (type) {
|
||||
case WATERMARK -> new DefaultImageWatermarkOperator();
|
||||
case NORMAL -> new NormalWatermarkOperator();
|
||||
case LEICA -> new LeicaWatermarkOperator();
|
||||
case PRINTER_DEFAULT -> new PrinterDefaultWatermarkOperator();
|
||||
default -> throw new ImageWatermarkUnsupportedException("不支持的类型" + type.name());
|
||||
case PUZZLE_PRINT -> throw new ImageWatermarkUnsupportedException(
|
||||
"PUZZLE_PRINT 仅支持边缘端处理,请使用 WatermarkEdgeService");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 水印模板构建器基类
|
||||
* 提供构建元素的工具方法
|
||||
*/
|
||||
public abstract class AbstractWatermarkTemplateBuilder implements IWatermarkTemplateBuilder {
|
||||
|
||||
// 虚拟模板ID(运行时使用,不存储)
|
||||
private static final AtomicLong VIRTUAL_TEMPLATE_ID = new AtomicLong(-1);
|
||||
private static final AtomicLong VIRTUAL_ELEMENT_ID = new AtomicLong(-1);
|
||||
|
||||
/**
|
||||
* 元素类型常量
|
||||
*/
|
||||
protected static final String ELEMENT_TYPE_IMAGE = "IMAGE";
|
||||
protected static final String ELEMENT_TYPE_TEXT = "TEXT";
|
||||
|
||||
/**
|
||||
* 图片适配模式
|
||||
*/
|
||||
protected static final String FIT_MODE_COVER = "COVER";
|
||||
protected static final String FIT_MODE_CONTAIN = "CONTAIN";
|
||||
|
||||
/**
|
||||
* 文本对齐方式
|
||||
*/
|
||||
protected static final String TEXT_ALIGN_LEFT = "LEFT";
|
||||
protected static final String TEXT_ALIGN_RIGHT = "RIGHT";
|
||||
protected static final String TEXT_ALIGN_CENTER = "CENTER";
|
||||
|
||||
/**
|
||||
* 创建虚拟模板
|
||||
*/
|
||||
protected PuzzleTemplateEntity createTemplate(String code, int width, int height, String backgroundImage) {
|
||||
PuzzleTemplateEntity template = new PuzzleTemplateEntity();
|
||||
template.setId(VIRTUAL_TEMPLATE_ID.decrementAndGet());
|
||||
template.setCode(code);
|
||||
template.setName("水印模板-" + getStyle());
|
||||
template.setCanvasWidth(width);
|
||||
template.setCanvasHeight(height);
|
||||
template.setBackgroundType(1); // 图片背景
|
||||
template.setBackgroundImage(backgroundImage);
|
||||
template.setStatus(1);
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建纯色背景模板
|
||||
*/
|
||||
protected PuzzleTemplateEntity createTemplateWithColor(String code, int width, int height, String backgroundColor) {
|
||||
PuzzleTemplateEntity template = new PuzzleTemplateEntity();
|
||||
template.setId(VIRTUAL_TEMPLATE_ID.decrementAndGet());
|
||||
template.setCode(code);
|
||||
template.setName("水印模板-" + getStyle());
|
||||
template.setCanvasWidth(width);
|
||||
template.setCanvasHeight(height);
|
||||
template.setBackgroundType(0); // 纯色背景
|
||||
template.setBackgroundColor(backgroundColor);
|
||||
template.setStatus(1);
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建图片元素
|
||||
*/
|
||||
protected PuzzleElementEntity createImageElement(String key, String name, int x, int y, int width, int height, int zIndex,
|
||||
String fitMode, Integer borderRadius, Integer opacity) {
|
||||
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||
element.setId(VIRTUAL_ELEMENT_ID.decrementAndGet());
|
||||
element.setElementType(ELEMENT_TYPE_IMAGE);
|
||||
element.setElementKey(key);
|
||||
element.setElementName(name);
|
||||
element.setXPosition(x);
|
||||
element.setYPosition(y);
|
||||
element.setWidth(width);
|
||||
element.setHeight(height);
|
||||
element.setZIndex(zIndex);
|
||||
element.setOpacity(opacity != null ? opacity : 100);
|
||||
|
||||
// 构建配置JSON
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("imageFitMode", fitMode != null ? fitMode : FIT_MODE_COVER);
|
||||
if (borderRadius != null && borderRadius > 0) {
|
||||
config.put("borderRadius", borderRadius);
|
||||
}
|
||||
element.setConfig(JacksonUtil.toJson(config));
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建圆形图片元素
|
||||
*/
|
||||
protected PuzzleElementEntity createCircleImageElement(String key, String name, int x, int y, int diameter, int zIndex) {
|
||||
// 圆形 = borderRadius 为直径的一半
|
||||
return createImageElement(key, name, x, y, diameter, diameter, zIndex, FIT_MODE_COVER, diameter / 2, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文字元素
|
||||
*/
|
||||
protected PuzzleElementEntity createTextElement(String key, String name, int x, int y, int width, int height, int zIndex,
|
||||
String fontFamily, int fontSize, String fontColor,
|
||||
String fontWeight, String textAlign) {
|
||||
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||
element.setId(VIRTUAL_ELEMENT_ID.decrementAndGet());
|
||||
element.setElementType(ELEMENT_TYPE_TEXT);
|
||||
element.setElementKey(key);
|
||||
element.setElementName(name);
|
||||
element.setXPosition(x);
|
||||
element.setYPosition(y);
|
||||
element.setWidth(width);
|
||||
element.setHeight(height);
|
||||
element.setZIndex(zIndex);
|
||||
element.setOpacity(100);
|
||||
|
||||
// 构建配置JSON
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("fontFamily", fontFamily != null ? fontFamily : "PingFang SC");
|
||||
config.put("fontSize", fontSize);
|
||||
config.put("fontColor", fontColor != null ? fontColor : "#FFFFFF");
|
||||
config.put("fontWeight", fontWeight != null ? fontWeight : "NORMAL");
|
||||
config.put("textAlign", textAlign != null ? textAlign : TEXT_ALIGN_LEFT);
|
||||
element.setConfig(JacksonUtil.toJson(config));
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建构建结果
|
||||
*/
|
||||
protected WatermarkTemplateResult createResult(PuzzleTemplateEntity template,
|
||||
List<PuzzleElementEntity> elements,
|
||||
Map<String, String> dynamicData) {
|
||||
WatermarkTemplateResult result = new WatermarkTemplateResult();
|
||||
result.setTemplate(template);
|
||||
result.setElements(elements);
|
||||
result.setDynamicData(dynamicData);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空的元素列表和动态数据
|
||||
*/
|
||||
protected List<PuzzleElementEntity> newElementList() {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
protected Map<String, String> newDynamicData() {
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
/**
|
||||
* 水印模板构建器接口
|
||||
* 将水印参数转换为拼图模板+元素的形式,用于发送给边缘渲染任务
|
||||
*/
|
||||
public interface IWatermarkTemplateBuilder {
|
||||
|
||||
/**
|
||||
* 构建水印模板
|
||||
*
|
||||
* @param request 水印请求参数
|
||||
* @return 模板构建结果
|
||||
*/
|
||||
WatermarkTemplateResult build(WatermarkRequest request);
|
||||
|
||||
/**
|
||||
* 获取水印风格标识
|
||||
*/
|
||||
String getStyle();
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 徕卡风格水印模板构建器
|
||||
* 对应 LeicaWatermarkOperator
|
||||
*
|
||||
* 布局说明(百分比基于1920x1080量化,精度0.5%):
|
||||
* - 画布大小 = 原图大小(不扩展)
|
||||
* - 原图收缩放在画布上半部分,底部留出空间
|
||||
* - 底部白色区域左侧:帧途 Logo + "帧途" 文字
|
||||
* - 底部白色区域右侧:二维码(含头像)+ 景区名 + 日期时间
|
||||
*/
|
||||
@Component
|
||||
public class LeicaWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
|
||||
|
||||
public static final String STYLE = "leica";
|
||||
|
||||
// 百分比常量配置(基于1920x1080量化,精度0.5%)
|
||||
/** 底部额外区域占高度百分比 */
|
||||
private static final double EXTRA_BOTTOM_PERCENT = 0.13; // 13%
|
||||
/** Logo大小占高度百分比 */
|
||||
private static final double LOGO_SIZE_PERCENT = 0.045; // 4.5%
|
||||
/** Logo额外边距占高度百分比 */
|
||||
private static final double LOGO_EXTRA_BORDER_PERCENT = 0.02; // 2%
|
||||
/** Logo字体大小占高度百分比 */
|
||||
private static final double LOGO_FONT_SIZE_PERCENT = 0.035; // 3.5%
|
||||
/** 二维码大小占高度百分比 */
|
||||
private static final double QRCODE_SIZE_PERCENT = 0.11; // 11%
|
||||
/** 二维码X偏移占宽度百分比 */
|
||||
private static final double QRCODE_OFFSET_X_PERCENT = 0.005; // 0.5%
|
||||
/** 二维码Y偏移占高度百分比 */
|
||||
private static final double QRCODE_OFFSET_Y_PERCENT = 0.02; // 2%
|
||||
/** 左右边距占宽度百分比 */
|
||||
private static final double OFFSET_X_PERCENT = 0.04; // 4%
|
||||
/** 上下边距占高度百分比 */
|
||||
private static final double OFFSET_Y_PERCENT = 0.03; // 3%
|
||||
/** 景区名字体大小占高度百分比 */
|
||||
private static final double SCENIC_FONT_SIZE_PERCENT = 0.03; // 3%
|
||||
/** 日期时间字体大小占高度百分比 */
|
||||
private static final double DATETIME_FONT_SIZE_PERCENT = 0.025; // 2.5%
|
||||
|
||||
private static final String LOGO_TEXT_COLOR = "#333333";
|
||||
private static final String SCENIC_COLOR = "#333333";
|
||||
private static final String DATETIME_COLOR = "#999999";
|
||||
|
||||
/**
|
||||
* Logo 图片 URL(需要预先上传到 OSS)
|
||||
*/
|
||||
private static final String LOGO_URL = "https://oss.zhentuai.com/zt/zt-logo.png";
|
||||
|
||||
@Override
|
||||
public String getStyle() {
|
||||
return STYLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WatermarkTemplateResult build(WatermarkRequest request) {
|
||||
int imageWidth = request.getImageWidth();
|
||||
int imageHeight = request.getImageHeight();
|
||||
|
||||
// 根据百分比计算实际像素值
|
||||
int extraBottom = (int) (imageHeight * EXTRA_BOTTOM_PERCENT);
|
||||
int logoSize = (int) (imageHeight * LOGO_SIZE_PERCENT);
|
||||
int logoExtraBorder = (int) (imageHeight * LOGO_EXTRA_BORDER_PERCENT);
|
||||
int logoFontSize = (int) (imageHeight * LOGO_FONT_SIZE_PERCENT);
|
||||
int qrcodeSize = (int) (imageHeight * QRCODE_SIZE_PERCENT);
|
||||
int qrcodeOffsetX = (int) (imageWidth * QRCODE_OFFSET_X_PERCENT);
|
||||
int qrcodeOffsetY = (int) (imageHeight * QRCODE_OFFSET_Y_PERCENT);
|
||||
int offsetX = (int) (imageWidth * OFFSET_X_PERCENT);
|
||||
int offsetY = (int) (imageHeight * OFFSET_Y_PERCENT);
|
||||
int scenicFontSize = (int) (imageHeight * SCENIC_FONT_SIZE_PERCENT);
|
||||
int datetimeFontSize = (int) (imageHeight * DATETIME_FONT_SIZE_PERCENT);
|
||||
|
||||
// 画布大小 = 原图大小(不扩展)
|
||||
int canvasWidth = imageWidth;
|
||||
int canvasHeight = imageHeight;
|
||||
|
||||
// 原图收缩后的区域高度
|
||||
int shrunkImageHeight = imageHeight - extraBottom;
|
||||
// 底部区域起始 Y 坐标
|
||||
int bottomAreaY = shrunkImageHeight;
|
||||
|
||||
// 创建模板(白色背景)
|
||||
PuzzleTemplateEntity template = createTemplateWithColor(
|
||||
"watermark_leica_" + System.currentTimeMillis(),
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
"#FFFFFF"
|
||||
);
|
||||
|
||||
List<PuzzleElementEntity> elements = newElementList();
|
||||
Map<String, String> dynamicData = newDynamicData();
|
||||
|
||||
// 1. 原图元素(收缩放在画布上半部分)
|
||||
PuzzleElementEntity originalImageElement = createImageElement(
|
||||
"originalImage", "原图",
|
||||
0, 0,
|
||||
imageWidth, shrunkImageHeight, 1,
|
||||
FIT_MODE_COVER, null, null
|
||||
);
|
||||
elements.add(originalImageElement);
|
||||
dynamicData.put("originalImage", request.getOriginalImageUrl());
|
||||
|
||||
// 2. Logo 元素(底部左侧)
|
||||
int logoY = bottomAreaY + offsetY + logoExtraBorder;
|
||||
PuzzleElementEntity logoElement = createImageElement(
|
||||
"logo", "Logo",
|
||||
offsetX, logoY - (int)(logoSize * 0.24),
|
||||
logoSize, logoSize, 10,
|
||||
FIT_MODE_CONTAIN, null, null
|
||||
);
|
||||
elements.add(logoElement);
|
||||
dynamicData.put("logo", LOGO_URL);
|
||||
|
||||
// 3. "帧途" 文字(Logo 右边)
|
||||
int logoTextX = offsetX + logoSize + (int)(imageWidth * 0.005);
|
||||
int logoTextY = bottomAreaY + offsetY + logoExtraBorder;
|
||||
PuzzleElementEntity logoTextElement = createTextElement(
|
||||
"logoText", "帧途文字",
|
||||
logoTextX, logoTextY,
|
||||
(int)(imageWidth * 0.05), logoSize, 10,
|
||||
"PingFang SC", logoFontSize, LOGO_TEXT_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_LEFT
|
||||
);
|
||||
elements.add(logoTextElement);
|
||||
dynamicData.put("logoText", "帧途");
|
||||
|
||||
// 4. 计算右侧区域位置
|
||||
// 估算文字宽度(使用景区名和日期的较大者)
|
||||
int estimatedTextWidth = Math.max(
|
||||
(request.getScenicLine() != null ? request.getScenicLine().length() : 0) * scenicFontSize / 2,
|
||||
(request.getDatetimeLine() != null ? request.getDatetimeLine().length() : 0) * datetimeFontSize / 2
|
||||
);
|
||||
|
||||
int qrcodeX = canvasWidth - offsetX - qrcodeSize - qrcodeOffsetX - estimatedTextWidth;
|
||||
int qrcodeY = bottomAreaY + offsetY - qrcodeOffsetY;
|
||||
|
||||
// 5. 二维码元素
|
||||
PuzzleElementEntity qrcodeElement = createImageElement(
|
||||
"qrcode", "二维码",
|
||||
qrcodeX, qrcodeY,
|
||||
qrcodeSize, qrcodeSize, 10,
|
||||
FIT_MODE_CONTAIN, null, null
|
||||
);
|
||||
elements.add(qrcodeElement);
|
||||
dynamicData.put("qrcode", request.getQrcodeUrl());
|
||||
|
||||
// 6. 头像元素(二维码中央,可选)
|
||||
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
|
||||
int avatarDiameter = (int) (qrcodeSize * 0.45);
|
||||
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
|
||||
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
|
||||
|
||||
PuzzleElementEntity faceElement = createCircleImageElement(
|
||||
"face", "头像",
|
||||
avatarX, avatarY,
|
||||
avatarDiameter, 20
|
||||
);
|
||||
elements.add(faceElement);
|
||||
dynamicData.put("face", request.getFaceUrl());
|
||||
}
|
||||
|
||||
// 7. 计算文字位置(与二维码垂直居中)
|
||||
int qrcodeCenter = qrcodeY + qrcodeSize / 2;
|
||||
int totalTextHeight = scenicFontSize + datetimeFontSize + (int)(imageHeight * 0.01);
|
||||
int textY = qrcodeCenter - totalTextHeight / 2;
|
||||
int textX = canvasWidth - offsetX - estimatedTextWidth;
|
||||
|
||||
// 8. 景区名文字
|
||||
PuzzleElementEntity scenicTextElement = createTextElement(
|
||||
"scenicLine", "景区名",
|
||||
textX, textY,
|
||||
estimatedTextWidth, scenicFontSize + (int)(imageHeight * 0.01), 30,
|
||||
"PingFang SC", scenicFontSize, SCENIC_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_LEFT
|
||||
);
|
||||
elements.add(scenicTextElement);
|
||||
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
|
||||
|
||||
// 9. 日期时间文字
|
||||
int datetimeY = textY + scenicFontSize + (int)(imageHeight * 0.005);
|
||||
PuzzleElementEntity datetimeTextElement = createTextElement(
|
||||
"datetimeLine", "日期时间",
|
||||
textX, datetimeY,
|
||||
estimatedTextWidth, datetimeFontSize + (int)(imageHeight * 0.01), 30,
|
||||
"PingFang SC", datetimeFontSize, DATETIME_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_LEFT
|
||||
);
|
||||
elements.add(datetimeTextElement);
|
||||
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
|
||||
|
||||
return createResult(template, elements, dynamicData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Normal 风格水印模板构建器
|
||||
* 对应 NormalWatermarkOperator
|
||||
*
|
||||
* 布局说明(百分比基于1920x1080量化,精度0.5%):
|
||||
* - 白色背景 + 原图元素(COVER模式)
|
||||
* - 左下角:圆形二维码(右边界在宽度45%位置)
|
||||
* - 二维码中央:圆形头像(可选)
|
||||
* - 二维码右侧:景区名 + 日期时间 两行文字(白色,左对齐)
|
||||
*/
|
||||
@Component
|
||||
public class NormalWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
|
||||
|
||||
public static final String STYLE = "normal";
|
||||
|
||||
// 百分比常量配置(基于1920x1080量化,精度0.5%)
|
||||
/** 底部距离占高度百分比 */
|
||||
private static final double BOTTOM_OFFSET_PERCENT = 0.085; // 8.5%
|
||||
/** 二维码大小占宽度百分比 */
|
||||
private static final double QRCODE_SIZE_PERCENT = 0.08; // 8%
|
||||
/** 二维码右边界占宽度百分比 */
|
||||
private static final double QRCODE_RIGHT_PERCENT = 0.45; // 45%
|
||||
/** 二维码Y方向偏移(向上)占高度百分比 */
|
||||
private static final double QRCODE_OFFSET_Y_PERCENT = 0.02; // 2%
|
||||
/** 文字区域起始X位置占宽度百分比 */
|
||||
private static final double TEXT_START_X_PERCENT = 0.455; // 45.5%
|
||||
/** 字体大小占高度百分比 */
|
||||
private static final double FONT_SIZE_PERCENT = 0.04; // 4%
|
||||
/** 文字行间距占高度百分比 */
|
||||
private static final double LINE_SPACING_PERCENT = 0.005; // 0.5%
|
||||
/** 文字区域右边距占宽度百分比 */
|
||||
private static final double TEXT_RIGHT_MARGIN_PERCENT = 0.01; // 1%
|
||||
|
||||
private static final String FONT_COLOR = "#FFFFFF";
|
||||
|
||||
@Override
|
||||
public String getStyle() {
|
||||
return STYLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WatermarkTemplateResult build(WatermarkRequest request) {
|
||||
int imageWidth = request.getImageWidth();
|
||||
int imageHeight = request.getImageHeight();
|
||||
|
||||
// 根据百分比计算实际像素值
|
||||
int bottomOffset = (int) (imageHeight * BOTTOM_OFFSET_PERCENT);
|
||||
int qrcodeSize = (int) (imageWidth * QRCODE_SIZE_PERCENT);
|
||||
int qrcodeRightX = (int) (imageWidth * QRCODE_RIGHT_PERCENT);
|
||||
int qrcodeOffsetY = (int) (imageHeight * QRCODE_OFFSET_Y_PERCENT);
|
||||
int textStartX = (int) (imageWidth * TEXT_START_X_PERCENT);
|
||||
int fontSize = (int) (imageHeight * FONT_SIZE_PERCENT);
|
||||
int lineSpacing = (int) (imageHeight * LINE_SPACING_PERCENT);
|
||||
int textRightMargin = (int) (imageWidth * TEXT_RIGHT_MARGIN_PERCENT);
|
||||
|
||||
// 创建模板(白色背景,原图作为元素实现 COVER 模式)
|
||||
PuzzleTemplateEntity template = createTemplateWithColor(
|
||||
"watermark_normal_" + System.currentTimeMillis(),
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
"#FFFFFF"
|
||||
);
|
||||
|
||||
List<PuzzleElementEntity> elements = newElementList();
|
||||
Map<String, String> dynamicData = newDynamicData();
|
||||
|
||||
// 0. 原图元素(z-index=1,最底层,COVER模式)
|
||||
PuzzleElementEntity originalImageElement = createImageElement(
|
||||
"originalImage", "原图",
|
||||
0, 0,
|
||||
imageWidth, imageHeight, 1,
|
||||
FIT_MODE_COVER, null, null
|
||||
);
|
||||
elements.add(originalImageElement);
|
||||
dynamicData.put("originalImage", request.getOriginalImageUrl());
|
||||
|
||||
// 计算二维码位置(右边界在45%位置,向左推算左边界)
|
||||
int qrcodeX = qrcodeRightX - qrcodeSize;
|
||||
int qrcodeY = imageHeight - bottomOffset - qrcodeSize - qrcodeOffsetY;
|
||||
|
||||
// 1. 二维码元素(圆形裁切)
|
||||
PuzzleElementEntity qrcodeElement = createCircleImageElement(
|
||||
"qrcode", "二维码",
|
||||
qrcodeX, qrcodeY,
|
||||
qrcodeSize, 10
|
||||
);
|
||||
elements.add(qrcodeElement);
|
||||
dynamicData.put("qrcode", request.getQrcodeUrl());
|
||||
|
||||
// 2. 头像元素(圆形,二维码中央,可选)
|
||||
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
|
||||
int avatarDiameter = (int) (qrcodeSize * 0.45);
|
||||
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
|
||||
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
|
||||
|
||||
PuzzleElementEntity faceElement = createCircleImageElement(
|
||||
"face", "头像",
|
||||
avatarX, avatarY,
|
||||
avatarDiameter, 20
|
||||
);
|
||||
elements.add(faceElement);
|
||||
dynamicData.put("face", request.getFaceUrl());
|
||||
}
|
||||
|
||||
// 3. 景区名文字(在二维码右侧,从45.5%位置开始,左对齐)
|
||||
// 文字垂直居中于二维码区域
|
||||
int textAreaHeight = fontSize * 2 + lineSpacing;
|
||||
int textY = qrcodeY + (qrcodeSize - textAreaHeight) / 2;
|
||||
|
||||
PuzzleElementEntity scenicTextElement = createTextElement(
|
||||
"scenicLine", "景区名",
|
||||
textStartX, textY,
|
||||
imageWidth - textStartX - textRightMargin, fontSize + lineSpacing, 30,
|
||||
"PingFang SC", fontSize, FONT_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_LEFT
|
||||
);
|
||||
elements.add(scenicTextElement);
|
||||
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
|
||||
|
||||
// 4. 日期时间文字(在景区名下方,左对齐)
|
||||
int datetimeY = textY + fontSize + lineSpacing;
|
||||
|
||||
PuzzleElementEntity datetimeTextElement = createTextElement(
|
||||
"datetimeLine", "日期时间",
|
||||
textStartX, datetimeY,
|
||||
imageWidth - textStartX - textRightMargin, fontSize + lineSpacing, 30,
|
||||
"PingFang SC", fontSize, FONT_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_LEFT
|
||||
);
|
||||
elements.add(datetimeTextElement);
|
||||
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
|
||||
|
||||
return createResult(template, elements, dynamicData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 打印专用水印模板构建器
|
||||
* 对应 PrinterDefaultWatermarkOperator
|
||||
*
|
||||
* 布局说明:
|
||||
* - 白色背景 + 原图元素(COVER模式)
|
||||
* - 左下角:圆形二维码(带白色圆形背景)
|
||||
* - 二维码中央:圆形头像(可选)
|
||||
* - 右下角:景区名 + 日期时间 两行文字(白色,右对齐)
|
||||
* - 支持缩放和四边偏移
|
||||
*/
|
||||
@Component
|
||||
public class PrinterDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
|
||||
|
||||
public static final String STYLE = "pDefault";
|
||||
|
||||
// 常量配置(与 PrinterDefaultWatermarkOperator 保持一致)
|
||||
private static final int OFFSET_Y = 15;
|
||||
private static final int QRCODE_SIZE = 150;
|
||||
private static final double QRCODE_LEFT_MARGIN_RATIO = 0.05;
|
||||
private static final int QRCODE_OFFSET_Y = -35;
|
||||
private static final int SCENIC_FONT_SIZE = 42;
|
||||
private static final int DATETIME_FONT_SIZE = 42;
|
||||
private static final String FONT_COLOR = "#FFFFFF";
|
||||
private static final double TEXT_RIGHT_MARGIN_RATIO = 0.05;
|
||||
|
||||
@Override
|
||||
public String getStyle() {
|
||||
return STYLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WatermarkTemplateResult build(WatermarkRequest request) {
|
||||
int imageWidth = request.getImageWidth();
|
||||
int imageHeight = request.getImageHeight();
|
||||
double scale = request.getScaleValue();
|
||||
|
||||
// 应用缩放
|
||||
int scaledOffsetY = (int) (OFFSET_Y * scale);
|
||||
int scaledQrcodeSize = (int) (QRCODE_SIZE * scale);
|
||||
int scaledQrcodeOffsetY = (int) (QRCODE_OFFSET_Y * scale);
|
||||
int scaledScenicFontSize = (int) (SCENIC_FONT_SIZE * scale);
|
||||
int scaledDatetimeFontSize = (int) (DATETIME_FONT_SIZE * scale);
|
||||
|
||||
// 获取偏移值
|
||||
int offsetLeft = (int) (request.getOffsetLeftValue() * scale);
|
||||
int offsetRight = (int) (request.getOffsetRightValue() * scale);
|
||||
int offsetBottom = (int) (request.getOffsetBottomValue() * scale);
|
||||
|
||||
// 创建模板(白色背景,原图作为元素实现 COVER 模式)
|
||||
PuzzleTemplateEntity template = createTemplateWithColor(
|
||||
"watermark_printer_" + System.currentTimeMillis(),
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
"#FFFFFF"
|
||||
);
|
||||
|
||||
List<PuzzleElementEntity> elements = newElementList();
|
||||
Map<String, String> dynamicData = newDynamicData();
|
||||
|
||||
// 0. 原图元素(z-index=1,最底层,COVER模式)
|
||||
PuzzleElementEntity originalImageElement = createImageElement(
|
||||
"originalImage", "原图",
|
||||
0, 0,
|
||||
imageWidth, imageHeight, 1,
|
||||
FIT_MODE_COVER, null, null
|
||||
);
|
||||
elements.add(originalImageElement);
|
||||
dynamicData.put("originalImage", request.getOriginalImageUrl());
|
||||
|
||||
// 计算二维码位置
|
||||
int qrcodeWidth = scaledQrcodeSize;
|
||||
int qrcodeHeight = scaledQrcodeSize;
|
||||
int qrcodeX = (int) (imageWidth * QRCODE_LEFT_MARGIN_RATIO) + offsetLeft;
|
||||
int qrcodeY = imageHeight - scaledOffsetY - qrcodeHeight - offsetBottom;
|
||||
|
||||
// 1. 二维码元素(圆形裁切)
|
||||
PuzzleElementEntity qrcodeElement = createCircleImageElement(
|
||||
"qrcode", "二维码",
|
||||
qrcodeX, qrcodeY + scaledQrcodeOffsetY,
|
||||
qrcodeHeight, 10
|
||||
);
|
||||
elements.add(qrcodeElement);
|
||||
dynamicData.put("qrcode", request.getQrcodeUrl());
|
||||
|
||||
// 2. 头像元素(圆形,二维码中央,可选)
|
||||
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
|
||||
int avatarDiameter = (int) (qrcodeHeight * 0.45);
|
||||
int avatarX = qrcodeX + (qrcodeWidth - avatarDiameter) / 2;
|
||||
int avatarY = qrcodeY + scaledQrcodeOffsetY + (qrcodeHeight - avatarDiameter) / 2;
|
||||
|
||||
PuzzleElementEntity faceElement = createCircleImageElement(
|
||||
"face", "头像",
|
||||
avatarX, avatarY,
|
||||
avatarDiameter, 20
|
||||
);
|
||||
elements.add(faceElement);
|
||||
dynamicData.put("face", request.getFaceUrl());
|
||||
}
|
||||
|
||||
// 3. 计算文字位置(右对齐)
|
||||
int textRightX = imageWidth - (int) (imageWidth * TEXT_RIGHT_MARGIN_RATIO) - offsetRight;
|
||||
int textWidth = textRightX - qrcodeX - qrcodeWidth - 20;
|
||||
|
||||
// 计算垂直居中
|
||||
int qrcodeTop = qrcodeY + scaledQrcodeOffsetY;
|
||||
int qrcodeBottom = qrcodeTop + qrcodeHeight;
|
||||
int qrcodeCenter = (qrcodeTop + qrcodeBottom) / 2;
|
||||
int totalTextHeight = scaledScenicFontSize + scaledDatetimeFontSize + 10;
|
||||
int textY = qrcodeCenter - totalTextHeight / 2;
|
||||
|
||||
// 4. 景区名文字(右对齐)
|
||||
PuzzleElementEntity scenicTextElement = createTextElement(
|
||||
"scenicLine", "景区名",
|
||||
textRightX - textWidth, textY,
|
||||
textWidth, scaledScenicFontSize + 10, 30,
|
||||
"PingFang SC", scaledScenicFontSize, FONT_COLOR,
|
||||
"BOLD", TEXT_ALIGN_RIGHT
|
||||
);
|
||||
elements.add(scenicTextElement);
|
||||
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
|
||||
|
||||
// 5. 日期时间文字(右对齐)
|
||||
int datetimeY = textY + scaledScenicFontSize + 5;
|
||||
|
||||
PuzzleElementEntity datetimeTextElement = createTextElement(
|
||||
"datetimeLine", "日期时间",
|
||||
textRightX - textWidth, datetimeY,
|
||||
textWidth, scaledDatetimeFontSize + 10, 30,
|
||||
"PingFang SC", scaledDatetimeFontSize, FONT_COLOR,
|
||||
"BOLD", TEXT_ALIGN_RIGHT
|
||||
);
|
||||
elements.add(datetimeTextElement);
|
||||
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
|
||||
|
||||
return createResult(template, elements, dynamicData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 拼图默认水印模板构建器
|
||||
*
|
||||
* 布局说明:
|
||||
* - 白色背景
|
||||
* - 顶部90%为原图区域(COVER模式)
|
||||
* - 底部10%为信息区域:
|
||||
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
|
||||
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
|
||||
*/
|
||||
@Component
|
||||
public class PuzzleDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
|
||||
|
||||
public static final String STYLE = "puzzle_default";
|
||||
|
||||
// 布局比例配置
|
||||
private static final double IMAGE_HEIGHT_RATIO = 0.90; // 原图占90%高度
|
||||
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
|
||||
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
|
||||
|
||||
// 文字配置
|
||||
private static final int SCENIC_FONT_SIZE = 52;
|
||||
private static final int DATETIME_FONT_SIZE = 42;
|
||||
private static final String SCENIC_COLOR = "#333333";
|
||||
private static final String DATETIME_COLOR = "#999999";
|
||||
|
||||
@Override
|
||||
public String getStyle() {
|
||||
return STYLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WatermarkTemplateResult build(WatermarkRequest request) {
|
||||
int imageWidth = request.getImageWidth();
|
||||
int imageHeight = request.getImageHeight();
|
||||
|
||||
// 画布尺寸 = 原图尺寸
|
||||
int canvasWidth = imageWidth;
|
||||
int canvasHeight = imageHeight;
|
||||
|
||||
// 原图区域占90%高度,底部信息区占10%高度
|
||||
int originalImageHeight = (int) (imageHeight * IMAGE_HEIGHT_RATIO);
|
||||
int bottomAreaHeight = imageHeight - originalImageHeight;
|
||||
|
||||
// 创建模板(白色背景)
|
||||
PuzzleTemplateEntity template = createTemplateWithColor(
|
||||
"watermark_puzzle_default_" + System.currentTimeMillis(),
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
"#FFFFFF"
|
||||
);
|
||||
|
||||
List<PuzzleElementEntity> elements = newElementList();
|
||||
Map<String, String> dynamicData = newDynamicData();
|
||||
|
||||
// 1. 原图元素(顶部90%区域,COVER模式)
|
||||
PuzzleElementEntity originalImageElement = createImageElement(
|
||||
"originalImage", "原图",
|
||||
0, 0,
|
||||
canvasWidth, originalImageHeight, 1,
|
||||
FIT_MODE_COVER, null, null
|
||||
);
|
||||
elements.add(originalImageElement);
|
||||
dynamicData.put("originalImage", request.getOriginalImageUrl());
|
||||
|
||||
// 2. 计算底部区域元素位置
|
||||
int marginX = (int) (canvasWidth * MARGIN_X_RATIO);
|
||||
int qrcodeSize = (int) (canvasHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
|
||||
|
||||
// 二维码垂直居中于底部区域
|
||||
int qrcodeX = marginX;
|
||||
int qrcodeY = originalImageHeight + (bottomAreaHeight - qrcodeSize) / 2;
|
||||
|
||||
// 3. 二维码元素
|
||||
PuzzleElementEntity qrcodeElement = createImageElement(
|
||||
"qrcode", "二维码",
|
||||
qrcodeX, qrcodeY,
|
||||
qrcodeSize, qrcodeSize, 10,
|
||||
FIT_MODE_CONTAIN, null, null
|
||||
);
|
||||
elements.add(qrcodeElement);
|
||||
dynamicData.put("qrcode", request.getQrcodeUrl());
|
||||
|
||||
// 4. 头像元素(二维码中央,可选)
|
||||
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
|
||||
int avatarDiameter = (int) (qrcodeSize * 0.45);
|
||||
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
|
||||
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
|
||||
|
||||
PuzzleElementEntity faceElement = createCircleImageElement(
|
||||
"face", "头像",
|
||||
avatarX, avatarY,
|
||||
avatarDiameter, 20
|
||||
);
|
||||
elements.add(faceElement);
|
||||
dynamicData.put("face", request.getFaceUrl());
|
||||
}
|
||||
|
||||
// 5. 计算右侧文字区域
|
||||
int textRightX = canvasWidth - marginX;
|
||||
int textWidth = textRightX - qrcodeX - qrcodeSize - marginX;
|
||||
|
||||
// 文字与二维码垂直居中
|
||||
int totalTextHeight = SCENIC_FONT_SIZE + DATETIME_FONT_SIZE + 5;
|
||||
int textY = originalImageHeight + (bottomAreaHeight - totalTextHeight) / 2;
|
||||
|
||||
// 6. 景区名文字(右对齐)
|
||||
PuzzleElementEntity scenicTextElement = createTextElement(
|
||||
"scenicLine", "景区名",
|
||||
qrcodeX + qrcodeSize + marginX, textY,
|
||||
textWidth, SCENIC_FONT_SIZE + 10, 30,
|
||||
"PingFang SC", SCENIC_FONT_SIZE, SCENIC_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_RIGHT
|
||||
);
|
||||
elements.add(scenicTextElement);
|
||||
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
|
||||
|
||||
// 7. 日期时间文字(右对齐)
|
||||
int datetimeY = textY + SCENIC_FONT_SIZE + 5;
|
||||
PuzzleElementEntity datetimeTextElement = createTextElement(
|
||||
"datetimeLine", "日期时间",
|
||||
qrcodeX + qrcodeSize + marginX, datetimeY,
|
||||
textWidth, DATETIME_FONT_SIZE + 10, 30,
|
||||
"PingFang SC", DATETIME_FONT_SIZE, DATETIME_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_RIGHT
|
||||
);
|
||||
elements.add(datetimeTextElement);
|
||||
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
|
||||
|
||||
return createResult(template, elements, dynamicData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 拼图打印水印模板构建器
|
||||
*
|
||||
* 布局说明:
|
||||
* - 白色背景
|
||||
* - 四周留1%白边
|
||||
* - 内部区域:顶部90%为原图区域(COVER模式)
|
||||
* - 底部10%为信息区域:
|
||||
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
|
||||
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
|
||||
*/
|
||||
@Component
|
||||
public class PuzzlePrintWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
|
||||
|
||||
public static final String STYLE = "puzzle_print";
|
||||
|
||||
// 布局比例配置
|
||||
private static final double BORDER_RATIO = 0.01; // 四周白边为1%
|
||||
private static final double IMAGE_HEIGHT_RATIO = 0.90; // 原图占内容区90%高度
|
||||
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
|
||||
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
|
||||
|
||||
// 文字配置
|
||||
private static final int SCENIC_FONT_SIZE = 52;
|
||||
private static final int DATETIME_FONT_SIZE = 42;
|
||||
private static final String SCENIC_COLOR = "#333333";
|
||||
private static final String DATETIME_COLOR = "#999999";
|
||||
|
||||
@Override
|
||||
public String getStyle() {
|
||||
return STYLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WatermarkTemplateResult build(WatermarkRequest request) {
|
||||
int imageWidth = request.getImageWidth();
|
||||
int imageHeight = request.getImageHeight();
|
||||
|
||||
// 计算白边尺寸(基于原图尺寸的1%)
|
||||
int borderX = (int) (imageWidth * BORDER_RATIO);
|
||||
int borderY = (int) (imageHeight * BORDER_RATIO);
|
||||
|
||||
// 画布尺寸 = 原图尺寸 + 四周白边
|
||||
int canvasWidth = imageWidth + borderX * 2;
|
||||
int canvasHeight = imageHeight + borderY * 2;
|
||||
|
||||
// 内容区起始位置(白边内)
|
||||
int contentStartX = borderX;
|
||||
int contentStartY = borderY;
|
||||
|
||||
// 内容区尺寸 = 原图尺寸
|
||||
int contentWidth = imageWidth;
|
||||
int contentHeight = imageHeight;
|
||||
|
||||
// 原图区域占90%高度,底部信息区占10%高度
|
||||
int originalImageHeight = (int) (contentHeight * IMAGE_HEIGHT_RATIO);
|
||||
int bottomAreaHeight = contentHeight - originalImageHeight;
|
||||
|
||||
// 创建模板(白色背景)
|
||||
PuzzleTemplateEntity template = createTemplateWithColor(
|
||||
"watermark_puzzle_print_" + System.currentTimeMillis(),
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
"#FFFFFF"
|
||||
);
|
||||
|
||||
List<PuzzleElementEntity> elements = newElementList();
|
||||
Map<String, String> dynamicData = newDynamicData();
|
||||
|
||||
// 1. 原图元素(内容区顶部90%,COVER模式)
|
||||
PuzzleElementEntity originalImageElement = createImageElement(
|
||||
"originalImage", "原图",
|
||||
contentStartX, contentStartY,
|
||||
contentWidth, originalImageHeight, 1,
|
||||
FIT_MODE_COVER, null, null
|
||||
);
|
||||
elements.add(originalImageElement);
|
||||
dynamicData.put("originalImage", request.getOriginalImageUrl());
|
||||
|
||||
// 2. 计算底部区域元素位置(相对于内容区)
|
||||
int marginX = (int) (contentWidth * MARGIN_X_RATIO);
|
||||
int qrcodeSize = (int) (contentHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
|
||||
|
||||
// 二维码垂直居中于底部区域
|
||||
int qrcodeX = contentStartX + marginX;
|
||||
int qrcodeY = contentStartY + originalImageHeight + (bottomAreaHeight - qrcodeSize) / 2;
|
||||
|
||||
// 3. 二维码元素
|
||||
PuzzleElementEntity qrcodeElement = createImageElement(
|
||||
"qrcode", "二维码",
|
||||
qrcodeX, qrcodeY,
|
||||
qrcodeSize, qrcodeSize, 10,
|
||||
FIT_MODE_CONTAIN, null, null
|
||||
);
|
||||
elements.add(qrcodeElement);
|
||||
dynamicData.put("qrcode", request.getQrcodeUrl());
|
||||
|
||||
// 4. 头像元素(二维码中央,可选)
|
||||
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
|
||||
int avatarDiameter = (int) (qrcodeSize * 0.45);
|
||||
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
|
||||
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
|
||||
|
||||
PuzzleElementEntity faceElement = createCircleImageElement(
|
||||
"face", "头像",
|
||||
avatarX, avatarY,
|
||||
avatarDiameter, 20
|
||||
);
|
||||
elements.add(faceElement);
|
||||
dynamicData.put("face", request.getFaceUrl());
|
||||
}
|
||||
|
||||
// 5. 计算右侧文字区域
|
||||
int textRightX = contentStartX + contentWidth - marginX;
|
||||
int textWidth = textRightX - qrcodeX - qrcodeSize - marginX;
|
||||
|
||||
// 文字与二维码垂直居中
|
||||
int totalTextHeight = SCENIC_FONT_SIZE + DATETIME_FONT_SIZE + 5;
|
||||
int textY = contentStartY + originalImageHeight + (bottomAreaHeight - totalTextHeight) / 2;
|
||||
|
||||
// 6. 景区名文字(右对齐)
|
||||
PuzzleElementEntity scenicTextElement = createTextElement(
|
||||
"scenicLine", "景区名",
|
||||
qrcodeX + qrcodeSize + marginX, textY,
|
||||
textWidth, SCENIC_FONT_SIZE + 10, 30,
|
||||
"PingFang SC", SCENIC_FONT_SIZE, SCENIC_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_RIGHT
|
||||
);
|
||||
elements.add(scenicTextElement);
|
||||
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
|
||||
|
||||
// 7. 日期时间文字(右对齐)
|
||||
int datetimeY = textY + SCENIC_FONT_SIZE + 5;
|
||||
PuzzleElementEntity datetimeTextElement = createTextElement(
|
||||
"datetimeLine", "日期时间",
|
||||
qrcodeX + qrcodeSize + marginX, datetimeY,
|
||||
textWidth, DATETIME_FONT_SIZE + 10, 30,
|
||||
"PingFang SC", DATETIME_FONT_SIZE, DATETIME_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_RIGHT
|
||||
);
|
||||
elements.add(datetimeTextElement);
|
||||
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
|
||||
|
||||
return createResult(template, elements, dynamicData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import com.ycwl.basic.constant.StorageConstant;
|
||||
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
|
||||
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||
import com.ycwl.basic.storage.enums.StorageAcl;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 水印边缘端处理服务
|
||||
* 将原有的 IOperator 本地处理迁移到边缘端渲染
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WatermarkEdgeService {
|
||||
|
||||
private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator;
|
||||
|
||||
/**
|
||||
* 默认等待超时时间(毫秒)
|
||||
*/
|
||||
private static final long DEFAULT_TIMEOUT_MS = 30_000L;
|
||||
|
||||
/**
|
||||
* 使用边缘端处理水印(适用于 GoodsServiceImpl 场景)
|
||||
* 直接传入 URL,不需要本地文件
|
||||
*
|
||||
* @param type 水印类型
|
||||
* @param originalUrl 原图URL
|
||||
* @param qrcodeUrl 二维码URL
|
||||
* @param faceUrl 头像URL(可选)
|
||||
* @param scenicLine 景区名称
|
||||
* @param datetime 日期时间
|
||||
* @param dtFormat 日期格式
|
||||
* @param sourceId 关联的sourceId(用于记录追踪)
|
||||
* @param faceId 人脸ID(可选)
|
||||
* @return 带水印的图片URL,处理失败返回null
|
||||
*/
|
||||
public String processWatermark(ImageWatermarkOperatorEnum type,
|
||||
String originalUrl,
|
||||
String qrcodeUrl,
|
||||
String faceUrl,
|
||||
String scenicLine,
|
||||
Date datetime,
|
||||
String dtFormat,
|
||||
Long sourceId,
|
||||
Long faceId) {
|
||||
return processWatermark(type, originalUrl, qrcodeUrl, faceUrl, scenicLine, datetime, dtFormat,
|
||||
sourceId, faceId, null, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用边缘端处理水印(完整参数版本)
|
||||
*
|
||||
* @param type 水印类型
|
||||
* @param originalUrl 原图URL
|
||||
* @param qrcodeUrl 二维码URL
|
||||
* @param faceUrl 头像URL(可选)
|
||||
* @param scenicLine 景区名称
|
||||
* @param datetime 日期时间
|
||||
* @param dtFormat 日期格式
|
||||
* @param sourceId 关联的sourceId(用于记录追踪)
|
||||
* @param faceId 人脸ID(可选)
|
||||
* @param scale 缩放倍数(可选)
|
||||
* @param offsetLeft 左偏移(可选)
|
||||
* @param offsetRight 右偏移(可选)
|
||||
* @param offsetTop 上偏移(可选)
|
||||
* @param offsetBottom 下偏移(可选)
|
||||
* @return 带水印的图片URL,处理失败返回null
|
||||
*/
|
||||
public String processWatermark(ImageWatermarkOperatorEnum type,
|
||||
String originalUrl,
|
||||
String qrcodeUrl,
|
||||
String faceUrl,
|
||||
String scenicLine,
|
||||
Date datetime,
|
||||
String dtFormat,
|
||||
Long sourceId,
|
||||
Long faceId,
|
||||
Double scale,
|
||||
Integer offsetLeft,
|
||||
Integer offsetRight,
|
||||
Integer offsetTop,
|
||||
Integer offsetBottom) {
|
||||
// 将 ImageWatermarkOperatorEnum 映射到边缘端风格
|
||||
String style = mapTypeToStyle(type);
|
||||
|
||||
// 检查边缘端是否支持该风格
|
||||
if (!watermarkEdgeTaskCreator.isStyleSupported(style)) {
|
||||
log.warn("边缘端不支持水印风格: {}", style);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取图片尺寸
|
||||
int[] dimensions = getImageDimensions(originalUrl);
|
||||
if (dimensions == null) {
|
||||
log.error("无法获取图片尺寸: {}", originalUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 构建日期时间行
|
||||
String datetimeLine = datetime != null && dtFormat != null
|
||||
? DateUtil.format(datetime, dtFormat)
|
||||
: null;
|
||||
|
||||
// 构建水印请求
|
||||
WatermarkRequest request = WatermarkRequest.builder()
|
||||
.originalImageUrl(originalUrl)
|
||||
.imageWidth(dimensions[0])
|
||||
.imageHeight(dimensions[1])
|
||||
.qrcodeUrl(qrcodeUrl)
|
||||
.faceUrl(faceUrl)
|
||||
.scenicLine(scenicLine)
|
||||
.datetimeLine(datetimeLine)
|
||||
.scale(scale)
|
||||
.offsetLeft(offsetLeft)
|
||||
.offsetRight(offsetRight)
|
||||
.offsetTop(offsetTop)
|
||||
.offsetBottom(offsetBottom)
|
||||
.outputFormat(type.getPreferFileType().equalsIgnoreCase("png") ? "PNG" : "JPEG")
|
||||
.outputQuality(90)
|
||||
.build();
|
||||
|
||||
// 创建边缘任务并等待结果
|
||||
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
|
||||
style,
|
||||
request,
|
||||
sourceId, // recordId
|
||||
faceId,
|
||||
type.getType(), // watermarkType
|
||||
DEFAULT_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (result.isSuccess()) {
|
||||
log.info("边缘端水印处理成功: sourceId={}, type={}, url={}", sourceId, type, result.getImageUrl());
|
||||
return result.getImageUrl();
|
||||
} else {
|
||||
log.error("边缘端水印处理失败: sourceId={}, type={}, error={}", sourceId, type, result.getErrorMessage());
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("边缘端水印处理异常: sourceId={}, type={}", sourceId, type, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用边缘端处理水印(适用于 WatermarkStage / Pipeline 场景)
|
||||
* 从本地文件处理,需要先上传原图和二维码
|
||||
*
|
||||
* @param info 水印信息(包含本地文件)
|
||||
* @param type 水印类型
|
||||
* @param adapter 存储适配器
|
||||
* @param recordId 记录ID(用于边缘端任务追踪,不能为空)
|
||||
* @return 处理后的本地文件,失败返回null
|
||||
*/
|
||||
public File processWatermarkFromFile(WatermarkInfo info,
|
||||
ImageWatermarkOperatorEnum type,
|
||||
IStorageAdapter adapter,
|
||||
String recordId) {
|
||||
// 将 ImageWatermarkOperatorEnum 映射到边缘端风格
|
||||
String style = mapTypeToStyle(type);
|
||||
|
||||
// 检查边缘端是否支持该风格
|
||||
if (!watermarkEdgeTaskCreator.isStyleSupported(style)) {
|
||||
log.warn("边缘端不支持水印风格: {}", style);
|
||||
return null;
|
||||
}
|
||||
|
||||
String uploadedOriginalUrl = null;
|
||||
String uploadedQrcodeUrl = null;
|
||||
String uploadedFaceUrl = null;
|
||||
|
||||
try {
|
||||
// 1. 获取图片尺寸
|
||||
BufferedImage originalImage = ImageIO.read(info.getOriginalFile());
|
||||
if (originalImage == null) {
|
||||
log.error("无法读取原图文件: {}", info.getOriginalFile());
|
||||
return null;
|
||||
}
|
||||
int imageWidth = originalImage.getWidth();
|
||||
int imageHeight = originalImage.getHeight();
|
||||
originalImage.flush();
|
||||
|
||||
// 2. 上传原图到临时位置
|
||||
String originalFileName = "temp_watermark_" + UUID.randomUUID() + ".jpg";
|
||||
uploadedOriginalUrl = adapter.uploadFile(null, info.getOriginalFile(),
|
||||
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", originalFileName);
|
||||
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", originalFileName);
|
||||
|
||||
// 3. 上传二维码(如果有)
|
||||
if (info.getQrcodeFile() != null && info.getQrcodeFile().exists()) {
|
||||
String qrcodeFileName = "temp_qrcode_" + UUID.randomUUID() + ".jpg";
|
||||
uploadedQrcodeUrl = adapter.uploadFile(null, info.getQrcodeFile(),
|
||||
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", qrcodeFileName);
|
||||
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", qrcodeFileName);
|
||||
}
|
||||
|
||||
// 4. 上传头像(如果有)
|
||||
if (info.getFaceFile() != null && info.getFaceFile().exists()) {
|
||||
String faceFileName = "temp_face_" + UUID.randomUUID() + ".jpg";
|
||||
uploadedFaceUrl = adapter.uploadFile(null, info.getFaceFile(),
|
||||
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", faceFileName);
|
||||
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", faceFileName);
|
||||
}
|
||||
|
||||
// 5. 构建水印请求
|
||||
WatermarkRequest request = WatermarkRequest.builder()
|
||||
.originalImageUrl(uploadedOriginalUrl)
|
||||
.imageWidth(imageWidth)
|
||||
.imageHeight(imageHeight)
|
||||
.qrcodeUrl(uploadedQrcodeUrl)
|
||||
.faceUrl(uploadedFaceUrl)
|
||||
.scenicLine(info.getScenicLine())
|
||||
.datetimeLine(info.getDatetimeLine())
|
||||
.scale(info.getScale())
|
||||
.offsetLeft(info.getOffsetLeft())
|
||||
.offsetRight(info.getOffsetRight())
|
||||
.offsetTop(info.getOffsetTop())
|
||||
.offsetBottom(info.getOffsetBottom())
|
||||
.outputFormat(type.getPreferFileType().equalsIgnoreCase("png") ? "PNG" : "JPEG")
|
||||
.outputQuality(90)
|
||||
.build();
|
||||
|
||||
// 6. 创建边缘任务并等待结果(使用传入的 recordId)
|
||||
// recordId 转换为 Long,如果无法转换则使用哈希值
|
||||
Long recordIdLong;
|
||||
try {
|
||||
recordIdLong = Long.parseLong(recordId);
|
||||
} catch (NumberFormatException e) {
|
||||
// 如果 recordId 不是数字(如 UUID),使用其哈希值的绝对值
|
||||
recordIdLong = (long) Math.abs(recordId.hashCode());
|
||||
}
|
||||
|
||||
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
|
||||
style,
|
||||
request,
|
||||
recordIdLong, // recordId
|
||||
null, // faceId
|
||||
type.getType(), // watermarkType
|
||||
DEFAULT_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (!result.isSuccess()) {
|
||||
log.error("边缘端水印处理失败: recordId={}, error={}", recordId, result.getErrorMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
// 7. 下载结果到目标文件
|
||||
String resultUrl = result.getImageUrl();
|
||||
File outputFile = info.getWatermarkedFile();
|
||||
downloadFile(resultUrl, outputFile);
|
||||
|
||||
log.info("边缘端水印处理成功: recordId={}, type={}, outputFile={}", recordId, type, outputFile);
|
||||
return outputFile;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("边缘端水印处理异常: recordId={}, type={}", recordId, type, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ImageWatermarkOperatorEnum 映射到边缘端风格
|
||||
*/
|
||||
private String mapTypeToStyle(ImageWatermarkOperatorEnum type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (type) {
|
||||
case NORMAL -> NormalWatermarkTemplateBuilder.STYLE;
|
||||
case LEICA -> LeicaWatermarkTemplateBuilder.STYLE;
|
||||
case PRINTER_DEFAULT -> PrinterDefaultWatermarkTemplateBuilder.STYLE;
|
||||
case PUZZLE_PRINT -> PuzzlePrintWatermarkTemplateBuilder.STYLE;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片尺寸
|
||||
*
|
||||
* @param imageUrl 图片URL
|
||||
* @return [width, height],失败返回null
|
||||
*/
|
||||
private int[] getImageDimensions(String imageUrl) {
|
||||
try {
|
||||
// 替换内网域名
|
||||
String url = imageUrl.replace("oss.zhentuai.com",
|
||||
"frametour-assets.oss-cn-shanghai-internal.aliyuncs.com");
|
||||
BufferedImage image = ImageIO.read(new URL(url));
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
int[] dimensions = new int[]{image.getWidth(), image.getHeight()};
|
||||
image.flush();
|
||||
return dimensions;
|
||||
} catch (IOException e) {
|
||||
log.error("获取图片尺寸失败: {}", imageUrl, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
private void downloadFile(String url, File targetFile) throws IOException {
|
||||
// 替换内网域名
|
||||
String downloadUrl = url.replace("oss.zhentuai.com",
|
||||
"frametour-assets.oss-cn-shanghai-internal.aliyuncs.com");
|
||||
HttpUtil.downloadFile(downloadUrl, targetFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 水印边缘任务创建服务
|
||||
* 将水印请求转换为边缘渲染任务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WatermarkEdgeTaskCreator {
|
||||
|
||||
private final PuzzleEdgeRenderTaskService edgeRenderTaskService;
|
||||
private final List<IWatermarkTemplateBuilder> builders;
|
||||
|
||||
private final Map<String, IWatermarkTemplateBuilder> builderMap = new HashMap<>();
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
for (IWatermarkTemplateBuilder builder : builders) {
|
||||
builderMap.put(builder.getStyle(), builder);
|
||||
log.info("注册水印模板构建器: {}", builder.getStyle());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建水印渲染任务
|
||||
*
|
||||
* @param style 水印风格(normal/leica/printer_default)
|
||||
* @param request 水印请求参数
|
||||
* @param recordId 原始拼图记录ID(用于关联)
|
||||
* @param faceId 人脸ID(可选)
|
||||
* @param watermarkType 水印类型标识(如 print、free_download)
|
||||
* @return 任务ID
|
||||
*/
|
||||
public Long createTask(String style,
|
||||
WatermarkRequest request,
|
||||
Long recordId,
|
||||
Long faceId,
|
||||
String watermarkType) {
|
||||
IWatermarkTemplateBuilder builder = builderMap.get(style);
|
||||
if (builder == null) {
|
||||
throw new IllegalArgumentException("未知的水印风格: " + style);
|
||||
}
|
||||
|
||||
// 构建水印模板
|
||||
WatermarkTemplateResult result = builder.build(request);
|
||||
|
||||
// 创建边缘渲染任务
|
||||
Long taskId = edgeRenderTaskService.createWatermarkRenderTask(
|
||||
recordId,
|
||||
faceId,
|
||||
watermarkType,
|
||||
result.getTemplate(),
|
||||
result.getElements(),
|
||||
result.getDynamicData(),
|
||||
request.getOutputFormat(),
|
||||
request.getOutputQuality()
|
||||
);
|
||||
|
||||
log.info("创建水印边缘渲染任务: style={}, taskId={}, recordId={}, watermarkType={}",
|
||||
style, taskId, recordId, watermarkType);
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建水印渲染任务并等待结果
|
||||
*
|
||||
* @param style 水印风格
|
||||
* @param request 水印请求参数
|
||||
* @param recordId 原始拼图记录ID
|
||||
* @param faceId 人脸ID
|
||||
* @param watermarkType 水印类型
|
||||
* @param timeoutMs 超时时间(毫秒)
|
||||
* @return 任务结果
|
||||
*/
|
||||
public PuzzleEdgeRenderTaskService.TaskWaitResult createAndWait(String style,
|
||||
WatermarkRequest request,
|
||||
Long recordId,
|
||||
Long faceId,
|
||||
String watermarkType,
|
||||
long timeoutMs) {
|
||||
Long taskId = createTask(style, request, recordId, faceId, watermarkType);
|
||||
edgeRenderTaskService.registerWait(taskId);
|
||||
return edgeRenderTaskService.waitForTask(taskId, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的水印风格列表
|
||||
*/
|
||||
public List<String> getSupportedStyles() {
|
||||
return List.copyOf(builderMap.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持指定的水印风格
|
||||
*/
|
||||
public boolean isStyleSupported(String style) {
|
||||
return builderMap.containsKey(style);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 水印请求参数
|
||||
* 将原有的 WatermarkInfo(基于文件)转换为边缘渲染所需的格式(基于URL)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class WatermarkRequest {
|
||||
/**
|
||||
* 原图URL
|
||||
*/
|
||||
private String originalImageUrl;
|
||||
|
||||
/**
|
||||
* 原图宽度(像素)
|
||||
*/
|
||||
private int imageWidth;
|
||||
|
||||
/**
|
||||
* 原图高度(像素)
|
||||
*/
|
||||
private int imageHeight;
|
||||
|
||||
/**
|
||||
* 二维码URL
|
||||
*/
|
||||
private String qrcodeUrl;
|
||||
|
||||
/**
|
||||
* 头像URL(可选)
|
||||
*/
|
||||
private String faceUrl;
|
||||
|
||||
/**
|
||||
* 景区名称
|
||||
*/
|
||||
private String scenicLine;
|
||||
|
||||
/**
|
||||
* 日期时间行
|
||||
*/
|
||||
private String datetimeLine;
|
||||
|
||||
/**
|
||||
* 四边偏移(像素),正数表示向内偏移
|
||||
*/
|
||||
private Integer offsetTop;
|
||||
private Integer offsetBottom;
|
||||
private Integer offsetLeft;
|
||||
private Integer offsetRight;
|
||||
|
||||
/**
|
||||
* 缩放倍数,默认1.0
|
||||
*/
|
||||
private Double scale;
|
||||
|
||||
/**
|
||||
* 输出格式:PNG / JPEG
|
||||
*/
|
||||
@Builder.Default
|
||||
private String outputFormat = "JPEG";
|
||||
|
||||
/**
|
||||
* 输出质量(0-100)
|
||||
*/
|
||||
@Builder.Default
|
||||
private Integer outputQuality = 75;
|
||||
|
||||
public double getScaleValue() {
|
||||
return scale != null ? scale : 1.0;
|
||||
}
|
||||
|
||||
public int getOffsetTopValue() {
|
||||
return offsetTop != null ? offsetTop : 0;
|
||||
}
|
||||
|
||||
public int getOffsetBottomValue() {
|
||||
return offsetBottom != null ? offsetBottom : 0;
|
||||
}
|
||||
|
||||
public int getOffsetLeftValue() {
|
||||
return offsetLeft != null ? offsetLeft : 0;
|
||||
}
|
||||
|
||||
public int getOffsetRightValue() {
|
||||
return offsetRight != null ? offsetRight : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 水印模板构建结果
|
||||
* 包含虚拟模板、元素列表和动态数据,用于发送给边缘渲染任务
|
||||
*/
|
||||
@Data
|
||||
public class WatermarkTemplateResult {
|
||||
/**
|
||||
* 虚拟模板(运行时构造,不存储到数据库)
|
||||
*/
|
||||
private PuzzleTemplateEntity template;
|
||||
|
||||
/**
|
||||
* 元素列表(按z-index排序)
|
||||
*/
|
||||
private List<PuzzleElementEntity> elements;
|
||||
|
||||
/**
|
||||
* 动态数据(elementKey -> 实际值URL或文本)
|
||||
*/
|
||||
private Map<String, String> dynamicData;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.ycwl.basic.image.watermark.edge.controller;
|
||||
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeTaskCreator;
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkRequest;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 水印边缘渲染测试控制器
|
||||
* 用于测试水印边缘渲染功能
|
||||
*/
|
||||
@Slf4j
|
||||
@IgnoreToken
|
||||
@RestController
|
||||
@RequestMapping("/test/watermark/edge")
|
||||
@RequiredArgsConstructor
|
||||
public class WatermarkEdgeTestController {
|
||||
|
||||
private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator;
|
||||
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
|
||||
|
||||
/**
|
||||
* 获取支持的水印风格列表
|
||||
*/
|
||||
@GetMapping("/styles")
|
||||
public ApiResponse<List<String>> getSupportedStyles() {
|
||||
return ApiResponse.success(watermarkEdgeTaskCreator.getSupportedStyles());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建水印渲染任务(异步)
|
||||
* 任务创建后由边缘端拉取执行
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
public ApiResponse<CreateTaskResponse> createTask(@RequestBody CreateTaskRequest req) {
|
||||
// 参数校验
|
||||
if (req.getStyle() == null || req.getStyle().isEmpty()) {
|
||||
return ApiResponse.fail("水印风格(style)不能为空");
|
||||
}
|
||||
if (!watermarkEdgeTaskCreator.isStyleSupported(req.getStyle())) {
|
||||
return ApiResponse.fail("不支持的水印风格: " + req.getStyle() +
|
||||
",支持的风格: " + watermarkEdgeTaskCreator.getSupportedStyles());
|
||||
}
|
||||
if (req.getOriginalImageUrl() == null || req.getOriginalImageUrl().isEmpty()) {
|
||||
return ApiResponse.fail("原图URL(originalImageUrl)不能为空");
|
||||
}
|
||||
if (req.getImageWidth() <= 0 || req.getImageHeight() <= 0) {
|
||||
return ApiResponse.fail("图片宽高必须大于0");
|
||||
}
|
||||
|
||||
// 构建请求
|
||||
WatermarkRequest watermarkRequest = WatermarkRequest.builder()
|
||||
.originalImageUrl(req.getOriginalImageUrl())
|
||||
.imageWidth(req.getImageWidth())
|
||||
.imageHeight(req.getImageHeight())
|
||||
.qrcodeUrl(req.getQrcodeUrl())
|
||||
.faceUrl(req.getFaceUrl())
|
||||
.scenicLine(req.getScenicLine())
|
||||
.datetimeLine(req.getDatetimeLine())
|
||||
.offsetTop(req.getOffsetTop())
|
||||
.offsetBottom(req.getOffsetBottom())
|
||||
.offsetLeft(req.getOffsetLeft())
|
||||
.offsetRight(req.getOffsetRight())
|
||||
.scale(req.getScale())
|
||||
.outputFormat(req.getOutputFormat() != null ? req.getOutputFormat() : "JPEG")
|
||||
.outputQuality(req.getOutputQuality() != null ? req.getOutputQuality() : 75)
|
||||
.build();
|
||||
|
||||
// 创建任务
|
||||
Long taskId = watermarkEdgeTaskCreator.createTask(
|
||||
req.getStyle(),
|
||||
watermarkRequest,
|
||||
req.getRecordId() != null ? req.getRecordId() : 0L, // 测试用默认值
|
||||
req.getFaceId(),
|
||||
req.getWatermarkType() != null ? req.getWatermarkType() : "test"
|
||||
);
|
||||
|
||||
CreateTaskResponse response = new CreateTaskResponse();
|
||||
response.setTaskId(taskId);
|
||||
response.setMessage("任务已创建,等待边缘端拉取执行");
|
||||
|
||||
log.info("测试创建水印任务: style={}, taskId={}", req.getStyle(), taskId);
|
||||
return ApiResponse.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建水印渲染任务并等待结果(同步)
|
||||
* 注意:此接口会阻塞直到任务完成或超时
|
||||
*/
|
||||
@PostMapping("/createAndWait")
|
||||
public ApiResponse<CreateAndWaitResponse> createAndWait(@RequestBody CreateTaskRequest req) {
|
||||
// 参数校验
|
||||
if (req.getStyle() == null || req.getStyle().isEmpty()) {
|
||||
return ApiResponse.fail("水印风格(style)不能为空");
|
||||
}
|
||||
if (!watermarkEdgeTaskCreator.isStyleSupported(req.getStyle())) {
|
||||
return ApiResponse.fail("不支持的水印风格: " + req.getStyle() +
|
||||
",支持的风格: " + watermarkEdgeTaskCreator.getSupportedStyles());
|
||||
}
|
||||
if (req.getOriginalImageUrl() == null || req.getOriginalImageUrl().isEmpty()) {
|
||||
return ApiResponse.fail("原图URL(originalImageUrl)不能为空");
|
||||
}
|
||||
if (req.getImageWidth() <= 0 || req.getImageHeight() <= 0) {
|
||||
return ApiResponse.fail("图片宽高必须大于0");
|
||||
}
|
||||
|
||||
// 构建请求
|
||||
WatermarkRequest watermarkRequest = WatermarkRequest.builder()
|
||||
.originalImageUrl(req.getOriginalImageUrl())
|
||||
.imageWidth(req.getImageWidth())
|
||||
.imageHeight(req.getImageHeight())
|
||||
.qrcodeUrl(req.getQrcodeUrl())
|
||||
.faceUrl(req.getFaceUrl())
|
||||
.scenicLine(req.getScenicLine())
|
||||
.datetimeLine(req.getDatetimeLine())
|
||||
.offsetTop(req.getOffsetTop())
|
||||
.offsetBottom(req.getOffsetBottom())
|
||||
.offsetLeft(req.getOffsetLeft())
|
||||
.offsetRight(req.getOffsetRight())
|
||||
.scale(req.getScale())
|
||||
.outputFormat(req.getOutputFormat() != null ? req.getOutputFormat() : "JPEG")
|
||||
.outputQuality(req.getOutputQuality() != null ? req.getOutputQuality() : 75)
|
||||
.build();
|
||||
|
||||
// 超时时间,默认30秒
|
||||
long timeoutMs = req.getTimeoutMs() != null ? req.getTimeoutMs() : 30000L;
|
||||
|
||||
// 先创建任务获取 taskId
|
||||
Long taskId = watermarkEdgeTaskCreator.createTask(
|
||||
req.getStyle(),
|
||||
watermarkRequest,
|
||||
req.getRecordId() != null ? req.getRecordId() : 0L,
|
||||
req.getFaceId(),
|
||||
req.getWatermarkType() != null ? req.getWatermarkType() : "test"
|
||||
);
|
||||
|
||||
// 注册等待并等待结果
|
||||
puzzleEdgeRenderTaskService.registerWait(taskId);
|
||||
PuzzleEdgeRenderTaskService.TaskWaitResult result = puzzleEdgeRenderTaskService.waitForTask(taskId, timeoutMs);
|
||||
|
||||
CreateAndWaitResponse response = new CreateAndWaitResponse();
|
||||
response.setTaskId(taskId);
|
||||
response.setSuccess(result.isSuccess());
|
||||
response.setImageUrl(result.getImageUrl());
|
||||
response.setErrorMessage(result.getErrorMessage());
|
||||
|
||||
log.info("测试水印任务完成: style={}, taskId={}, success={}, imageUrl={}",
|
||||
req.getStyle(), taskId, result.isSuccess(), result.getImageUrl());
|
||||
|
||||
return ApiResponse.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务请求
|
||||
*/
|
||||
@Data
|
||||
public static class CreateTaskRequest {
|
||||
/**
|
||||
* 水印风格:normal / leica / printer_default
|
||||
*/
|
||||
private String style;
|
||||
|
||||
/**
|
||||
* 原图URL
|
||||
*/
|
||||
private String originalImageUrl;
|
||||
|
||||
/**
|
||||
* 原图宽度
|
||||
*/
|
||||
private int imageWidth;
|
||||
|
||||
/**
|
||||
* 原图高度
|
||||
*/
|
||||
private int imageHeight;
|
||||
|
||||
/**
|
||||
* 二维码URL
|
||||
*/
|
||||
private String qrcodeUrl;
|
||||
|
||||
/**
|
||||
* 头像URL(可选)
|
||||
*/
|
||||
private String faceUrl;
|
||||
|
||||
/**
|
||||
* 景区名称
|
||||
*/
|
||||
private String scenicLine;
|
||||
|
||||
/**
|
||||
* 日期时间行
|
||||
*/
|
||||
private String datetimeLine;
|
||||
|
||||
/**
|
||||
* 四边偏移(像素)
|
||||
*/
|
||||
private Integer offsetTop;
|
||||
private Integer offsetBottom;
|
||||
private Integer offsetLeft;
|
||||
private Integer offsetRight;
|
||||
|
||||
/**
|
||||
* 缩放倍数
|
||||
*/
|
||||
private Double scale;
|
||||
|
||||
/**
|
||||
* 输出格式:PNG / JPEG
|
||||
*/
|
||||
private String outputFormat;
|
||||
|
||||
/**
|
||||
* 输出质量(0-100)
|
||||
*/
|
||||
private Integer outputQuality;
|
||||
|
||||
/**
|
||||
* 关联的拼图记录ID(测试用)
|
||||
*/
|
||||
private Long recordId;
|
||||
|
||||
/**
|
||||
* 人脸ID(可选)
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 水印类型标识
|
||||
*/
|
||||
private String watermarkType;
|
||||
|
||||
/**
|
||||
* 等待超时时间(毫秒),仅用于 createAndWait
|
||||
*/
|
||||
private Long timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务响应
|
||||
*/
|
||||
@Data
|
||||
public static class CreateTaskResponse {
|
||||
private Long taskId;
|
||||
private String message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并等待响应
|
||||
*/
|
||||
@Data
|
||||
public static class CreateAndWaitResponse {
|
||||
private Long taskId;
|
||||
private boolean success;
|
||||
private String imageUrl;
|
||||
private String errorMessage;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public enum ImageWatermarkOperatorEnum {
|
||||
WATERMARK("defW", "jpg"),
|
||||
LEICA("leica", "png"),
|
||||
NORMAL("normal", "png"),
|
||||
PRINTER_DEFAULT("pDefault", "png");
|
||||
LEICA("leica", "jpg"),
|
||||
NORMAL("normal", "jpg"),
|
||||
PRINTER_DEFAULT("pDefault", "jpg"),
|
||||
PUZZLE_PRINT("puzzle_print", "jpg");
|
||||
|
||||
private final String type;
|
||||
private final String preferFileType;
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package com.ycwl.basic.image.watermark.operator;
|
||||
|
||||
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
|
||||
import com.ycwl.basic.image.watermark.exception.ImageWatermarkException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
@Slf4j
|
||||
public class DefaultImageWatermarkOperator implements IOperator {
|
||||
@Override
|
||||
public File process(WatermarkInfo info) throws ImageWatermarkException {
|
||||
BufferedImage baseImage;
|
||||
BufferedImage watermarkImage;
|
||||
InputStream logoInputStream = getClass().getResourceAsStream("/watermark.png");
|
||||
if (logoInputStream == null) {
|
||||
throw new ImageWatermarkException("无法找到 watermark.png 资源文件");
|
||||
}
|
||||
try {
|
||||
baseImage = ImageIO.read(info.getOriginalFile());
|
||||
watermarkImage = ImageIO.read(logoInputStream);
|
||||
} catch (IOException e) {
|
||||
throw new ImageWatermarkException("图片打开失败");
|
||||
}
|
||||
// 新图像画布
|
||||
BufferedImage newImage = new BufferedImage(baseImage.getWidth(), baseImage.getHeight(), BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = newImage.createGraphics();
|
||||
g2d.drawImage(baseImage, 0, 0, null);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.5f));
|
||||
g2d.drawImage(watermarkImage, 0, 0, baseImage.getWidth(), baseImage.getHeight(), null);
|
||||
String fileName = info.getWatermarkedFile().getName();
|
||||
String formatName = "jpg"; // 默认格式为 jpg
|
||||
if (fileName.endsWith(".png")) {
|
||||
formatName = "png";
|
||||
} else if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) {
|
||||
formatName = "jpg";
|
||||
}
|
||||
ImageWriter writer = ImageIO.getImageWritersByFormatName(formatName).next();
|
||||
ImageOutputStream ios;
|
||||
try {
|
||||
ios = ImageIO.createImageOutputStream(info.getWatermarkedFile());
|
||||
} catch (IOException e) {
|
||||
throw new ImageWatermarkException("图片保存失败,目标文件无法写入");
|
||||
}
|
||||
writer.setOutput(ios);
|
||||
try {
|
||||
// 使用 ImageWriter 设置写入质量
|
||||
ImageWriteParam writeParam = writer.getDefaultWriteParam();
|
||||
if (writeParam.canWriteCompressed()) {
|
||||
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||
writeParam.setCompressionQuality(0.8f); // 设置写入质量为 80%
|
||||
}
|
||||
writer.write(null, new javax.imageio.IIOImage(newImage, null, null), writeParam);
|
||||
} catch (IOException e) {
|
||||
throw new ImageWatermarkException("图片保存失败");
|
||||
}
|
||||
finally {
|
||||
g2d.dispose();
|
||||
try {
|
||||
ios.close();
|
||||
} catch (IOException ignore) {
|
||||
}
|
||||
writer.dispose();
|
||||
}
|
||||
return info.getWatermarkedFile();
|
||||
}
|
||||
}
|
||||
@@ -105,10 +105,36 @@ public interface StatisticsMapper {
|
||||
|
||||
List<HashMap<String, String>> orderChartByHour(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按小时统计扫码人数(仅统计数据,不含订单)
|
||||
*/
|
||||
List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按日期统计扫码人数(仅统计数据,不含订单)
|
||||
*/
|
||||
List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按小时统计访问打印样片页面人数
|
||||
*/
|
||||
List<HashMap<String, String>> printerFromSampleChartByHour(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按日期统计访问打印样片页面人数
|
||||
*/
|
||||
List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按小时统计订单数据
|
||||
*/
|
||||
List<HashMap<String, String>> orderChartByHourForMerge(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按日期统计订单数据
|
||||
*/
|
||||
List<HashMap<String, String>> orderChartByDateForMerge(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 统计分销员扫码次数
|
||||
*/
|
||||
|
||||
@@ -78,7 +78,7 @@ public interface UserNotificationAuthorizationMapper extends BaseMapper<UserNoti
|
||||
|
||||
/**
|
||||
* 检查用户是否还有剩余授权次数
|
||||
*
|
||||
*
|
||||
* @param memberId 用户ID
|
||||
* @param templateId 模板ID
|
||||
* @param scenicId 景区ID
|
||||
@@ -89,4 +89,18 @@ public interface UserNotificationAuthorizationMapper extends BaseMapper<UserNoti
|
||||
@Param("templateId") String templateId,
|
||||
@Param("scenicId") Long scenicId
|
||||
);
|
||||
|
||||
/**
|
||||
* 批量查询用户对多个模板的授权记录
|
||||
*
|
||||
* @param memberId 用户ID
|
||||
* @param templateIds 模板ID列表
|
||||
* @param scenicId 景区ID
|
||||
* @return 授权记录列表
|
||||
*/
|
||||
List<UserNotificationAuthorizationEntity> selectBatchByTemplateIds(
|
||||
@Param("memberId") Long memberId,
|
||||
@Param("templateIds") List<String> templateIds,
|
||||
@Param("scenicId") Long scenicId
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationRecordEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 用户订阅消息授权明细Mapper(幂等)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Mapper
|
||||
public interface UserNotificationAuthorizationRecordMapper extends BaseMapper<UserNotificationAuthorizationRecordEntity> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 微信订阅消息事件模板映射Mapper
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Mapper
|
||||
public interface WechatSubscribeEventTemplateMapper extends BaseMapper<WechatSubscribeEventTemplateEntity> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 微信订阅消息场景模板映射Mapper
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Mapper
|
||||
public interface WechatSubscribeSceneTemplateMapper extends BaseMapper<WechatSubscribeSceneTemplateEntity> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 微信订阅消息发送日志Mapper
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Mapper
|
||||
public interface WechatSubscribeSendLogMapper extends BaseMapper<WechatSubscribeSendLogEntity> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板配置Mapper
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Mapper
|
||||
public interface WechatSubscribeTemplateConfigMapper extends BaseMapper<WechatSubscribeTemplateConfigEntity> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ycwl.basic.model.mobile.notify.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 批量查询用户授权余额请求
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2026/01/10
|
||||
*/
|
||||
@Data
|
||||
public class BatchRemainingCountReq {
|
||||
|
||||
/**
|
||||
* 通知模板ID列表(微信 wechatTemplateId)
|
||||
*/
|
||||
@NotEmpty(message = "模板ID列表不能为空")
|
||||
private List<String> templateIds;
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
@NotNull(message = "景区ID不能为空")
|
||||
private Long scenicId;
|
||||
}
|
||||
@@ -14,16 +14,25 @@ import java.util.List;
|
||||
*/
|
||||
@Data
|
||||
public class NotificationAuthRecordReq {
|
||||
|
||||
|
||||
/**
|
||||
* 通知模板ID列表 - 支持批量授权
|
||||
*/
|
||||
@NotEmpty(message = "模板ID列表不能为空")
|
||||
private List<String> templateIds;
|
||||
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
@NotNull(message = "景区ID不能为空")
|
||||
private Long scenicId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端幂等ID(可选)
|
||||
* <p>
|
||||
* 目的:避免前端重试导致授权次数虚增。
|
||||
* 同一次用户授权动作(一次 requestSubscribeMessage)建议复用同一个 requestId。
|
||||
* </p>
|
||||
*/
|
||||
private String requestId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ycwl.basic.model.mobile.notify.resp;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 景区所有场景及其订阅消息模板列表(静态配置,不含用户授权信息)
|
||||
* 用户授权信息通过 /api/mobile/notify/auth/batch-remaining 接口获取
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2026/01/10
|
||||
*/
|
||||
@Data
|
||||
public class WechatSubscribeAllScenesResp {
|
||||
|
||||
private Long scenicId;
|
||||
|
||||
private List<SceneWithTemplates> scenes;
|
||||
|
||||
@Data
|
||||
public static class SceneWithTemplates {
|
||||
/**
|
||||
* 场景标识
|
||||
*/
|
||||
private String sceneKey;
|
||||
|
||||
/**
|
||||
* 该场景下的模板列表
|
||||
*/
|
||||
private List<StaticTemplateInfo> templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态模板信息(不含用户授权信息,可缓存)
|
||||
*/
|
||||
@Data
|
||||
public static class StaticTemplateInfo {
|
||||
/**
|
||||
* 逻辑模板键(业务固定)
|
||||
*/
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板ID(tmplId)
|
||||
*/
|
||||
private String wechatTemplateId;
|
||||
|
||||
/**
|
||||
* 前端展示标题
|
||||
*/
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 前端展示描述
|
||||
*/
|
||||
private String description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ycwl.basic.model.mobile.notify.resp;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 场景可申请的订阅消息模板列表(含用户授权余额)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
public class WechatSubscribeSceneTemplatesResp {
|
||||
|
||||
private Long scenicId;
|
||||
|
||||
private String sceneKey;
|
||||
|
||||
private List<TemplateInfo> templates;
|
||||
|
||||
@Data
|
||||
public static class TemplateInfo {
|
||||
/**
|
||||
* 逻辑模板键(业务固定)
|
||||
*/
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板ID(tmplId)
|
||||
*/
|
||||
private String wechatTemplateId;
|
||||
|
||||
/**
|
||||
* 前端展示标题
|
||||
*/
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 前端展示描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 用户剩余授权次数
|
||||
*/
|
||||
private Integer remainingCount;
|
||||
|
||||
/**
|
||||
* 是否有授权(remainingCount > 0)
|
||||
*/
|
||||
private Boolean hasAuthorization;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ycwl.basic.model.pc.notify.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 用户订阅消息授权明细(幂等)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
@TableName("user_notification_authorization_record")
|
||||
public class UserNotificationAuthorizationRecordEntity {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
private Long memberId;
|
||||
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板ID(tmplId)
|
||||
*/
|
||||
private String templateId;
|
||||
|
||||
/**
|
||||
* 前端幂等ID(同一次用户授权动作复用)
|
||||
*/
|
||||
private String requestId;
|
||||
|
||||
private Date createTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ycwl.basic.model.pc.notify.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 事件到模板映射(后端触发发送用,支持按景区覆盖)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
@TableName("wechat_subscribe_event_template")
|
||||
public class WechatSubscribeEventTemplateEntity {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
private String eventKey;
|
||||
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
private Integer sortOrder;
|
||||
|
||||
private Integer sendDelaySeconds;
|
||||
|
||||
private Integer dedupSeconds;
|
||||
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.ycwl.basic.model.pc.notify.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 场景到模板映射(前端申请授权用,支持按景区覆盖)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
@TableName("wechat_subscribe_scene_template")
|
||||
public class WechatSubscribeSceneTemplateEntity {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
private String sceneKey;
|
||||
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
private Integer sortOrder;
|
||||
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ycwl.basic.model.pc.notify.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 微信订阅消息发送日志(用于幂等与排障)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
@TableName("wechat_subscribe_send_log")
|
||||
public class WechatSubscribeSendLogEntity {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
private String idempotencyKey;
|
||||
|
||||
private String eventKey;
|
||||
|
||||
private String templateKey;
|
||||
|
||||
private Long scenicId;
|
||||
|
||||
private Long memberId;
|
||||
|
||||
private String openId;
|
||||
|
||||
private String wechatTemplateId;
|
||||
|
||||
private String ztMessageId;
|
||||
|
||||
private String status;
|
||||
|
||||
private String errorMessage;
|
||||
|
||||
private String payloadJson;
|
||||
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ycwl.basic.model.pc.notify.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 微信小程序订阅消息模板配置(支持按景区覆盖)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
@TableName("wechat_subscribe_template_config")
|
||||
public class WechatSubscribeTemplateConfigEntity {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 逻辑模板键(业务固定)
|
||||
*/
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板ID(tmplId)
|
||||
*/
|
||||
private String wechatTemplateId;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
/**
|
||||
* 标题模板(用于日志/后台展示)
|
||||
*/
|
||||
private String titleTemplate;
|
||||
|
||||
/**
|
||||
* 内容模板(用于日志/后台展示)
|
||||
*/
|
||||
private String contentTemplate;
|
||||
|
||||
/**
|
||||
* 跳转页面模板(小程序 page)
|
||||
*/
|
||||
private String pageTemplate;
|
||||
|
||||
/**
|
||||
* data模板JSON:{ "thing1":"${scenicName}", "thing3":"${remark}" }
|
||||
*/
|
||||
private String dataTemplateJson;
|
||||
|
||||
/**
|
||||
* 前端展示用描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ycwl.basic.model.pc.notify.req;
|
||||
|
||||
import com.ycwl.basic.model.common.BaseQueryParameterReq;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 事件-模板映射分页查询请求
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
public class WechatSubscribeEventTemplatePageReq extends BaseQueryParameterReq {
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置;为空表示不筛选
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 事件键(模糊匹配)
|
||||
*/
|
||||
private String eventKey;
|
||||
|
||||
/**
|
||||
* 逻辑模板键(模糊匹配)
|
||||
*/
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ycwl.basic.model.pc.notify.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 事件-模板映射保存请求(新增/修改)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
public class WechatSubscribeEventTemplateSaveReq {
|
||||
|
||||
/**
|
||||
* 主键ID(为空表示新增;不为空表示更新)
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
private String eventKey;
|
||||
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
/**
|
||||
* 排序(越小越靠前)
|
||||
*/
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 发送延迟(秒),0表示立即发送(预留)
|
||||
*/
|
||||
private Integer sendDelaySeconds;
|
||||
|
||||
/**
|
||||
* 去重窗口(秒),0表示仅依赖幂等键(预留)
|
||||
*/
|
||||
private Integer dedupSeconds;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ycwl.basic.model.pc.notify.req;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 微信订阅消息触发入参(后端内部调用)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class WechatSubscribeNotifyTriggerRequest {
|
||||
|
||||
private Long scenicId;
|
||||
|
||||
private Long memberId;
|
||||
|
||||
private String openId;
|
||||
|
||||
/**
|
||||
* 业务幂等ID(强烈建议必填)
|
||||
* <p>
|
||||
* 示例:taskId、couponId、faceId+日期 等。
|
||||
* </p>
|
||||
*/
|
||||
private String bizId;
|
||||
|
||||
/**
|
||||
* 模板渲染变量(${key})
|
||||
*/
|
||||
private Map<String, Object> variables;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ycwl.basic.model.pc.notify.req;
|
||||
|
||||
import com.ycwl.basic.model.common.BaseQueryParameterReq;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 场景-模板映射分页查询请求
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
public class WechatSubscribeSceneTemplatePageReq extends BaseQueryParameterReq {
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置;为空表示不筛选
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 场景键(模糊匹配)
|
||||
*/
|
||||
private String sceneKey;
|
||||
|
||||
/**
|
||||
* 逻辑模板键(模糊匹配)
|
||||
*/
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ycwl.basic.model.pc.notify.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 场景-模板映射保存请求(新增/修改)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
public class WechatSubscribeSceneTemplateSaveReq {
|
||||
|
||||
/**
|
||||
* 主键ID(为空表示新增;不为空表示更新)
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
private String sceneKey;
|
||||
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
/**
|
||||
* 排序(越小越靠前)
|
||||
*/
|
||||
private Integer sortOrder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ycwl.basic.model.pc.notify.req;
|
||||
|
||||
import com.ycwl.basic.model.common.BaseQueryParameterReq;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 微信订阅消息发送日志分页查询请求
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
public class WechatSubscribeSendLogPageReq extends BaseQueryParameterReq {
|
||||
|
||||
private Long scenicId;
|
||||
|
||||
private Long memberId;
|
||||
|
||||
private String eventKey;
|
||||
|
||||
private String templateKey;
|
||||
|
||||
private String status;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ycwl.basic.model.pc.notify.req;
|
||||
|
||||
import com.ycwl.basic.model.common.BaseQueryParameterReq;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板配置分页查询请求
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
public class WechatSubscribeTemplateConfigPageReq extends BaseQueryParameterReq {
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置;为空表示不筛选
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 逻辑模板键(模糊匹配)
|
||||
*/
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板ID(模糊匹配)
|
||||
*/
|
||||
private String wechatTemplateId;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.ycwl.basic.model.pc.notify.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板配置保存请求(新增/修改)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
public class WechatSubscribeTemplateConfigSaveReq {
|
||||
|
||||
/**
|
||||
* 主键ID(为空表示新增;不为空表示更新)
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 逻辑模板键(业务固定)
|
||||
*/
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板ID(tmplId)
|
||||
*/
|
||||
private String wechatTemplateId;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
/**
|
||||
* 标题模板(用于日志/后台展示)
|
||||
*/
|
||||
private String titleTemplate;
|
||||
|
||||
/**
|
||||
* 内容模板(用于日志/后台展示)
|
||||
*/
|
||||
private String contentTemplate;
|
||||
|
||||
/**
|
||||
* 跳转页面模板(小程序 page)
|
||||
*/
|
||||
private String pageTemplate;
|
||||
|
||||
/**
|
||||
* data模板JSON:{ "thing1":"${scenicName}", "thing3":"${remark}" }
|
||||
*/
|
||||
private String dataTemplateJson;
|
||||
|
||||
/**
|
||||
* 前端展示用描述
|
||||
*/
|
||||
private String description;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ycwl.basic.model.pc.notify.resp;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信订阅消息触发结果(后端内部调用)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
public class WechatSubscribeNotifyTriggerResult {
|
||||
|
||||
/**
|
||||
* 是否找到了可用配置(eventKey -> templateKey -> templateConfig)
|
||||
*/
|
||||
private boolean configFound;
|
||||
|
||||
/**
|
||||
* 成功投递到消息系统的数量(以 producer send 成功返回为准)
|
||||
*/
|
||||
private int sentCount;
|
||||
|
||||
/**
|
||||
* 跳过数量(无授权/幂等命中/配置不完整等)
|
||||
*/
|
||||
private int skippedCount;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public class MemberPrintEntity {
|
||||
private Long memberId;
|
||||
private Long faceId;
|
||||
private Long sourceId;
|
||||
private String imageType;
|
||||
private String origUrl;
|
||||
private String cropUrl;
|
||||
private String printUrl;
|
||||
|
||||
@@ -9,6 +9,7 @@ public class MemberPrintResp {
|
||||
private Integer id;
|
||||
private Long scenicId;
|
||||
private Long sourceId;
|
||||
private String imageType;
|
||||
private String scenicName;
|
||||
private Long faceId;
|
||||
private Long memberId;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ycwl.basic.model.pc.puzzle.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 会员拼图关联实体
|
||||
* 记录人脸与拼图生成记录的关联关系,包含免费和购买状态
|
||||
*/
|
||||
@Data
|
||||
@TableName("member_puzzle")
|
||||
public class MemberPuzzleEntity {
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private Long scenicId;
|
||||
private Long faceId;
|
||||
private Long recordId;
|
||||
private Integer isBuy;
|
||||
private Long orderId;
|
||||
private Integer isFree;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.model.pc.puzzle.entity;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 拼图水印实体
|
||||
* 存储拼图在不同场景下的水印版本(如打印水印、免费下载水印等)
|
||||
*/
|
||||
@Data
|
||||
public class PuzzleWatermarkEntity {
|
||||
private Integer id;
|
||||
private Long recordId;
|
||||
private Long faceId;
|
||||
private String watermarkType;
|
||||
private String watermarkUrl;
|
||||
}
|
||||
@@ -21,4 +21,14 @@ public class CreateVirtualOrderRequest {
|
||||
* 打印机ID(可选)
|
||||
*/
|
||||
private Integer printerId;
|
||||
|
||||
/**
|
||||
* 是否需要图像增强(可选,默认不增强)
|
||||
*/
|
||||
private Boolean needEnhance;
|
||||
|
||||
/**
|
||||
* 打印图片URL(可选,如果提供则使用此URL进行打印)
|
||||
*/
|
||||
private String printImgUrl;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ycwl.basic.pricing.controller;
|
||||
|
||||
import com.ycwl.basic.constant.BaseContextHandler;
|
||||
import com.ycwl.basic.pricing.dto.CouponClaimResult;
|
||||
import com.ycwl.basic.pricing.dto.req.SceneCouponClaimReq;
|
||||
import com.ycwl.basic.pricing.dto.resp.SceneCouponAvailableResp;
|
||||
import com.ycwl.basic.pricing.service.ISceneCouponService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 场景优惠券领取控制器(前端/移动端)
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/pricing/scene-coupon")
|
||||
@RequiredArgsConstructor
|
||||
public class SceneCouponClaimController {
|
||||
|
||||
private final ISceneCouponService sceneCouponService;
|
||||
|
||||
/**
|
||||
* 查询场景下可领取的优惠券列表
|
||||
*
|
||||
* @param sceneKey 场景标识
|
||||
* @param scenicId 景区ID
|
||||
* @return 可领取优惠券列表(包含用户领取状态)
|
||||
*/
|
||||
@GetMapping("/available")
|
||||
public ApiResponse<List<SceneCouponAvailableResp>> getAvailableCoupons(
|
||||
@RequestParam String sceneKey,
|
||||
@RequestParam Long scenicId) {
|
||||
try {
|
||||
Long userId = getUserId();
|
||||
List<SceneCouponAvailableResp> list = sceneCouponService.getAvailableCoupons(sceneKey, scenicId, userId);
|
||||
return ApiResponse.success(list);
|
||||
} catch (Exception e) {
|
||||
log.error("场景优惠券|可领取列表查询失败 sceneKey={}, scenicId={}", sceneKey, scenicId, e);
|
||||
return ApiResponse.fail("查询失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取场景优惠券
|
||||
*
|
||||
* @param req 领取请求
|
||||
* @return 领取结果列表
|
||||
*/
|
||||
@PostMapping("/claim")
|
||||
public ApiResponse<List<CouponClaimResult>> claimCoupons(@RequestBody SceneCouponClaimReq req) {
|
||||
try {
|
||||
Long userId = getUserId();
|
||||
if (userId == null) {
|
||||
return ApiResponse.fail("用户未登录");
|
||||
}
|
||||
|
||||
List<CouponClaimResult> results = sceneCouponService.claimCoupons(req, userId);
|
||||
|
||||
// 判断整体结果
|
||||
boolean hasSuccess = results.stream().anyMatch(CouponClaimResult::isSuccess);
|
||||
if (!hasSuccess && !results.isEmpty()) {
|
||||
// 全部失败,返回第一个错误信息
|
||||
return ApiResponse.fail(results.get(0).getErrorMessage());
|
||||
}
|
||||
|
||||
return ApiResponse.success(results);
|
||||
} catch (Exception e) {
|
||||
log.error("场景优惠券|领取失败 req={}", req, e);
|
||||
return ApiResponse.fail("领取失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户ID
|
||||
*/
|
||||
private Long getUserId() {
|
||||
try {
|
||||
String userIdStr = BaseContextHandler.getUserId();
|
||||
if (userIdStr == null || userIdStr.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return Long.valueOf(userIdStr);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("无法解析用户ID: {}", BaseContextHandler.getUserId());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.ycwl.basic.pricing.controller;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigPageReq;
|
||||
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigSaveReq;
|
||||
import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp;
|
||||
import com.ycwl.basic.pricing.service.ISceneCouponService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 场景优惠券配置管理控制器(后台管理端)
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/pricing/admin/scene-coupon")
|
||||
@RequiredArgsConstructor
|
||||
public class SceneCouponConfigController {
|
||||
|
||||
private final ISceneCouponService sceneCouponService;
|
||||
|
||||
/**
|
||||
* 分页查询场景优惠券配置
|
||||
*/
|
||||
@PostMapping("/page")
|
||||
public ApiResponse<PageInfo<SceneCouponConfigResp>> page(@RequestBody SceneCouponConfigPageReq req) {
|
||||
try {
|
||||
PageInfo<SceneCouponConfigResp> pageInfo = sceneCouponService.pageConfig(req);
|
||||
return ApiResponse.success(pageInfo);
|
||||
} catch (Exception e) {
|
||||
log.error("场景优惠券|分页查询失败", e);
|
||||
return ApiResponse.fail("分页查询失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置详情
|
||||
*/
|
||||
@GetMapping("/detail/{id}")
|
||||
public ApiResponse<SceneCouponConfigResp> getDetail(@PathVariable("id") Long id) {
|
||||
try {
|
||||
SceneCouponConfigResp detail = sceneCouponService.getConfigDetail(id);
|
||||
if (detail == null) {
|
||||
return ApiResponse.fail("记录不存在");
|
||||
}
|
||||
return ApiResponse.success(detail);
|
||||
} catch (Exception e) {
|
||||
log.error("场景优惠券|详情查询失败 id={}", id, e);
|
||||
return ApiResponse.fail("详情查询失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置(新增/更新)
|
||||
*/
|
||||
@PostMapping("/save")
|
||||
public ApiResponse<Boolean> save(@RequestBody SceneCouponConfigSaveReq req) {
|
||||
try {
|
||||
boolean success = sceneCouponService.saveConfig(req);
|
||||
return ApiResponse.success(success);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ApiResponse.fail(e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("场景优惠券|保存失败", e);
|
||||
return ApiResponse.fail("保存失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除配置
|
||||
*/
|
||||
@DeleteMapping("/delete/{id}")
|
||||
public ApiResponse<Boolean> delete(@PathVariable("id") Long id) {
|
||||
try {
|
||||
boolean success = sceneCouponService.deleteConfig(id);
|
||||
return ApiResponse.success(success);
|
||||
} catch (Exception e) {
|
||||
log.error("场景优惠券|删除失败 id={}", id, e);
|
||||
return ApiResponse.fail("删除失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已配置的场景列表(用于前端下拉选择)
|
||||
*/
|
||||
@GetMapping("/scenes")
|
||||
public ApiResponse<List<String>> listSceneKeys() {
|
||||
try {
|
||||
List<String> sceneKeys = sceneCouponService.listSceneKeys();
|
||||
return ApiResponse.success(sceneKeys);
|
||||
} catch (Exception e) {
|
||||
log.error("场景优惠券|场景列表查询失败", e);
|
||||
return ApiResponse.fail("场景列表查询失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据场景和景区查询配置列表
|
||||
*/
|
||||
@GetMapping("/by-scene")
|
||||
public ApiResponse<List<SceneCouponConfigResp>> listByScene(
|
||||
@RequestParam String sceneKey,
|
||||
@RequestParam Long scenicId) {
|
||||
try {
|
||||
List<SceneCouponConfigResp> list = sceneCouponService.listBySceneKeyAndScenicId(sceneKey, scenicId);
|
||||
return ApiResponse.success(list);
|
||||
} catch (Exception e) {
|
||||
log.error("场景优惠券|按场景查询失败 sceneKey={}, scenicId={}", sceneKey, scenicId, e);
|
||||
return ApiResponse.fail("按场景查询失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ycwl.basic.pricing.dto.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 场景优惠券领取请求
|
||||
*/
|
||||
@Data
|
||||
public class SceneCouponClaimReq {
|
||||
|
||||
/**
|
||||
* 场景标识符(必填)
|
||||
*/
|
||||
private String sceneKey;
|
||||
|
||||
/**
|
||||
* 景区ID(必填)
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 指定领取的优惠券ID(可选,不传则领取场景下所有可领取的优惠券)
|
||||
*/
|
||||
private Long couponId;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ycwl.basic.pricing.dto.req;
|
||||
|
||||
import com.ycwl.basic.model.common.BaseQueryParameterReq;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 场景优惠券配置分页查询请求
|
||||
*/
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
public class SceneCouponConfigPageReq extends BaseQueryParameterReq {
|
||||
|
||||
/**
|
||||
* 场景标识(模糊匹配)
|
||||
*/
|
||||
private String sceneKey;
|
||||
|
||||
/**
|
||||
* 景区ID;为空表示不筛选
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 优惠券ID
|
||||
*/
|
||||
private Long couponId;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ycwl.basic.pricing.dto.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 场景优惠券配置保存请求(新增/修改)
|
||||
*/
|
||||
@Data
|
||||
public class SceneCouponConfigSaveReq {
|
||||
|
||||
/**
|
||||
* 主键ID(为空表示新增;不为空表示更新)
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 场景标识符
|
||||
*/
|
||||
private String sceneKey;
|
||||
|
||||
/**
|
||||
* 关联的优惠券ID
|
||||
*/
|
||||
private Long couponId;
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
/**
|
||||
* 排序顺序(越小越靠前)
|
||||
*/
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.ycwl.basic.pricing.dto.resp;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 场景下可领取优惠券响应
|
||||
*/
|
||||
@Data
|
||||
public class SceneCouponAvailableResp {
|
||||
|
||||
/**
|
||||
* 优惠券ID
|
||||
*/
|
||||
private Long couponId;
|
||||
|
||||
/**
|
||||
* 优惠券名称
|
||||
*/
|
||||
private String couponName;
|
||||
|
||||
/**
|
||||
* 优惠券类型 (PERCENTAGE/FIXED_AMOUNT)
|
||||
*/
|
||||
private String couponType;
|
||||
|
||||
/**
|
||||
* 优惠值
|
||||
*/
|
||||
private BigDecimal discountValue;
|
||||
|
||||
/**
|
||||
* 最小使用金额
|
||||
*/
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/**
|
||||
* 最大优惠金额
|
||||
*/
|
||||
private BigDecimal maxDiscount;
|
||||
|
||||
/**
|
||||
* 有效期起始
|
||||
*/
|
||||
private Date validFrom;
|
||||
|
||||
/**
|
||||
* 有效期结束
|
||||
*/
|
||||
private Date validUntil;
|
||||
|
||||
/**
|
||||
* 用户可领取数量限制(每人最多可领,null或0表示无限制)
|
||||
*/
|
||||
private Integer userClaimLimit;
|
||||
|
||||
/**
|
||||
* 用户已领取数量
|
||||
*/
|
||||
private Integer userClaimedCount;
|
||||
|
||||
/**
|
||||
* 用户剩余可领取数量(-1表示无限制,0表示已达上限)
|
||||
*/
|
||||
private Integer userRemaining;
|
||||
|
||||
/**
|
||||
* 是否可领取(综合判断:库存、用户限制、有效期等)
|
||||
*/
|
||||
private Boolean canClaim;
|
||||
|
||||
/**
|
||||
* 不可领取原因(当 canClaim=false 时)
|
||||
*/
|
||||
private String cannotClaimReason;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ycwl.basic.pricing.dto.resp;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 场景优惠券配置响应(包含优惠券详情)
|
||||
*/
|
||||
@Data
|
||||
public class SceneCouponConfigResp {
|
||||
|
||||
// ========== 配置信息 ==========
|
||||
|
||||
private Long id;
|
||||
|
||||
private String sceneKey;
|
||||
|
||||
private Long couponId;
|
||||
|
||||
private Long scenicId;
|
||||
|
||||
private Integer enabled;
|
||||
|
||||
private Integer sortOrder;
|
||||
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
|
||||
// ========== 关联的优惠券信息 ==========
|
||||
|
||||
/**
|
||||
* 优惠券名称
|
||||
*/
|
||||
private String couponName;
|
||||
|
||||
/**
|
||||
* 优惠券类型 (PERCENTAGE/FIXED_AMOUNT)
|
||||
*/
|
||||
private String couponType;
|
||||
|
||||
/**
|
||||
* 优惠值
|
||||
*/
|
||||
private BigDecimal discountValue;
|
||||
|
||||
/**
|
||||
* 优惠券是否启用
|
||||
*/
|
||||
private Boolean couponActive;
|
||||
|
||||
/**
|
||||
* 优惠券有效期起始
|
||||
*/
|
||||
private Date couponValidFrom;
|
||||
|
||||
/**
|
||||
* 优惠券有效期结束
|
||||
*/
|
||||
private Date couponValidUntil;
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import com.ycwl.basic.pricing.enums.CouponType;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
@@ -80,12 +79,12 @@ public class PriceCouponConfig {
|
||||
/**
|
||||
* 生效时间
|
||||
*/
|
||||
private LocalDateTime validFrom;
|
||||
|
||||
private Date validFrom;
|
||||
|
||||
/**
|
||||
* 失效时间
|
||||
*/
|
||||
private LocalDateTime validUntil;
|
||||
private Date validUntil;
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ycwl.basic.pricing.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 场景-优惠券关联配置实体
|
||||
* <p>
|
||||
* 用于配置不同场景下可领取的优惠券,支持分景区配置。
|
||||
* scenicId=0 表示默认配置(所有景区通用),具体景区配置优先级高于默认配置。
|
||||
* </p>
|
||||
*/
|
||||
@Data
|
||||
@TableName("price_scene_coupon_config")
|
||||
public class PriceSceneCouponConfig {
|
||||
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 场景标识符(如 home_banner, checkout_page, new_user_popup)
|
||||
*/
|
||||
private String sceneKey;
|
||||
|
||||
/**
|
||||
* 关联的优惠券ID (price_coupon_config.id)
|
||||
*/
|
||||
private Long couponId;
|
||||
|
||||
/**
|
||||
* 景区ID;0=默认配置(所有景区通用)
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 是否启用:1启用 0禁用
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
/**
|
||||
* 排序顺序(越小越靠前)
|
||||
*/
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -17,44 +17,55 @@ import java.util.List;
|
||||
public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
|
||||
|
||||
/**
|
||||
* 查询有效的优惠券配置
|
||||
* 查询有效的优惠券配置(可领取的)
|
||||
*/
|
||||
@Select("SELECT * FROM price_coupon_config WHERE is_active = 1 " +
|
||||
"AND valid_from <= NOW() AND valid_until > NOW() " +
|
||||
"AND used_quantity < total_quantity")
|
||||
"AND (total_quantity IS NULL OR total_quantity <= 0 OR COALESCE(claimed_quantity, 0) < total_quantity)")
|
||||
List<PriceCouponConfig> selectValidCoupons();
|
||||
|
||||
|
||||
/**
|
||||
* 根据ID查询优惠券(包括使用数量检查)
|
||||
* 根据ID查询优惠券(包括库存检查)
|
||||
*/
|
||||
@Select("SELECT * FROM price_coupon_config WHERE id = #{couponId} " +
|
||||
"AND is_active = 1 AND valid_from <= NOW() AND valid_until > NOW() " +
|
||||
"AND used_quantity < total_quantity")
|
||||
"AND (total_quantity IS NULL OR total_quantity <= 0 OR COALESCE(claimed_quantity, 0) < total_quantity)")
|
||||
PriceCouponConfig selectValidCouponById(Long couponId);
|
||||
|
||||
/**
|
||||
* 增加优惠券使用数量
|
||||
* 增加优惠券使用数量(支持无限量优惠券)
|
||||
* 当 total_quantity 为 NULL 或 <= 0 时表示不限制使用数量
|
||||
*/
|
||||
@Update("UPDATE price_coupon_config SET used_quantity = used_quantity + 1, " +
|
||||
"update_time = NOW() WHERE id = #{couponId} AND used_quantity < total_quantity")
|
||||
@Update("UPDATE price_coupon_config SET used_quantity = COALESCE(used_quantity, 0) + 1, " +
|
||||
"update_time = NOW() WHERE id = #{couponId} " +
|
||||
"AND (total_quantity IS NULL OR total_quantity <= 0 OR COALESCE(used_quantity, 0) < total_quantity)")
|
||||
int incrementUsedQuantity(Long couponId);
|
||||
|
||||
/**
|
||||
* 原子性增加已领取数量(仅对有限库存的优惠券生效)
|
||||
* 原子性增加已领取数量(仅对有限库存的优惠券生效,带库存检查)
|
||||
*/
|
||||
@Update("UPDATE price_coupon_config SET claimed_quantity = COALESCE(claimed_quantity, 0) + 1, " +
|
||||
"update_time = NOW() WHERE id = #{couponId} AND total_quantity IS NOT NULL AND total_quantity > 0 " +
|
||||
"AND COALESCE(claimed_quantity, 0) < total_quantity")
|
||||
int incrementClaimedQuantityIfAvailable(@Param("couponId") Long couponId);
|
||||
|
||||
/**
|
||||
* 无条件增加已领取数量(用于无限量优惠券的领取统计)
|
||||
*/
|
||||
@Update("UPDATE price_coupon_config SET claimed_quantity = COALESCE(claimed_quantity, 0) + 1, " +
|
||||
"update_time = NOW() WHERE id = #{couponId}")
|
||||
int incrementClaimedQuantity(@Param("couponId") Long couponId);
|
||||
|
||||
/**
|
||||
* 插入优惠券配置
|
||||
*/
|
||||
@Insert("INSERT INTO price_coupon_config (coupon_name, coupon_type, discount_value, min_amount, " +
|
||||
"max_discount, applicable_products, required_attribute_keys, total_quantity, used_quantity, valid_from, valid_until, " +
|
||||
"max_discount, applicable_products, required_attribute_keys, total_quantity, used_quantity, " +
|
||||
"claimed_quantity, user_claim_limit, valid_from, valid_until, " +
|
||||
"is_active, scenic_id, create_time, update_time) VALUES " +
|
||||
"(#{couponName}, #{couponType}, #{discountValue}, #{minAmount}, #{maxDiscount}, " +
|
||||
"#{applicableProducts}, #{requiredAttributeKeys}, #{totalQuantity}, #{usedQuantity}, #{validFrom}, #{validUntil}, " +
|
||||
"#{applicableProducts}, #{requiredAttributeKeys}, #{totalQuantity}, #{usedQuantity}, " +
|
||||
"#{claimedQuantity}, #{userClaimLimit}, #{validFrom}, #{validUntil}, " +
|
||||
"#{isActive}, #{scenicId}, NOW(), NOW())")
|
||||
int insertCoupon(PriceCouponConfig coupon);
|
||||
|
||||
@@ -63,7 +74,8 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
|
||||
*/
|
||||
@Update("UPDATE price_coupon_config SET coupon_name = #{couponName}, coupon_type = #{couponType}, " +
|
||||
"discount_value = #{discountValue}, min_amount = #{minAmount}, max_discount = #{maxDiscount}, " +
|
||||
"applicable_products = #{applicableProducts}, required_attribute_keys = #{requiredAttributeKeys}, total_quantity = #{totalQuantity}, " +
|
||||
"applicable_products = #{applicableProducts}, required_attribute_keys = #{requiredAttributeKeys}, " +
|
||||
"total_quantity = #{totalQuantity}, user_claim_limit = #{userClaimLimit}, " +
|
||||
"valid_from = #{validFrom}, valid_until = #{validUntil}, is_active = #{isActive}, " +
|
||||
"scenic_id = #{scenicId}, update_time = NOW() WHERE id = #{id}")
|
||||
int updateCoupon(PriceCouponConfig coupon);
|
||||
@@ -117,11 +129,11 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
|
||||
int deleteCoupon(Long id);
|
||||
|
||||
/**
|
||||
* 查询指定景区的有效优惠券配置
|
||||
* 查询指定景区的有效优惠券配置(可领取的)
|
||||
*/
|
||||
@Select("SELECT * FROM price_coupon_config WHERE is_active = 1 " +
|
||||
"AND valid_from <= NOW() AND valid_until > NOW() " +
|
||||
"AND used_quantity < total_quantity " +
|
||||
"AND (total_quantity IS NULL OR total_quantity <= 0 OR COALESCE(claimed_quantity, 0) < total_quantity) " +
|
||||
"AND (scenic_id IS NULL OR scenic_id = #{scenicId})")
|
||||
List<PriceCouponConfig> selectValidCouponsByScenicId(@Param("scenicId") String scenicId);
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.ycwl.basic.pricing.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp;
|
||||
import com.ycwl.basic.pricing.entity.PriceSceneCouponConfig;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 场景优惠券配置Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface PriceSceneCouponConfigMapper extends BaseMapper<PriceSceneCouponConfig> {
|
||||
|
||||
/**
|
||||
* 查询场景下已启用的优惠券配置(带优惠券详情)
|
||||
* 用于前端领取接口
|
||||
*
|
||||
* @param sceneKey 场景标识
|
||||
* @param scenicId 景区ID
|
||||
* @return 配置列表(带优惠券信息)
|
||||
*/
|
||||
@Select("SELECT scc.id, scc.scene_key, scc.coupon_id, scc.scenic_id, scc.enabled, " +
|
||||
"scc.sort_order, scc.create_time, scc.update_time, " +
|
||||
"c.coupon_name, c.coupon_type, c.discount_value, " +
|
||||
"c.is_active AS coupon_active, c.valid_from AS coupon_valid_from, c.valid_until AS coupon_valid_until " +
|
||||
"FROM price_scene_coupon_config scc " +
|
||||
"JOIN price_coupon_config c ON scc.coupon_id = c.id AND c.deleted = 0 " +
|
||||
"WHERE scc.scene_key = #{sceneKey} AND scc.scenic_id = #{scenicId} " +
|
||||
"AND scc.enabled = 1 AND c.is_active = 1 " +
|
||||
"AND (c.valid_from IS NULL OR c.valid_from <= NOW()) " +
|
||||
"AND (c.valid_until IS NULL OR c.valid_until > NOW()) " +
|
||||
"ORDER BY scc.sort_order ASC, scc.id ASC")
|
||||
List<SceneCouponConfigResp> selectEnabledBySceneKeyAndScenicId(
|
||||
@Param("sceneKey") String sceneKey,
|
||||
@Param("scenicId") Long scenicId);
|
||||
|
||||
/**
|
||||
* 检查场景下是否存在已启用的配置(用于判断是否需要回退到默认)
|
||||
*
|
||||
* @param sceneKey 场景标识
|
||||
* @param scenicId 景区ID
|
||||
* @return 配置数量
|
||||
*/
|
||||
@Select("SELECT COUNT(*) FROM price_scene_coupon_config " +
|
||||
"WHERE scene_key = #{sceneKey} AND scenic_id = #{scenicId} AND enabled = 1")
|
||||
int countEnabledBySceneKeyAndScenicId(
|
||||
@Param("sceneKey") String sceneKey,
|
||||
@Param("scenicId") Long scenicId);
|
||||
|
||||
/**
|
||||
* 查询所有已配置的场景列表(去重)
|
||||
*
|
||||
* @return 场景标识列表
|
||||
*/
|
||||
@Select("SELECT DISTINCT scene_key FROM price_scene_coupon_config ORDER BY scene_key")
|
||||
List<String> selectDistinctSceneKeys();
|
||||
|
||||
/**
|
||||
* 管理端:带优惠券信息的分页查询
|
||||
*
|
||||
* @param sceneKey 场景标识(模糊匹配,可为null)
|
||||
* @param scenicId 景区ID(精确匹配,可为null)
|
||||
* @param couponId 优惠券ID(精确匹配,可为null)
|
||||
* @param enabled 启用状态(精确匹配,可为null)
|
||||
* @return 配置列表(带优惠券信息)
|
||||
*/
|
||||
@Select("<script>" +
|
||||
"SELECT scc.id, scc.scene_key, scc.coupon_id, scc.scenic_id, scc.enabled, " +
|
||||
"scc.sort_order, scc.create_time, scc.update_time, " +
|
||||
"c.coupon_name, c.coupon_type, c.discount_value, " +
|
||||
"c.is_active AS coupon_active, c.valid_from AS coupon_valid_from, c.valid_until AS coupon_valid_until " +
|
||||
"FROM price_scene_coupon_config scc " +
|
||||
"LEFT JOIN price_coupon_config c ON scc.coupon_id = c.id AND c.deleted = 0 " +
|
||||
"<where>" +
|
||||
"<if test='sceneKey != null and sceneKey != \"\"'>" +
|
||||
"AND scc.scene_key LIKE CONCAT('%', #{sceneKey}, '%') " +
|
||||
"</if>" +
|
||||
"<if test='scenicId != null'>" +
|
||||
"AND scc.scenic_id = #{scenicId} " +
|
||||
"</if>" +
|
||||
"<if test='couponId != null'>" +
|
||||
"AND scc.coupon_id = #{couponId} " +
|
||||
"</if>" +
|
||||
"<if test='enabled != null'>" +
|
||||
"AND scc.enabled = #{enabled} " +
|
||||
"</if>" +
|
||||
"</where>" +
|
||||
"ORDER BY scc.scenic_id DESC, scc.scene_key ASC, scc.sort_order ASC, scc.id DESC" +
|
||||
"</script>")
|
||||
List<SceneCouponConfigResp> selectPageWithCouponInfo(
|
||||
@Param("sceneKey") String sceneKey,
|
||||
@Param("scenicId") Long scenicId,
|
||||
@Param("couponId") Long couponId,
|
||||
@Param("enabled") Integer enabled);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.pricing.dto.CouponClaimResult;
|
||||
import com.ycwl.basic.pricing.dto.req.SceneCouponClaimReq;
|
||||
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigPageReq;
|
||||
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigSaveReq;
|
||||
import com.ycwl.basic.pricing.dto.resp.SceneCouponAvailableResp;
|
||||
import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 场景优惠券服务接口
|
||||
*/
|
||||
public interface ISceneCouponService {
|
||||
|
||||
// ==================== 后台管理接口 ====================
|
||||
|
||||
/**
|
||||
* 分页查询场景优惠券配置
|
||||
*
|
||||
* @param req 查询请求
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageInfo<SceneCouponConfigResp> pageConfig(SceneCouponConfigPageReq req);
|
||||
|
||||
/**
|
||||
* 获取配置详情
|
||||
*
|
||||
* @param id 配置ID
|
||||
* @return 配置详情
|
||||
*/
|
||||
SceneCouponConfigResp getConfigDetail(Long id);
|
||||
|
||||
/**
|
||||
* 保存配置(新增/更新)
|
||||
*
|
||||
* @param req 保存请求
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean saveConfig(SceneCouponConfigSaveReq req);
|
||||
|
||||
/**
|
||||
* 删除配置
|
||||
*
|
||||
* @param id 配置ID
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean deleteConfig(Long id);
|
||||
|
||||
/**
|
||||
* 获取所有已配置的场景列表
|
||||
*
|
||||
* @return 场景标识列表
|
||||
*/
|
||||
List<String> listSceneKeys();
|
||||
|
||||
/**
|
||||
* 根据场景和景区查询配置列表
|
||||
*
|
||||
* @param sceneKey 场景标识
|
||||
* @param scenicId 景区ID
|
||||
* @return 配置列表
|
||||
*/
|
||||
List<SceneCouponConfigResp> listBySceneKeyAndScenicId(String sceneKey, Long scenicId);
|
||||
|
||||
// ==================== 前端领取接口 ====================
|
||||
|
||||
/**
|
||||
* 查询场景下可领取的优惠券列表
|
||||
*
|
||||
* @param sceneKey 场景标识
|
||||
* @param scenicId 景区ID
|
||||
* @param userId 用户ID(用于判断用户已领取数量)
|
||||
* @return 可领取优惠券列表
|
||||
*/
|
||||
List<SceneCouponAvailableResp> getAvailableCoupons(String sceneKey, Long scenicId, Long userId);
|
||||
|
||||
/**
|
||||
* 领取场景优惠券
|
||||
*
|
||||
* @param req 领取请求
|
||||
* @param userId 用户ID
|
||||
* @return 领取结果列表
|
||||
*/
|
||||
List<CouponClaimResult> claimCoupons(SceneCouponClaimReq req, Long userId);
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import org.springframework.transaction.interceptor.TransactionAspectSupport;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -125,12 +124,17 @@ public class CouponServiceImpl implements ICouponService {
|
||||
List<String> applicableProductTypes = objectMapper.readValue(
|
||||
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
|
||||
|
||||
discountableProducts = products.stream()
|
||||
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
|
||||
.toList();
|
||||
// 空数组表示不限制商品类型,适用于所有商品
|
||||
if (applicableProductTypes == null || applicableProductTypes.isEmpty()) {
|
||||
// 不过滤,使用全部商品
|
||||
} else {
|
||||
discountableProducts = products.stream()
|
||||
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
|
||||
.toList();
|
||||
|
||||
if (discountableProducts.isEmpty()) {
|
||||
return false;
|
||||
if (discountableProducts.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("解析适用商品类型失败", e);
|
||||
@@ -198,6 +202,13 @@ public class CouponServiceImpl implements ICouponService {
|
||||
List<String> applicableProductTypes = objectMapper.readValue(
|
||||
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
|
||||
|
||||
// 空数组表示不限制商品类型,返回所有商品总价
|
||||
if (applicableProductTypes == null || applicableProductTypes.isEmpty()) {
|
||||
return products.stream()
|
||||
.map(ProductItem::getSubtotal)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
// 计算适用商品的总价
|
||||
return products.stream()
|
||||
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
|
||||
@@ -304,11 +315,11 @@ public class CouponServiceImpl implements ICouponService {
|
||||
}
|
||||
|
||||
// 4. 检查优惠券有效期
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (coupon.getValidFrom() != null && now.isBefore(coupon.getValidFrom())) {
|
||||
Date now = new Date();
|
||||
if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
|
||||
return CouponClaimResult.failure(CouponClaimResult.ERROR_COUPON_EXPIRED, "优惠券尚未生效");
|
||||
}
|
||||
if (coupon.getValidUntil() != null && now.isAfter(coupon.getValidUntil())) {
|
||||
if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
|
||||
return CouponClaimResult.failure(CouponClaimResult.ERROR_COUPON_EXPIRED, "优惠券已过期");
|
||||
}
|
||||
|
||||
@@ -354,17 +365,20 @@ public class CouponServiceImpl implements ICouponService {
|
||||
}
|
||||
|
||||
// 9. 更新优惠券已领取数量(区分于已使用数量)
|
||||
// 仅在有总量限制时才更新claimedQuantity(totalQuantity为正整数)
|
||||
if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) {
|
||||
// 有总量限制:使用带库存检查的原子更新
|
||||
int affected = couponConfigMapper.incrementClaimedQuantityIfAvailable(coupon.getId());
|
||||
if (affected == 0) {
|
||||
throw new CouponInvalidException(
|
||||
CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK,
|
||||
"优惠券已被领取完,请稍后重试");
|
||||
}
|
||||
int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1;
|
||||
coupon.setClaimedQuantity(updatedClaimedQuantity);
|
||||
} else {
|
||||
// 无总量限制:无条件增加已领取数量(用于统计)
|
||||
couponConfigMapper.incrementClaimedQuantity(coupon.getId());
|
||||
}
|
||||
int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1;
|
||||
coupon.setClaimedQuantity(updatedClaimedQuantity);
|
||||
|
||||
log.info("优惠券领取成功: userId={}, couponId={}, claimRecordId={}",
|
||||
request.getUserId(), request.getCouponId(), claimRecord.getId());
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package com.ycwl.basic.pricing.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.model.common.BaseQueryParameterReq;
|
||||
import com.ycwl.basic.pricing.dto.CouponClaimRequest;
|
||||
import com.ycwl.basic.pricing.dto.CouponClaimResult;
|
||||
import com.ycwl.basic.pricing.dto.req.SceneCouponClaimReq;
|
||||
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigPageReq;
|
||||
import com.ycwl.basic.pricing.dto.req.SceneCouponConfigSaveReq;
|
||||
import com.ycwl.basic.pricing.dto.resp.SceneCouponAvailableResp;
|
||||
import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp;
|
||||
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
|
||||
import com.ycwl.basic.pricing.entity.PriceSceneCouponConfig;
|
||||
import com.ycwl.basic.pricing.mapper.PriceCouponClaimRecordMapper;
|
||||
import com.ycwl.basic.pricing.mapper.PriceCouponConfigMapper;
|
||||
import com.ycwl.basic.pricing.mapper.PriceSceneCouponConfigMapper;
|
||||
import com.ycwl.basic.pricing.service.ICouponService;
|
||||
import com.ycwl.basic.pricing.service.ISceneCouponService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 场景优惠券服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SceneCouponServiceImpl implements ISceneCouponService {
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 200;
|
||||
private static final Long DEFAULT_SCENIC_ID = 0L;
|
||||
|
||||
private final PriceSceneCouponConfigMapper sceneCouponConfigMapper;
|
||||
private final PriceCouponConfigMapper couponConfigMapper;
|
||||
private final PriceCouponClaimRecordMapper claimRecordMapper;
|
||||
private final ICouponService couponService;
|
||||
|
||||
// ==================== 后台管理接口实现 ====================
|
||||
|
||||
@Override
|
||||
public PageInfo<SceneCouponConfigResp> pageConfig(SceneCouponConfigPageReq req) {
|
||||
if (req == null) {
|
||||
req = new SceneCouponConfigPageReq();
|
||||
}
|
||||
sanitizePage(req);
|
||||
|
||||
PageHelper.startPage(req.getPageNum(), req.getPageSize());
|
||||
List<SceneCouponConfigResp> list = sceneCouponConfigMapper.selectPageWithCouponInfo(
|
||||
req.getSceneKey(), req.getScenicId(), req.getCouponId(), req.getEnabled());
|
||||
return new PageInfo<>(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SceneCouponConfigResp getConfigDetail(Long id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
PriceSceneCouponConfig config = sceneCouponConfigMapper.selectById(id);
|
||||
if (config == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SceneCouponConfigResp resp = new SceneCouponConfigResp();
|
||||
resp.setId(config.getId());
|
||||
resp.setSceneKey(config.getSceneKey());
|
||||
resp.setCouponId(config.getCouponId());
|
||||
resp.setScenicId(config.getScenicId());
|
||||
resp.setEnabled(config.getEnabled());
|
||||
resp.setSortOrder(config.getSortOrder());
|
||||
resp.setCreateTime(config.getCreateTime());
|
||||
resp.setUpdateTime(config.getUpdateTime());
|
||||
|
||||
// 填充优惠券信息
|
||||
PriceCouponConfig coupon = couponConfigMapper.selectById(config.getCouponId());
|
||||
if (coupon != null) {
|
||||
resp.setCouponName(coupon.getCouponName());
|
||||
resp.setCouponType(coupon.getCouponType() != null ? coupon.getCouponType().name() : null);
|
||||
resp.setDiscountValue(coupon.getDiscountValue());
|
||||
resp.setCouponActive(coupon.getIsActive());
|
||||
resp.setCouponValidFrom(coupon.getValidFrom());
|
||||
resp.setCouponValidUntil(coupon.getValidUntil());
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean saveConfig(SceneCouponConfigSaveReq req) {
|
||||
String err = validateSaveReq(req);
|
||||
if (err != null) {
|
||||
throw new IllegalArgumentException(err);
|
||||
}
|
||||
|
||||
PriceSceneCouponConfig entity = new PriceSceneCouponConfig();
|
||||
entity.setSceneKey(req.getSceneKey().trim());
|
||||
entity.setCouponId(req.getCouponId());
|
||||
entity.setScenicId(req.getScenicId());
|
||||
entity.setEnabled(req.getEnabled());
|
||||
entity.setSortOrder(Objects.requireNonNullElse(req.getSortOrder(), 0));
|
||||
entity.setUpdateTime(new Date());
|
||||
|
||||
try {
|
||||
if (req.getId() != null) {
|
||||
// 更新
|
||||
PriceSceneCouponConfig existing = sceneCouponConfigMapper.selectById(req.getId());
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("记录不存在");
|
||||
}
|
||||
entity.setId(req.getId());
|
||||
return sceneCouponConfigMapper.updateById(entity) > 0;
|
||||
}
|
||||
|
||||
// 新增前检查唯一性
|
||||
PriceSceneCouponConfig existing = sceneCouponConfigMapper.selectOne(
|
||||
new QueryWrapper<PriceSceneCouponConfig>()
|
||||
.eq("scene_key", entity.getSceneKey())
|
||||
.eq("coupon_id", entity.getCouponId())
|
||||
.eq("scenic_id", entity.getScenicId()));
|
||||
if (existing != null) {
|
||||
// 已存在则更新
|
||||
entity.setId(existing.getId());
|
||||
return sceneCouponConfigMapper.updateById(entity) > 0;
|
||||
}
|
||||
|
||||
entity.setCreateTime(new Date());
|
||||
return sceneCouponConfigMapper.insert(entity) > 0;
|
||||
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw new IllegalArgumentException("保存失败:配置已存在(sceneKey+couponId+scenicId重复)");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteConfig(Long id) {
|
||||
if (id == null) {
|
||||
return false;
|
||||
}
|
||||
return sceneCouponConfigMapper.deleteById(id) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> listSceneKeys() {
|
||||
return sceneCouponConfigMapper.selectDistinctSceneKeys();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SceneCouponConfigResp> listBySceneKeyAndScenicId(String sceneKey, Long scenicId) {
|
||||
return sceneCouponConfigMapper.selectPageWithCouponInfo(sceneKey, scenicId, null, null);
|
||||
}
|
||||
|
||||
// ==================== 前端领取接口实现 ====================
|
||||
|
||||
@Override
|
||||
public List<SceneCouponAvailableResp> getAvailableCoupons(String sceneKey, Long scenicId, Long userId) {
|
||||
if (sceneKey == null || sceneKey.isBlank() || scenicId == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 景区隔离查询:先查具体景区,为空则回退到默认配置
|
||||
List<SceneCouponConfigResp> configs = getConfigsWithFallback(sceneKey, scenicId);
|
||||
if (configs.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
List<SceneCouponAvailableResp> result = new ArrayList<>();
|
||||
for (SceneCouponConfigResp config : configs) {
|
||||
SceneCouponAvailableResp resp = buildAvailableResp(config, userId);
|
||||
result.add(resp);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public List<CouponClaimResult> claimCoupons(SceneCouponClaimReq req, Long userId) {
|
||||
if (req.getSceneKey() == null || req.getSceneKey().isBlank() || req.getScenicId() == null) {
|
||||
return List.of(CouponClaimResult.failure(CouponClaimResult.ERROR_INVALID_PARAMS, "sceneKey和scenicId不能为空"));
|
||||
}
|
||||
|
||||
// 获取场景下的优惠券配置
|
||||
List<SceneCouponConfigResp> configs = getConfigsWithFallback(req.getSceneKey(), req.getScenicId());
|
||||
if (configs.isEmpty()) {
|
||||
return List.of(CouponClaimResult.failure("SCENE_NOT_FOUND", "该场景暂无可领取的优惠券"));
|
||||
}
|
||||
|
||||
// 如果指定了couponId,只领取指定的
|
||||
if (req.getCouponId() != null) {
|
||||
configs = configs.stream()
|
||||
.filter(c -> req.getCouponId().equals(c.getCouponId()))
|
||||
.toList();
|
||||
if (configs.isEmpty()) {
|
||||
return List.of(CouponClaimResult.failure("COUPON_NOT_IN_SCENE", "指定的优惠券不在该场景中"));
|
||||
}
|
||||
}
|
||||
|
||||
// 调用现有的领取服务
|
||||
List<CouponClaimResult> results = new ArrayList<>();
|
||||
for (SceneCouponConfigResp config : configs) {
|
||||
CouponClaimRequest claimReq = new CouponClaimRequest();
|
||||
claimReq.setUserId(userId);
|
||||
claimReq.setCouponId(config.getCouponId());
|
||||
claimReq.setScenicId(String.valueOf(req.getScenicId()));
|
||||
claimReq.setClaimSource("scene:" + req.getSceneKey());
|
||||
|
||||
CouponClaimResult claimResult = couponService.claimCoupon(claimReq);
|
||||
results.add(claimResult);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/**
|
||||
* 带景区回退的配置查询
|
||||
* 如果景区有配置(哪怕只有一条),则使用景区的配置;
|
||||
* 如果景区没有配置,则使用默认景区(scenicId=0)的配置。
|
||||
*/
|
||||
private List<SceneCouponConfigResp> getConfigsWithFallback(String sceneKey, Long scenicId) {
|
||||
// 1. 先查具体景区是否有配置
|
||||
int count = sceneCouponConfigMapper.countEnabledBySceneKeyAndScenicId(sceneKey, scenicId);
|
||||
if (count > 0) {
|
||||
// 景区有配置,使用景区的配置
|
||||
return sceneCouponConfigMapper.selectEnabledBySceneKeyAndScenicId(sceneKey, scenicId);
|
||||
}
|
||||
|
||||
// 2. 景区没有配置,回退到默认配置 (scenicId=0)
|
||||
return sceneCouponConfigMapper.selectEnabledBySceneKeyAndScenicId(sceneKey, DEFAULT_SCENIC_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可领取优惠券响应
|
||||
*/
|
||||
private SceneCouponAvailableResp buildAvailableResp(SceneCouponConfigResp config, Long userId) {
|
||||
SceneCouponAvailableResp resp = new SceneCouponAvailableResp();
|
||||
|
||||
// 查询优惠券详情
|
||||
PriceCouponConfig coupon = couponConfigMapper.selectById(config.getCouponId());
|
||||
if (coupon == null) {
|
||||
resp.setCouponId(config.getCouponId());
|
||||
resp.setCanClaim(false);
|
||||
resp.setCannotClaimReason("优惠券不存在");
|
||||
return resp;
|
||||
}
|
||||
|
||||
resp.setCouponId(coupon.getId());
|
||||
resp.setCouponName(coupon.getCouponName());
|
||||
resp.setCouponType(coupon.getCouponType() != null ? coupon.getCouponType().name() : null);
|
||||
resp.setDiscountValue(coupon.getDiscountValue());
|
||||
resp.setMinAmount(coupon.getMinAmount());
|
||||
resp.setMaxDiscount(coupon.getMaxDiscount());
|
||||
resp.setValidFrom(coupon.getValidFrom());
|
||||
resp.setValidUntil(coupon.getValidUntil());
|
||||
resp.setUserClaimLimit(coupon.getUserClaimLimit());
|
||||
|
||||
// 查询用户已领取数量
|
||||
int userClaimedCount = 0;
|
||||
if (userId != null) {
|
||||
userClaimedCount = claimRecordMapper.countUserCouponClaims(userId, coupon.getId());
|
||||
}
|
||||
resp.setUserClaimedCount(userClaimedCount);
|
||||
|
||||
// 计算剩余可领取数量
|
||||
int userRemaining;
|
||||
if (coupon.getUserClaimLimit() == null || coupon.getUserClaimLimit() <= 0) {
|
||||
userRemaining = -1; // -1表示无限制
|
||||
} else {
|
||||
userRemaining = Math.max(0, coupon.getUserClaimLimit() - userClaimedCount);
|
||||
}
|
||||
resp.setUserRemaining(userRemaining);
|
||||
|
||||
// 综合判断是否可领取
|
||||
String cannotClaimReason = checkCanClaim(coupon, userClaimedCount);
|
||||
resp.setCanClaim(cannotClaimReason == null);
|
||||
resp.setCannotClaimReason(cannotClaimReason);
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可领取
|
||||
*
|
||||
* @return null表示可领取,否则返回不可领取原因
|
||||
*/
|
||||
private String checkCanClaim(PriceCouponConfig coupon, int userClaimedCount) {
|
||||
// 1. 检查启用状态
|
||||
if (!Boolean.TRUE.equals(coupon.getIsActive())) {
|
||||
return "优惠券已停用";
|
||||
}
|
||||
|
||||
// 2. 检查有效期
|
||||
Date now = new Date();
|
||||
if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
|
||||
return "优惠券尚未生效";
|
||||
}
|
||||
if (coupon.getValidUntil() != null && now.after(coupon.getValidUntil())) {
|
||||
return "优惠券已过期";
|
||||
}
|
||||
|
||||
// 3. 检查库存
|
||||
if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) {
|
||||
int claimed = coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity();
|
||||
if (claimed >= coupon.getTotalQuantity()) {
|
||||
return "优惠券已领完";
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查用户领取限制
|
||||
if (coupon.getUserClaimLimit() != null && coupon.getUserClaimLimit() > 0) {
|
||||
if (userClaimedCount >= coupon.getUserClaimLimit()) {
|
||||
return "您已达到领取上限";
|
||||
}
|
||||
}
|
||||
|
||||
return null; // 可领取
|
||||
}
|
||||
|
||||
private static void sanitizePage(BaseQueryParameterReq req) {
|
||||
if (req.getPageNum() == null || req.getPageNum() < 1) {
|
||||
req.setPageNum(1);
|
||||
}
|
||||
if (req.getPageSize() == null || req.getPageSize() < 1) {
|
||||
req.setPageSize(10);
|
||||
}
|
||||
if (req.getPageSize() > MAX_PAGE_SIZE) {
|
||||
req.setPageSize(MAX_PAGE_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
private static String validateSaveReq(SceneCouponConfigSaveReq req) {
|
||||
if (req == null) {
|
||||
return "请求体不能为空";
|
||||
}
|
||||
if (req.getSceneKey() == null || req.getSceneKey().isBlank()) {
|
||||
return "sceneKey不能为空";
|
||||
}
|
||||
if (req.getCouponId() == null) {
|
||||
return "couponId不能为空";
|
||||
}
|
||||
if (req.getScenicId() == null) {
|
||||
return "scenicId不能为空";
|
||||
}
|
||||
if (req.getEnabled() == null || (req.getEnabled() != 0 && req.getEnabled() != 1)) {
|
||||
return "enabled必须为0或1";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -88,11 +88,6 @@ public class PuzzleTemplateDTO {
|
||||
*/
|
||||
private Integer canPrint;
|
||||
|
||||
/**
|
||||
* 用户查看区域(裁切区域),格式:x,y,w,h
|
||||
*/
|
||||
private String userArea;
|
||||
|
||||
/**
|
||||
* 元素列表
|
||||
*/
|
||||
|
||||
@@ -71,11 +71,6 @@ public class TemplateCreateRequest {
|
||||
*/
|
||||
private Integer canPrint;
|
||||
|
||||
/**
|
||||
* 用户查看区域(裁切区域),格式:x,y,w,h
|
||||
*/
|
||||
private String userArea;
|
||||
|
||||
/**
|
||||
* 状态:0-禁用 1-启用
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskFailRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskSuccessRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeUploadUrlsResponse;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerAuthRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncRequest;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncResponse;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
@@ -36,9 +35,8 @@ public class PuzzleEdgeRenderTaskController {
|
||||
}
|
||||
|
||||
@PostMapping("/task/{taskId}/uploadUrls")
|
||||
public ApiResponse<PuzzleEdgeUploadUrlsResponse> uploadUrls(@PathVariable Long taskId,
|
||||
@RequestBody PuzzleEdgeWorkerAuthRequest req) {
|
||||
return ApiResponse.success(puzzleEdgeRenderTaskService.getUploadUrls(taskId, req != null ? req.getAccessKey() : null));
|
||||
public ApiResponse<PuzzleEdgeUploadUrlsResponse> uploadUrls(@PathVariable Long taskId) {
|
||||
return ApiResponse.success(puzzleEdgeRenderTaskService.getUploadUrls(taskId));
|
||||
}
|
||||
|
||||
@PostMapping("/task/{taskId}/success")
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.edge.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PuzzleEdgeWorkerAuthRequest {
|
||||
private String accessKey;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,18 @@ public class PuzzleEdgeRenderTaskEntity {
|
||||
@TableField("face_id")
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 任务类型:PUZZLE-原始拼图 WATERMARK-水印拼图
|
||||
*/
|
||||
@TableField("task_type")
|
||||
private String taskType;
|
||||
|
||||
/**
|
||||
* 水印类型(仅task_type=WATERMARK时有效)
|
||||
*/
|
||||
@TableField("watermark_type")
|
||||
private String watermarkType;
|
||||
|
||||
@TableField("content_hash")
|
||||
private String contentHash;
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ public class PuzzleEdgeWorkerIpInterceptor implements HandlerInterceptor {
|
||||
if (Ipv4CidrMatcher.matches(clientIp, properties.getAllowedIpCidr())) {
|
||||
return true;
|
||||
}
|
||||
if (Ipv4CidrMatcher.matches(clientIp, "127.0.0.1/8")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
log.warn("拒绝边缘 Worker 请求: uri={}, ip={}, allowedIpCidr={}",
|
||||
request != null ? request.getRequestURI() : null,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.edge.mapper;
|
||||
|
||||
import com.ycwl.basic.puzzle.edge.entity.PuzzleEdgeRenderTaskEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Mapper
|
||||
public interface PuzzleEdgeRenderTaskMapper {
|
||||
|
||||
PuzzleEdgeRenderTaskEntity getById(@Param("id") Long id);
|
||||
|
||||
int insert(PuzzleEdgeRenderTaskEntity entity);
|
||||
|
||||
/**
|
||||
* 获取下一条可领取任务ID:PENDING 或 RUNNING但租约已过期
|
||||
*/
|
||||
Long findNextClaimableTaskId();
|
||||
|
||||
/**
|
||||
* 领取任务(并写入租约与attempt)
|
||||
*/
|
||||
int claimTask(@Param("taskId") Long taskId,
|
||||
@Param("workerId") Long workerId,
|
||||
@Param("leaseExpireTime") Date leaseExpireTime);
|
||||
|
||||
int markSuccess(@Param("taskId") Long taskId, @Param("workerId") Long workerId);
|
||||
|
||||
int markFail(@Param("taskId") Long taskId,
|
||||
@Param("workerId") Long workerId,
|
||||
@Param("errorMessage") String errorMessage);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.ycwl.basic.model.pc.renderWorker.entity.RenderWorkerEntity;
|
||||
import com.ycwl.basic.model.pc.puzzle.entity.PuzzleWatermarkEntity;
|
||||
import com.ycwl.basic.model.task.req.ClientStatusReqVo;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeRenderTaskDTO;
|
||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskFailRequest;
|
||||
@@ -17,8 +17,9 @@ import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleWatermarkMapper;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.repository.RenderWorkerRepository;
|
||||
import com.ycwl.basic.service.pc.processor.PuzzleRelationProcessor;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||
@@ -56,6 +57,15 @@ public class PuzzleEdgeRenderTaskService {
|
||||
private static final int STATUS_SUCCESS = 2;
|
||||
private static final int STATUS_FAIL = 3;
|
||||
|
||||
/**
|
||||
* 任务类型:原始拼图(成功后更新 puzzle_generation_record)
|
||||
*/
|
||||
public static final String TASK_TYPE_PUZZLE = "PUZZLE";
|
||||
/**
|
||||
* 任务类型:水印拼图(成功后写入 puzzle_watermark)
|
||||
*/
|
||||
public static final String TASK_TYPE_WATERMARK = "WATERMARK";
|
||||
|
||||
private static final int MAX_SYNC_TASKS = 5;
|
||||
private static final long LEASE_MILLIS = TimeUnit.SECONDS.toMillis(20);
|
||||
private static final long UPLOAD_URL_EXPIRE_MILLIS = TimeUnit.HOURS.toMillis(1);
|
||||
@@ -134,16 +144,25 @@ public class PuzzleEdgeRenderTaskService {
|
||||
private final ConcurrentHashMap<Long, WaitFutureEntry> waitFutures = new ConcurrentHashMap<>();
|
||||
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
private final PuzzleWatermarkMapper puzzleWatermarkMapper;
|
||||
private final PuzzleRepository puzzleRepository;
|
||||
private final PrinterService printerService;
|
||||
private final RenderWorkerRepository renderWorkerRepository;
|
||||
private final PuzzleRelationProcessor puzzleRelationProcessor;
|
||||
|
||||
/**
|
||||
* 固定的 workerId,用于标识通过 IP CIDR 验证的 worker
|
||||
* 由于 IP 验证已在拦截器层完成,此处不再区分具体 worker
|
||||
*/
|
||||
private static final Long DEFAULT_WORKER_ID = 1L;
|
||||
|
||||
public PuzzleEdgeWorkerSyncResponse sync(PuzzleEdgeWorkerSyncRequest req) {
|
||||
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
|
||||
// IP 验证已在拦截器层完成,此处无需验证 accessKey
|
||||
Long workerId = DEFAULT_WORKER_ID;
|
||||
|
||||
// 客户端状态上报(可选,不再关联具体 worker)
|
||||
ClientStatusReqVo clientStatus = req != null ? req.getClientStatus() : null;
|
||||
if (clientStatus != null) {
|
||||
renderWorkerRepository.setWorkerHostStatus(worker.getId(), clientStatus);
|
||||
log.debug("收到客户端状态上报: {}", clientStatus);
|
||||
}
|
||||
|
||||
int maxTasks = req != null && req.getMaxTasks() != null ? req.getMaxTasks() : 1;
|
||||
@@ -156,12 +175,12 @@ public class PuzzleEdgeRenderTaskService {
|
||||
|
||||
PuzzleEdgeWorkerSyncResponse resp = new PuzzleEdgeWorkerSyncResponse();
|
||||
for (int i = 0; i < maxTasks; i++) {
|
||||
PuzzleEdgeRenderTaskEntity task = claimOne(worker.getId());
|
||||
PuzzleEdgeRenderTaskEntity task = claimOne(workerId);
|
||||
if (task == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
PuzzleEdgeRenderTaskDTO dto = toTaskDTOOrFail(task, worker.getId());
|
||||
PuzzleEdgeRenderTaskDTO dto = toTaskDTOOrFail(task, workerId);
|
||||
if (dto != null) {
|
||||
resp.getTasks().add(dto);
|
||||
}
|
||||
@@ -170,15 +189,17 @@ public class PuzzleEdgeRenderTaskService {
|
||||
return resp;
|
||||
}
|
||||
|
||||
public PuzzleEdgeUploadUrlsResponse getUploadUrls(Long taskId, String accessKey) {
|
||||
RenderWorkerEntity worker = requireWorker(accessKey);
|
||||
PuzzleEdgeRenderTaskEntity task = getAndCheckRunningTask(taskId, worker.getId());
|
||||
public PuzzleEdgeUploadUrlsResponse getUploadUrls(Long taskId) {
|
||||
// IP 验证已在拦截器层完成,此处无需验证 accessKey
|
||||
Long workerId = DEFAULT_WORKER_ID;
|
||||
PuzzleEdgeRenderTaskEntity task = getAndCheckRunningTask(taskId, workerId);
|
||||
return buildUploadUrls(task);
|
||||
}
|
||||
|
||||
public void taskSuccess(Long taskId, PuzzleEdgeTaskSuccessRequest req) {
|
||||
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
|
||||
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, worker.getId());
|
||||
// IP 验证已在拦截器层完成,此处无需验证 accessKey
|
||||
Long workerId = DEFAULT_WORKER_ID;
|
||||
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, workerId);
|
||||
if (task.getStatus() != null && task.getStatus() == STATUS_SUCCESS) {
|
||||
return;
|
||||
}
|
||||
@@ -186,22 +207,39 @@ public class PuzzleEdgeRenderTaskService {
|
||||
throw new IllegalArgumentException("任务状态非法");
|
||||
}
|
||||
|
||||
boolean updated = tryMarkSuccess(task, worker.getId());
|
||||
boolean updated = tryMarkSuccess(task, workerId);
|
||||
if (!updated) {
|
||||
throw new IllegalStateException("任务状态更新失败");
|
||||
}
|
||||
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(task.getRecordId());
|
||||
if (record == null) {
|
||||
log.warn("边缘渲染任务回报成功,但生成记录不存在: taskId={}, recordId={}", taskId, task.getRecordId());
|
||||
return;
|
||||
IStorageAdapter storage = StorageFactory.use();
|
||||
String resultImageUrl = storage.getUrl(task.getOriginalObjectKey());
|
||||
|
||||
// 根据任务类型决定写入哪个表
|
||||
String taskType = task.getTaskType();
|
||||
if (TASK_TYPE_WATERMARK.equals(taskType)) {
|
||||
// 水印拼图任务:写入 puzzle_watermark 表
|
||||
handleWatermarkTaskSuccess(task, resultImageUrl);
|
||||
} else {
|
||||
// 原始拼图任务(默认):更新 puzzle_generation_record 表
|
||||
handlePuzzleTaskSuccess(task, req, resultImageUrl);
|
||||
}
|
||||
|
||||
IStorageAdapter storage = StorageFactory.use();
|
||||
String originalImageUrl = storage.getUrl(task.getOriginalObjectKey());
|
||||
String resultImageUrl = StrUtil.isNotBlank(task.getCroppedObjectKey())
|
||||
? storage.getUrl(task.getCroppedObjectKey())
|
||||
: originalImageUrl;
|
||||
// 通知等待方任务完成
|
||||
completeWaitFuture(taskId, TaskWaitResult.success(resultImageUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理原始拼图任务成功
|
||||
*/
|
||||
private void handlePuzzleTaskSuccess(PuzzleEdgeRenderTaskEntity task,
|
||||
PuzzleEdgeTaskSuccessRequest req,
|
||||
String resultImageUrl) {
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(task.getRecordId());
|
||||
if (record == null) {
|
||||
log.warn("边缘渲染任务回报成功,但生成记录不存在: taskId={}, recordId={}", task.getId(), task.getRecordId());
|
||||
return;
|
||||
}
|
||||
|
||||
Long resultFileSize = req != null ? req.getResultFileSize() : null;
|
||||
Integer resultWidth = req != null ? req.getResultWidth() : null;
|
||||
@@ -211,15 +249,22 @@ public class PuzzleEdgeRenderTaskService {
|
||||
recordMapper.updateSuccess(
|
||||
record.getId(),
|
||||
resultImageUrl,
|
||||
originalImageUrl,
|
||||
resultFileSize,
|
||||
resultWidth,
|
||||
resultHeight,
|
||||
renderDurationMs
|
||||
);
|
||||
|
||||
// 通知等待方任务完成
|
||||
completeWaitFuture(taskId, TaskWaitResult.success(resultImageUrl));
|
||||
// 清除生成记录缓存(状态已更新)
|
||||
puzzleRepository.clearRecordCache(record.getId(), record.getFaceId());
|
||||
|
||||
// 创建member_puzzle关联记录(使用INSERT IGNORE避免重复)
|
||||
puzzleRelationProcessor.createPuzzleRelation(
|
||||
record.getUserId(),
|
||||
record.getScenicId(),
|
||||
record.getFaceId(),
|
||||
record.getId()
|
||||
);
|
||||
|
||||
PuzzleTemplateEntity template = puzzleRepository.getTemplateById(task.getTemplateId());
|
||||
if (template != null && template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
|
||||
@@ -228,8 +273,8 @@ public class PuzzleEdgeRenderTaskService {
|
||||
record.getUserId(),
|
||||
record.getScenicId(),
|
||||
record.getFaceId(),
|
||||
originalImageUrl,
|
||||
record.getId()
|
||||
resultImageUrl,
|
||||
record.getId() // 拼图记录ID,用于关联 puzzle_generation_record 表
|
||||
);
|
||||
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
|
||||
} catch (Exception e) {
|
||||
@@ -238,9 +283,30 @@ public class PuzzleEdgeRenderTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理水印拼图任务成功
|
||||
*/
|
||||
private void handleWatermarkTaskSuccess(PuzzleEdgeRenderTaskEntity task, String resultImageUrl) {
|
||||
PuzzleWatermarkEntity watermark = new PuzzleWatermarkEntity();
|
||||
watermark.setRecordId(task.getRecordId());
|
||||
watermark.setFaceId(task.getFaceId());
|
||||
watermark.setWatermarkType(task.getWatermarkType());
|
||||
watermark.setWatermarkUrl(resultImageUrl);
|
||||
|
||||
try {
|
||||
puzzleWatermarkMapper.insert(watermark);
|
||||
log.info("水印拼图任务成功,已写入puzzle_watermark: taskId={}, recordId={}, watermarkType={}",
|
||||
task.getId(), task.getRecordId(), task.getWatermarkType());
|
||||
} catch (Exception e) {
|
||||
log.error("水印拼图任务成功,但写入puzzle_watermark失败: taskId={}, recordId={}",
|
||||
task.getId(), task.getRecordId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void taskFail(Long taskId, PuzzleEdgeTaskFailRequest req) {
|
||||
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
|
||||
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, worker.getId());
|
||||
// IP 验证已在拦截器层完成,此处无需验证 accessKey
|
||||
Long workerId = DEFAULT_WORKER_ID;
|
||||
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, workerId);
|
||||
if (task.getStatus() != null && task.getStatus() == STATUS_FAIL) {
|
||||
return;
|
||||
}
|
||||
@@ -252,12 +318,15 @@ public class PuzzleEdgeRenderTaskService {
|
||||
? req.getErrorMessage()
|
||||
: "边缘渲染失败";
|
||||
|
||||
boolean updated = tryMarkFail(task, worker.getId(), errorMessage);
|
||||
boolean updated = tryMarkFail(task, workerId, errorMessage);
|
||||
if (!updated) {
|
||||
throw new IllegalStateException("任务状态更新失败");
|
||||
}
|
||||
recordMapper.updateFail(task.getRecordId(), errorMessage);
|
||||
|
||||
// 清除生成记录缓存(状态已更新)
|
||||
puzzleRepository.clearRecordCache(task.getRecordId(), task.getFaceId());
|
||||
|
||||
// 通知等待方任务失败
|
||||
completeWaitFuture(taskId, TaskWaitResult.fail(errorMessage));
|
||||
}
|
||||
@@ -273,6 +342,7 @@ public class PuzzleEdgeRenderTaskService {
|
||||
List<Long> retryRecordIds = new ArrayList<>();
|
||||
Map<Long, String> failRecordMessages = new HashMap<>();
|
||||
Map<Long, String> failTaskMessages = new HashMap<>(); // taskId -> errorMessage
|
||||
Map<Long, Long> failRecordFaceIds = new HashMap<>(); // recordId -> faceId,用于缓存清除
|
||||
|
||||
synchronized (taskLock) {
|
||||
long now = System.currentTimeMillis();
|
||||
@@ -303,6 +373,7 @@ public class PuzzleEdgeRenderTaskService {
|
||||
task.setUpdateTime(new Date(now));
|
||||
if (task.getRecordId() != null) {
|
||||
failRecordMessages.put(task.getRecordId(), errorMessage);
|
||||
failRecordFaceIds.put(task.getRecordId(), task.getFaceId());
|
||||
}
|
||||
// 记录需要通知的任务
|
||||
failTaskMessages.put(task.getId(), errorMessage);
|
||||
@@ -329,6 +400,9 @@ public class PuzzleEdgeRenderTaskService {
|
||||
|
||||
for (Map.Entry<Long, String> entry : failRecordMessages.entrySet()) {
|
||||
recordMapper.updateFail(entry.getKey(), entry.getValue());
|
||||
// 清除生成记录缓存
|
||||
Long faceId = failRecordFaceIds.get(entry.getKey());
|
||||
puzzleRepository.clearRecordCache(entry.getKey(), faceId);
|
||||
}
|
||||
|
||||
// 通知等待方任务最终失败
|
||||
@@ -402,9 +476,6 @@ public class PuzzleEdgeRenderTaskService {
|
||||
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
|
||||
|
||||
String originalObjectKey = String.format("puzzle/%s/%s", template.getCode(), fileName);
|
||||
String croppedObjectKey = StrUtil.isNotBlank(template.getUserArea())
|
||||
? String.format("puzzle/%s_cropped/%s", template.getCode(), fileName)
|
||||
: null;
|
||||
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("recordId", record.getId());
|
||||
@@ -417,7 +488,6 @@ public class PuzzleEdgeRenderTaskService {
|
||||
templatePayload.put("backgroundType", template.getBackgroundType());
|
||||
templatePayload.put("backgroundColor", template.getBackgroundColor());
|
||||
templatePayload.put("backgroundImage", template.getBackgroundImage());
|
||||
templatePayload.put("userArea", template.getUserArea());
|
||||
payload.put("template", templatePayload);
|
||||
|
||||
List<Map<String, Object>> elementPayloadList = new ArrayList<>();
|
||||
@@ -451,13 +521,127 @@ public class PuzzleEdgeRenderTaskService {
|
||||
task.setTemplateCode(template.getCode());
|
||||
task.setScenicId(record.getScenicId());
|
||||
task.setFaceId(record.getFaceId());
|
||||
task.setTaskType(TASK_TYPE_PUZZLE);
|
||||
task.setWatermarkType(null);
|
||||
task.setContentHash(record.getContentHash());
|
||||
task.setStatus(STATUS_PENDING);
|
||||
task.setAttemptCount(0);
|
||||
task.setOutputFormat(normalizedFormat);
|
||||
task.setOutputQuality(outputQuality);
|
||||
task.setOriginalObjectKey(originalObjectKey);
|
||||
task.setCroppedObjectKey(croppedObjectKey);
|
||||
task.setCroppedObjectKey(null);
|
||||
task.setPayloadJson(JacksonUtil.toJson(payload));
|
||||
|
||||
Long taskId = taskIdSequence.incrementAndGet();
|
||||
Date now = new Date();
|
||||
task.setId(taskId);
|
||||
task.setCreateTime(now);
|
||||
task.setUpdateTime(now);
|
||||
|
||||
taskCache.put(taskId, task);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建水印拼图边缘渲染任务(供中心业务侧调用)
|
||||
* 成功后将结果写入 puzzle_watermark 表
|
||||
*
|
||||
* @param recordId 原始拼图生成记录ID
|
||||
* @param faceId 人脸ID(可选)
|
||||
* @param watermarkType 水印类型(如 print、free_download)
|
||||
* @param template 模板配置
|
||||
* @param sortedElements 元素列表(按z-index排序)
|
||||
* @param finalDynamicData 动态数据
|
||||
* @param outputFormat 输出格式
|
||||
* @param quality 输出质量
|
||||
* @return 任务ID
|
||||
*/
|
||||
public Long createWatermarkRenderTask(Long recordId,
|
||||
Long faceId,
|
||||
String watermarkType,
|
||||
PuzzleTemplateEntity template,
|
||||
List<PuzzleElementEntity> sortedElements,
|
||||
Map<String, String> finalDynamicData,
|
||||
String outputFormat,
|
||||
Integer quality) {
|
||||
if (recordId == null) {
|
||||
throw new IllegalArgumentException("recordId不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(watermarkType)) {
|
||||
throw new IllegalArgumentException("watermarkType不能为空");
|
||||
}
|
||||
if (template == null || template.getId() == null) {
|
||||
throw new IllegalArgumentException("template不能为空");
|
||||
}
|
||||
if (sortedElements == null) {
|
||||
sortedElements = List.of();
|
||||
}
|
||||
if (finalDynamicData == null) {
|
||||
finalDynamicData = Map.of();
|
||||
}
|
||||
|
||||
String normalizedFormat = normalizeOutputFormat(outputFormat);
|
||||
Integer outputQuality = quality != null ? quality : 90;
|
||||
String ext = "PNG".equals(normalizedFormat) ? "png" : "jpeg";
|
||||
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
|
||||
|
||||
// 水印拼图使用单独的目录
|
||||
String originalObjectKey = String.format("puzzle_watermark/%s/%s/%s", template.getCode(), watermarkType, fileName);
|
||||
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("recordId", recordId);
|
||||
payload.put("watermarkType", watermarkType);
|
||||
|
||||
Map<String, Object> templatePayload = new HashMap<>();
|
||||
templatePayload.put("id", template.getId());
|
||||
templatePayload.put("code", template.getCode());
|
||||
templatePayload.put("canvasWidth", template.getCanvasWidth());
|
||||
templatePayload.put("canvasHeight", template.getCanvasHeight());
|
||||
templatePayload.put("backgroundType", template.getBackgroundType());
|
||||
templatePayload.put("backgroundColor", template.getBackgroundColor());
|
||||
templatePayload.put("backgroundImage", template.getBackgroundImage());
|
||||
payload.put("template", templatePayload);
|
||||
|
||||
List<Map<String, Object>> elementPayloadList = new ArrayList<>();
|
||||
for (PuzzleElementEntity e : sortedElements) {
|
||||
Map<String, Object> elementPayload = new HashMap<>();
|
||||
elementPayload.put("id", e.getId());
|
||||
elementPayload.put("type", e.getElementType());
|
||||
elementPayload.put("key", e.getElementKey());
|
||||
elementPayload.put("name", e.getElementName());
|
||||
elementPayload.put("x", e.getXPosition());
|
||||
elementPayload.put("y", e.getYPosition());
|
||||
elementPayload.put("width", e.getWidth());
|
||||
elementPayload.put("height", e.getHeight());
|
||||
elementPayload.put("zIndex", e.getZIndex());
|
||||
elementPayload.put("rotation", e.getRotation());
|
||||
elementPayload.put("opacity", e.getOpacity());
|
||||
elementPayload.put("config", e.getConfig());
|
||||
elementPayloadList.add(elementPayload);
|
||||
}
|
||||
payload.put("elements", elementPayloadList);
|
||||
payload.put("dynamicData", finalDynamicData);
|
||||
|
||||
Map<String, Object> outputPayload = new HashMap<>();
|
||||
outputPayload.put("format", normalizedFormat);
|
||||
outputPayload.put("quality", outputQuality);
|
||||
payload.put("output", outputPayload);
|
||||
|
||||
PuzzleEdgeRenderTaskEntity task = new PuzzleEdgeRenderTaskEntity();
|
||||
task.setRecordId(recordId);
|
||||
task.setTemplateId(template.getId());
|
||||
task.setTemplateCode(template.getCode());
|
||||
task.setScenicId(template.getScenicId());
|
||||
task.setFaceId(faceId);
|
||||
task.setTaskType(TASK_TYPE_WATERMARK);
|
||||
task.setWatermarkType(watermarkType);
|
||||
task.setContentHash(null); // 水印任务不需要内容哈希去重
|
||||
task.setStatus(STATUS_PENDING);
|
||||
task.setAttemptCount(0);
|
||||
task.setOutputFormat(normalizedFormat);
|
||||
task.setOutputQuality(outputQuality);
|
||||
task.setOriginalObjectKey(originalObjectKey);
|
||||
task.setCroppedObjectKey(null);
|
||||
task.setPayloadJson(JacksonUtil.toJson(payload));
|
||||
|
||||
Long taskId = taskIdSequence.incrementAndGet();
|
||||
@@ -749,20 +933,6 @@ public class PuzzleEdgeRenderTaskService {
|
||||
return attemptCount < MAX_RETRY_ATTEMPTS;
|
||||
}
|
||||
|
||||
private RenderWorkerEntity requireWorker(String accessKey) {
|
||||
if (StrUtil.isBlank(accessKey)) {
|
||||
throw new IllegalArgumentException("accessKey不能为空");
|
||||
}
|
||||
RenderWorkerEntity worker = renderWorkerRepository.getWorkerByAccessKey(accessKey);
|
||||
if (worker == null) {
|
||||
throw new IllegalArgumentException("worker不存在");
|
||||
}
|
||||
if (worker.getStatus() == null || worker.getStatus() != 1) {
|
||||
throw new IllegalArgumentException("worker未启用");
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
private String normalizeOutputFormat(String format) {
|
||||
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
|
||||
if ("JPG".equals(outputFormat)) {
|
||||
|
||||
@@ -29,9 +29,83 @@ public class ImageConfig implements ElementConfig {
|
||||
|
||||
/**
|
||||
* 圆角半径(像素,0为直角)
|
||||
* 注意:当 borderRadius >= min(width, height) / 2 时,效果为圆形
|
||||
*/
|
||||
private Integer borderRadius = 0;
|
||||
|
||||
/**
|
||||
* 叠加图片配置
|
||||
* 用于在主图上叠加另一张图片(如二维码中心的头像)
|
||||
*/
|
||||
private OverlayImageConfig overlayImage;
|
||||
|
||||
/**
|
||||
* 叠加图片配置类
|
||||
*/
|
||||
@Data
|
||||
public static class OverlayImageConfig {
|
||||
/**
|
||||
* 叠加图片的数据源 key(从 dynamicData 获取 URL)
|
||||
* 例如:faceAvatar
|
||||
*/
|
||||
private String imageKey;
|
||||
|
||||
/**
|
||||
* 叠加图片的默认 URL(当 dynamicData 中无对应值时使用)
|
||||
*/
|
||||
private String defaultImageUrl;
|
||||
|
||||
/**
|
||||
* 叠加图片宽度占主图宽度的比例(0.0 - 1.0)
|
||||
* 默认 0.45(与现有水印实现一致)
|
||||
*/
|
||||
private Double widthRatio = 0.45;
|
||||
|
||||
/**
|
||||
* 叠加图片高度占主图高度的比例(0.0 - 1.0)
|
||||
* 默认 0.45
|
||||
*/
|
||||
private Double heightRatio = 0.45;
|
||||
|
||||
/**
|
||||
* 叠加图片的适配模式
|
||||
* 默认 COVER(与现有水印实现一致)
|
||||
*/
|
||||
private String imageFitMode = "COVER";
|
||||
|
||||
/**
|
||||
* 叠加图片的圆角半径
|
||||
* 默认 -1 表示自动计算为圆形(min(width, height) / 2)
|
||||
*/
|
||||
private Integer borderRadius = -1;
|
||||
|
||||
/**
|
||||
* 叠加图片的水平对齐方式
|
||||
* CENTER - 居中(默认)
|
||||
* LEFT - 左对齐
|
||||
* RIGHT - 右对齐
|
||||
*/
|
||||
private String horizontalAlign = "CENTER";
|
||||
|
||||
/**
|
||||
* 叠加图片的垂直对齐方式
|
||||
* CENTER - 居中(默认)
|
||||
* TOP - 顶部对齐
|
||||
* BOTTOM - 底部对齐
|
||||
*/
|
||||
private String verticalAlign = "CENTER";
|
||||
|
||||
/**
|
||||
* 水平偏移量(像素),正值向右,负值向左
|
||||
*/
|
||||
private Integer offsetX = 0;
|
||||
|
||||
/**
|
||||
* 垂直偏移量(像素),正值向下,负值向上
|
||||
*/
|
||||
private Integer offsetY = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() {
|
||||
// 校验圆角半径
|
||||
@@ -50,12 +124,55 @@ public class ImageConfig implements ElementConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// 校验图片URL
|
||||
if (StrUtil.isBlank(defaultImageUrl)) {
|
||||
throw new IllegalArgumentException("默认图片URL不能为空");
|
||||
// 校验图片URL(注意:现在可以通过 dynamicData 动态填充,所以允许为空)
|
||||
if (StrUtil.isNotBlank(defaultImageUrl)) {
|
||||
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
||||
}
|
||||
}
|
||||
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
||||
|
||||
// 校验叠加图片配置
|
||||
if (overlayImage != null) {
|
||||
validateOverlayImage(overlayImage);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOverlayImage(OverlayImageConfig overlay) {
|
||||
// 校验比例范围
|
||||
if (overlay.getWidthRatio() != null && (overlay.getWidthRatio() <= 0 || overlay.getWidthRatio() > 1)) {
|
||||
throw new IllegalArgumentException("叠加图片宽度比例必须在 0-1 之间: " + overlay.getWidthRatio());
|
||||
}
|
||||
if (overlay.getHeightRatio() != null && (overlay.getHeightRatio() <= 0 || overlay.getHeightRatio() > 1)) {
|
||||
throw new IllegalArgumentException("叠加图片高度比例必须在 0-1 之间: " + overlay.getHeightRatio());
|
||||
}
|
||||
|
||||
// 校验对齐方式
|
||||
if (StrUtil.isNotBlank(overlay.getHorizontalAlign())) {
|
||||
String align = overlay.getHorizontalAlign().toUpperCase();
|
||||
if (!"CENTER".equals(align) && !"LEFT".equals(align) && !"RIGHT".equals(align)) {
|
||||
throw new IllegalArgumentException("水平对齐方式只能是CENTER、LEFT或RIGHT: " + overlay.getHorizontalAlign());
|
||||
}
|
||||
}
|
||||
if (StrUtil.isNotBlank(overlay.getVerticalAlign())) {
|
||||
String align = overlay.getVerticalAlign().toUpperCase();
|
||||
if (!"CENTER".equals(align) && !"TOP".equals(align) && !"BOTTOM".equals(align)) {
|
||||
throw new IllegalArgumentException("垂直对齐方式只能是CENTER、TOP或BOTTOM: " + overlay.getVerticalAlign());
|
||||
}
|
||||
}
|
||||
|
||||
// 校验叠加图片URL
|
||||
if (StrUtil.isNotBlank(overlay.getDefaultImageUrl())) {
|
||||
if (!overlay.getDefaultImageUrl().startsWith("http://") && !overlay.getDefaultImageUrl().startsWith("https://")) {
|
||||
throw new IllegalArgumentException("叠加图片URL必须以http://或https://开头: " + overlay.getDefaultImageUrl());
|
||||
}
|
||||
}
|
||||
|
||||
// 校验适配模式
|
||||
if (StrUtil.isNotBlank(overlay.getImageFitMode())) {
|
||||
String mode = overlay.getImageFitMode().toUpperCase();
|
||||
if (!"CONTAIN".equals(mode) && !"COVER".equals(mode) && !"FILL".equals(mode) && !"SCALE_DOWN".equals(mode)) {
|
||||
throw new IllegalArgumentException("叠加图片适配模式只能是CONTAIN、COVER、FILL或SCALE_DOWN: " + overlay.getImageFitMode());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +181,19 @@ public class ImageConfig implements ElementConfig {
|
||||
return "{\n" +
|
||||
" \"defaultImageUrl\": \"https://example.com/image.jpg\",\n" +
|
||||
" \"imageFitMode\": \"CONTAIN|COVER|FILL|SCALE_DOWN\",\n" +
|
||||
" \"borderRadius\": 0\n" +
|
||||
" \"borderRadius\": 0,\n" +
|
||||
" \"overlayImage\": {\n" +
|
||||
" \"imageKey\": \"faceAvatar\",\n" +
|
||||
" \"defaultImageUrl\": \"https://example.com/default-avatar.png\",\n" +
|
||||
" \"widthRatio\": 0.45,\n" +
|
||||
" \"heightRatio\": 0.45,\n" +
|
||||
" \"imageFitMode\": \"COVER\",\n" +
|
||||
" \"borderRadius\": -1,\n" +
|
||||
" \"horizontalAlign\": \"CENTER\",\n" +
|
||||
" \"verticalAlign\": \"CENTER\",\n" +
|
||||
" \"offsetX\": 0,\n" +
|
||||
" \"offsetY\": 0\n" +
|
||||
" }\n" +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,12 +76,6 @@ public class PuzzleGenerationRecordEntity {
|
||||
@TableField("result_image_url")
|
||||
private String resultImageUrl;
|
||||
|
||||
/**
|
||||
* 原始图片URL(未裁切的图片,用于打印)
|
||||
*/
|
||||
@TableField("original_image_url")
|
||||
private String originalImageUrl;
|
||||
|
||||
/**
|
||||
* 文件大小(字节)
|
||||
*/
|
||||
|
||||
@@ -109,12 +109,6 @@ public class PuzzleTemplateEntity {
|
||||
@TableField("can_print")
|
||||
private Integer canPrint;
|
||||
|
||||
/**
|
||||
* 用户查看区域(裁切区域),格式:x,y,w,h
|
||||
*/
|
||||
@TableField("user_area")
|
||||
private String userArea;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ycwl.basic.puzzle.mapper;
|
||||
|
||||
import com.ycwl.basic.model.pc.puzzle.entity.MemberPuzzleEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 会员拼图关联Mapper接口
|
||||
*/
|
||||
@Mapper
|
||||
public interface MemberPuzzleMapper {
|
||||
|
||||
/**
|
||||
* 添加关联记录
|
||||
*/
|
||||
int addRelation(MemberPuzzleEntity entity);
|
||||
|
||||
/**
|
||||
* 批量添加关联记录
|
||||
*/
|
||||
int addRelations(@Param("list") List<MemberPuzzleEntity> list);
|
||||
|
||||
/**
|
||||
* 根据人脸ID查询关联列表
|
||||
*/
|
||||
List<MemberPuzzleEntity> listByFaceId(@Param("faceId") Long faceId);
|
||||
|
||||
/**
|
||||
* 根据人脸ID统计免费数量
|
||||
*/
|
||||
int countFreeByFaceId(@Param("faceId") Long faceId);
|
||||
|
||||
/**
|
||||
* 更新关联记录(购买状态、订单ID等)
|
||||
*/
|
||||
int updateRelation(MemberPuzzleEntity entity);
|
||||
|
||||
/**
|
||||
* 批量标记为免费
|
||||
*/
|
||||
int freeRelations(@Param("ids") List<Long> ids);
|
||||
|
||||
/**
|
||||
* 根据人脸ID和记录ID查询
|
||||
*/
|
||||
MemberPuzzleEntity getByFaceAndRecord(@Param("faceId") Long faceId, @Param("recordId") Long recordId);
|
||||
}
|
||||
@@ -52,7 +52,6 @@ public interface PuzzleGenerationRecordMapper {
|
||||
*/
|
||||
int updateSuccess(@Param("id") Long id,
|
||||
@Param("resultImageUrl") String resultImageUrl,
|
||||
@Param("originalImageUrl") String originalImageUrl,
|
||||
@Param("resultFileSize") Long resultFileSize,
|
||||
@Param("resultWidth") Integer resultWidth,
|
||||
@Param("resultHeight") Integer resultHeight,
|
||||
@@ -77,4 +76,15 @@ public interface PuzzleGenerationRecordMapper {
|
||||
PuzzleGenerationRecordEntity findByContentHash(@Param("templateId") Long templateId,
|
||||
@Param("contentHash") String contentHash,
|
||||
@Param("scenicId") Long scenicId);
|
||||
|
||||
/**
|
||||
* 根据人脸ID和模板ID查询最近的成功记录
|
||||
* 用于素材版本缓存命中时快速返回历史结果
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @param templateId 模板ID
|
||||
* @return 最近的成功记录,如果不存在返回null
|
||||
*/
|
||||
PuzzleGenerationRecordEntity findLatestSuccessByFaceAndTemplate(@Param("faceId") Long faceId,
|
||||
@Param("templateId") Long templateId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ycwl.basic.puzzle.mapper;
|
||||
|
||||
import com.ycwl.basic.model.pc.puzzle.entity.PuzzleWatermarkEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 拼图水印Mapper接口
|
||||
* 用于存储和查询拼图在不同场景下的水印版本
|
||||
*/
|
||||
@Mapper
|
||||
public interface PuzzleWatermarkMapper {
|
||||
|
||||
/**
|
||||
* 新增拼图水印记录
|
||||
*/
|
||||
int insert(PuzzleWatermarkEntity entity);
|
||||
|
||||
/**
|
||||
* 批量查询拼图水印
|
||||
*
|
||||
* @param recordIds 拼图生成记录ID列表
|
||||
* @param faceId 人脸ID(可选)
|
||||
* @param watermarkType 水印类型
|
||||
* @return 水印列表
|
||||
*/
|
||||
List<PuzzleWatermarkEntity> listByRecordIds(@Param("recordIds") List<Long> recordIds,
|
||||
@Param("faceId") Long faceId,
|
||||
@Param("watermarkType") String watermarkType);
|
||||
|
||||
/**
|
||||
* 查询单条拼图水印
|
||||
*
|
||||
* @param recordId 拼图生成记录ID
|
||||
* @param faceId 人脸ID(可选)
|
||||
* @param watermarkType 水印类型
|
||||
* @return 水印记录
|
||||
*/
|
||||
PuzzleWatermarkEntity getByRecordAndType(@Param("recordId") Long recordId,
|
||||
@Param("faceId") Long faceId,
|
||||
@Param("watermarkType") String watermarkType);
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package com.ycwl.basic.puzzle.repository;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -26,6 +28,7 @@ public class PuzzleRepository {
|
||||
|
||||
private final PuzzleTemplateMapper templateMapper;
|
||||
private final PuzzleElementMapper elementMapper;
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
/**
|
||||
@@ -43,17 +46,39 @@ public class PuzzleRepository {
|
||||
*/
|
||||
private static final String PUZZLE_ELEMENTS_BY_TEMPLATE_KEY = "puzzle:elements:templateId:%s";
|
||||
|
||||
/**
|
||||
* 生成记录缓存KEY(根据faceId)
|
||||
*/
|
||||
private static final String PUZZLE_RECORDS_BY_FACE_KEY = "puzzle:records:faceId:%s";
|
||||
|
||||
/**
|
||||
* 景区模板列表缓存KEY(根据scenicId)
|
||||
*/
|
||||
private static final String PUZZLE_TEMPLATES_BY_SCENIC_KEY = "puzzle:templates:scenicId:%s";
|
||||
|
||||
/**
|
||||
* 单条生成记录缓存KEY(根据recordId)
|
||||
*/
|
||||
private static final String PUZZLE_RECORD_BY_ID_KEY = "puzzle:record:id:%s";
|
||||
|
||||
/**
|
||||
* 缓存过期时间(小时)
|
||||
*/
|
||||
private static final long CACHE_EXPIRE_HOURS = 24;
|
||||
|
||||
/**
|
||||
* 生成记录缓存过期时间(分钟)- 较短,因为可能频繁变化
|
||||
*/
|
||||
private static final long RECORD_CACHE_EXPIRE_MINUTES = 30;
|
||||
|
||||
public PuzzleRepository(
|
||||
PuzzleTemplateMapper templateMapper,
|
||||
PuzzleElementMapper elementMapper,
|
||||
PuzzleGenerationRecordMapper recordMapper,
|
||||
RedisTemplate<String, String> redisTemplate) {
|
||||
this.templateMapper = templateMapper;
|
||||
this.elementMapper = elementMapper;
|
||||
this.recordMapper = recordMapper;
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@@ -147,6 +172,8 @@ public class PuzzleRepository {
|
||||
* @param code 模板编码(可为null,此时需要先查询获取)
|
||||
*/
|
||||
public void clearTemplateCache(Long id, String code) {
|
||||
Long scenicId = null;
|
||||
|
||||
// 如果没有传code,尝试从缓存或数据库获取
|
||||
if (code == null && id != null) {
|
||||
String idKey = String.format(PUZZLE_TEMPLATE_BY_ID_KEY, id);
|
||||
@@ -154,10 +181,25 @@ public class PuzzleRepository {
|
||||
if (cacheValue != null) {
|
||||
PuzzleTemplateEntity template = JacksonUtil.parseObject(cacheValue, PuzzleTemplateEntity.class);
|
||||
code = template.getCode();
|
||||
scenicId = template.getScenicId();
|
||||
} else {
|
||||
PuzzleTemplateEntity template = templateMapper.getById(id);
|
||||
if (template != null) {
|
||||
code = template.getCode();
|
||||
scenicId = template.getScenicId();
|
||||
}
|
||||
}
|
||||
} else if (id != null) {
|
||||
// 有 code 但需要获取 scenicId
|
||||
String idKey = String.format(PUZZLE_TEMPLATE_BY_ID_KEY, id);
|
||||
String cacheValue = redisTemplate.opsForValue().get(idKey);
|
||||
if (cacheValue != null) {
|
||||
PuzzleTemplateEntity template = JacksonUtil.parseObject(cacheValue, PuzzleTemplateEntity.class);
|
||||
scenicId = template.getScenicId();
|
||||
} else {
|
||||
PuzzleTemplateEntity template = templateMapper.getById(id);
|
||||
if (template != null) {
|
||||
scenicId = template.getScenicId();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +222,11 @@ public class PuzzleRepository {
|
||||
if (id != null) {
|
||||
clearElementsCache(id);
|
||||
}
|
||||
|
||||
// 清除景区模板列表缓存(确保列表数据一致性)
|
||||
if (scenicId != null) {
|
||||
clearTemplateByScenicCache(scenicId);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 元素缓存 ====================
|
||||
@@ -225,6 +272,58 @@ public class PuzzleRepository {
|
||||
log.debug("清除元素缓存: templateId={}", templateId);
|
||||
}
|
||||
|
||||
// ==================== 景区模板列表缓存 ====================
|
||||
|
||||
/**
|
||||
* 根据景区ID获取启用的模板列表(优先从缓存读取)
|
||||
* 用于人脸匹配后的批量拼图生成场景
|
||||
*
|
||||
* @param scenicId 景区ID
|
||||
* @return 启用状态的模板列表
|
||||
*/
|
||||
public List<PuzzleTemplateEntity> listTemplateByScenic(Long scenicId) {
|
||||
if (scenicId == null) {
|
||||
log.warn("景区ID为空,跳过缓存查询");
|
||||
return templateMapper.list(null, null, 1);
|
||||
}
|
||||
|
||||
String cacheKey = String.format(PUZZLE_TEMPLATES_BY_SCENIC_KEY, scenicId);
|
||||
|
||||
// 1. 尝试从缓存读取
|
||||
Boolean hasKey = redisTemplate.hasKey(cacheKey);
|
||||
if (Boolean.TRUE.equals(hasKey)) {
|
||||
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cacheValue != null) {
|
||||
log.debug("从缓存读取景区模板列表: scenicId={}", scenicId);
|
||||
return JacksonUtil.parseObject(cacheValue, new TypeReference<List<PuzzleTemplateEntity>>() {});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从数据库查询(只查启用状态 status=1)
|
||||
List<PuzzleTemplateEntity> templates = templateMapper.list(scenicId, null, 1);
|
||||
|
||||
// 3. 写入缓存(即使是空列表也缓存,避免缓存穿透)
|
||||
String json = JacksonUtil.toJSONString(templates);
|
||||
redisTemplate.opsForValue().set(cacheKey, json, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
|
||||
log.debug("景区模板列表缓存写入: scenicId={}, count={}", scenicId, templates.size());
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除景区模板列表缓存
|
||||
*
|
||||
* @param scenicId 景区ID
|
||||
*/
|
||||
public void clearTemplateByScenicCache(Long scenicId) {
|
||||
if (scenicId == null) {
|
||||
return;
|
||||
}
|
||||
String cacheKey = String.format(PUZZLE_TEMPLATES_BY_SCENIC_KEY, scenicId);
|
||||
redisTemplate.delete(cacheKey);
|
||||
log.debug("清除景区模板列表缓存: scenicId={}", scenicId);
|
||||
}
|
||||
|
||||
// ==================== 批量清除 ====================
|
||||
|
||||
/**
|
||||
@@ -238,6 +337,8 @@ public class PuzzleRepository {
|
||||
deleteByPattern("puzzle:template:*");
|
||||
// 使用 SCAN 删除元素缓存
|
||||
deleteByPattern("puzzle:elements:*");
|
||||
// 使用 SCAN 删除景区模板列表缓存
|
||||
deleteByPattern("puzzle:templates:*");
|
||||
|
||||
log.warn("拼图缓存清除完成");
|
||||
}
|
||||
@@ -257,4 +358,122 @@ public class PuzzleRepository {
|
||||
log.error("删除缓存失败: pattern={}", pattern, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生成记录缓存 ====================
|
||||
|
||||
/**
|
||||
* 根据人脸ID获取生成记录列表(优先从缓存读取)
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @return 生成记录列表
|
||||
*/
|
||||
public List<PuzzleGenerationRecordEntity> getRecordsByFaceId(Long faceId) {
|
||||
String cacheKey = String.format(PUZZLE_RECORDS_BY_FACE_KEY, faceId);
|
||||
|
||||
// 1. 尝试从缓存读取
|
||||
Boolean hasKey = redisTemplate.hasKey(cacheKey);
|
||||
if (Boolean.TRUE.equals(hasKey)) {
|
||||
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cacheValue != null) {
|
||||
log.debug("从缓存读取生成记录列表: faceId={}", faceId);
|
||||
return JacksonUtil.parseObject(cacheValue, new TypeReference<List<PuzzleGenerationRecordEntity>>() {});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从数据库查询
|
||||
List<PuzzleGenerationRecordEntity> records = recordMapper.listByFaceId(faceId);
|
||||
|
||||
// 3. 写入缓存
|
||||
String json = JacksonUtil.toJSONString(records);
|
||||
redisTemplate.opsForValue().set(cacheKey, json, RECORD_CACHE_EXPIRE_MINUTES, TimeUnit.MINUTES);
|
||||
log.debug("生成记录列表缓存写入: faceId={}, count={}", faceId, records.size());
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据人脸ID获取生成记录数量(优先从缓存读取)
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @return 记录数量
|
||||
*/
|
||||
public int countRecordsByFaceId(Long faceId) {
|
||||
List<PuzzleGenerationRecordEntity> records = getRecordsByFaceId(faceId);
|
||||
return records.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据记录ID获取单条生成记录(优先从缓存读取)
|
||||
*
|
||||
* @param recordId 记录ID
|
||||
* @return 生成记录,不存在返回null
|
||||
*/
|
||||
public PuzzleGenerationRecordEntity getRecordById(Long recordId) {
|
||||
String cacheKey = String.format(PUZZLE_RECORD_BY_ID_KEY, recordId);
|
||||
|
||||
// 1. 尝试从缓存读取
|
||||
Boolean hasKey = redisTemplate.hasKey(cacheKey);
|
||||
if (Boolean.TRUE.equals(hasKey)) {
|
||||
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cacheValue != null) {
|
||||
log.debug("从缓存读取生成记录: recordId={}", recordId);
|
||||
return JacksonUtil.parseObject(cacheValue, PuzzleGenerationRecordEntity.class);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从数据库查询
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 写入缓存
|
||||
String json = JacksonUtil.toJSONString(record);
|
||||
redisTemplate.opsForValue().set(cacheKey, json, RECORD_CACHE_EXPIRE_MINUTES, TimeUnit.MINUTES);
|
||||
log.debug("生成记录缓存写入: recordId={}", recordId);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除人脸相关的生成记录缓存
|
||||
* 在新拼图生成成功后调用
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
*/
|
||||
public void clearRecordCacheByFace(Long faceId) {
|
||||
if (faceId == null) {
|
||||
return;
|
||||
}
|
||||
// 清除列表缓存
|
||||
String listKey = String.format(PUZZLE_RECORDS_BY_FACE_KEY, faceId);
|
||||
redisTemplate.delete(listKey);
|
||||
|
||||
log.debug("清除人脸生成记录缓存: faceId={}", faceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除单条记录的缓存
|
||||
*
|
||||
* @param recordId 记录ID
|
||||
*/
|
||||
public void clearRecordCacheById(Long recordId) {
|
||||
if (recordId == null) {
|
||||
return;
|
||||
}
|
||||
String cacheKey = String.format(PUZZLE_RECORD_BY_ID_KEY, recordId);
|
||||
redisTemplate.delete(cacheKey);
|
||||
log.debug("清除生成记录缓存: recordId={}", recordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除单条记录缓存并同时清除关联的人脸缓存
|
||||
*
|
||||
* @param recordId 记录ID
|
||||
* @param faceId 人脸ID
|
||||
*/
|
||||
public void clearRecordCache(Long recordId, Long faceId) {
|
||||
clearRecordCacheById(recordId);
|
||||
clearRecordCacheByFace(faceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ycwl.basic.puzzle.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.ycwl.basic.biz.FaceStatusManager;
|
||||
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||
@@ -17,6 +18,7 @@ import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.service.pc.processor.PuzzleRelationProcessor;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.utils.WxMpUtil;
|
||||
@@ -56,6 +58,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
private final PuzzleDuplicationDetector duplicationDetector;
|
||||
private final PrinterService printerService;
|
||||
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
|
||||
private final FaceStatusManager faceStatusManager;
|
||||
private final PuzzleRelationProcessor puzzleRelationProcessor;
|
||||
|
||||
public PuzzleGenerateServiceImpl(
|
||||
PuzzleRepository puzzleRepository,
|
||||
@@ -65,7 +69,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
@Lazy ScenicRepository scenicRepository,
|
||||
@Lazy PuzzleDuplicationDetector duplicationDetector,
|
||||
@Lazy PrinterService printerService,
|
||||
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService) {
|
||||
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService,
|
||||
@Lazy FaceStatusManager faceStatusManager,
|
||||
@Lazy PuzzleRelationProcessor puzzleRelationProcessor) {
|
||||
this.puzzleRepository = puzzleRepository;
|
||||
this.recordMapper = recordMapper;
|
||||
this.imageRenderer = imageRenderer;
|
||||
@@ -74,6 +80,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
this.duplicationDetector = duplicationDetector;
|
||||
this.printerService = printerService;
|
||||
this.puzzleEdgeRenderTaskService = puzzleEdgeRenderTaskService;
|
||||
this.faceStatusManager = faceStatusManager;
|
||||
this.puzzleRelationProcessor = puzzleRelationProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -101,6 +109,32 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
}
|
||||
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
|
||||
|
||||
// 2.5 素材版本缓存检查(减少数据库查询)
|
||||
// 如果缓存存在,说明自上次成功生成后素材没有变化,可以直接复用历史记录
|
||||
if (request.getFaceId() != null && !faceStatusManager.isPuzzleSourceChanged(request.getFaceId(), template.getId(), 0)) {
|
||||
PuzzleGenerationRecordEntity cachedRecord = recordMapper.findLatestSuccessByFaceAndTemplate(
|
||||
request.getFaceId(), template.getId());
|
||||
if (cachedRecord != null) {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("素材版本缓存命中,复用历史记录: faceId={}, templateId={}, recordId={}, imageUrl={}, duration={}ms",
|
||||
request.getFaceId(), template.getId(), cachedRecord.getId(),
|
||||
cachedRecord.getResultImageUrl(), duration);
|
||||
return PuzzleGenerateResponse.success(
|
||||
cachedRecord.getResultImageUrl(),
|
||||
cachedRecord.getResultFileSize(),
|
||||
cachedRecord.getResultWidth(),
|
||||
cachedRecord.getResultHeight(),
|
||||
(int) duration,
|
||||
cachedRecord.getId(),
|
||||
true,
|
||||
cachedRecord.getId()
|
||||
);
|
||||
}
|
||||
// 缓存存在但记录被删除,继续执行正常流程
|
||||
log.debug("素材版本缓存存在但历史记录不存在,继续正常生成: faceId={}, templateId={}",
|
||||
request.getFaceId(), template.getId());
|
||||
}
|
||||
|
||||
// 3. 查询并排序元素
|
||||
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
|
||||
if (elements.isEmpty()) {
|
||||
@@ -124,6 +158,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
|
||||
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
|
||||
// 标记素材版本缓存
|
||||
if (request.getFaceId() != null) {
|
||||
faceStatusManager.markPuzzleSourceVersion(request.getFaceId(), template.getId(), 0);
|
||||
}
|
||||
return PuzzleGenerateResponse.success(
|
||||
duplicateRecord.getResultImageUrl(),
|
||||
duplicateRecord.getResultFileSize(),
|
||||
@@ -141,6 +179,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
record.setContentHash(contentHash);
|
||||
recordMapper.insert(record);
|
||||
|
||||
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
|
||||
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
|
||||
|
||||
// 8. 创建边缘渲染任务并等待完成
|
||||
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
|
||||
record,
|
||||
@@ -164,6 +205,19 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
log.info("同步拼图边缘渲染完成: recordId={}, taskId={}, imageUrl={}, duration={}ms",
|
||||
record.getId(), taskId, waitResult.getImageUrl(), duration);
|
||||
|
||||
// 标记素材版本缓存(成功生成后)
|
||||
if (request.getFaceId() != null) {
|
||||
faceStatusManager.markPuzzleSourceVersion(request.getFaceId(), template.getId(), 0);
|
||||
}
|
||||
|
||||
// 创建member_puzzle关联记录
|
||||
puzzleRelationProcessor.createPuzzleRelation(
|
||||
request.getUserId(),
|
||||
resolvedScenicId,
|
||||
request.getFaceId(),
|
||||
record.getId()
|
||||
);
|
||||
|
||||
// 重新查询记录获取完整信息(边缘渲染回调已更新)
|
||||
PuzzleGenerationRecordEntity updatedRecord = recordMapper.getById(record.getId());
|
||||
if (updatedRecord != null && updatedRecord.getResultImageUrl() != null) {
|
||||
@@ -212,6 +266,23 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
}
|
||||
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
|
||||
|
||||
// 2.5 素材版本缓存检查(减少数据库查询)
|
||||
// 如果缓存存在,说明自上次成功生成后素材没有变化,可以直接复用历史记录
|
||||
if (request.getFaceId() != null && !faceStatusManager.isPuzzleSourceChanged(request.getFaceId(), template.getId(), 0)) {
|
||||
PuzzleGenerationRecordEntity cachedRecord = recordMapper.findLatestSuccessByFaceAndTemplate(
|
||||
request.getFaceId(), template.getId());
|
||||
if (cachedRecord != null) {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("素材版本缓存命中,复用历史记录: faceId={}, templateId={}, recordId={}, imageUrl={}, duration={}ms",
|
||||
request.getFaceId(), template.getId(), cachedRecord.getId(),
|
||||
cachedRecord.getResultImageUrl(), duration);
|
||||
return cachedRecord.getId();
|
||||
}
|
||||
// 缓存存在但记录被删除,继续执行正常流程
|
||||
log.debug("素材版本缓存存在但历史记录不存在,继续正常生成: faceId={}, templateId={}",
|
||||
request.getFaceId(), template.getId());
|
||||
}
|
||||
|
||||
// 3. 查询并排序元素
|
||||
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
|
||||
if (elements.isEmpty()) {
|
||||
@@ -235,6 +306,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
|
||||
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
|
||||
// 标记素材版本缓存
|
||||
if (request.getFaceId() != null) {
|
||||
faceStatusManager.markPuzzleSourceVersion(request.getFaceId(), template.getId(), 0);
|
||||
}
|
||||
return duplicateRecord.getId();
|
||||
}
|
||||
|
||||
@@ -243,6 +318,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
record.setContentHash(contentHash);
|
||||
recordMapper.insert(record);
|
||||
|
||||
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
|
||||
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
|
||||
|
||||
// 8. 创建边缘渲染任务
|
||||
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
|
||||
record,
|
||||
@@ -253,6 +331,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
request.getQuality()
|
||||
);
|
||||
|
||||
// 异步任务:在回调成功后标记缓存(由边缘渲染服务在成功回调中处理)
|
||||
// 这里只记录请求信息供后续使用
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("异步拼图任务已进入边缘渲染队列: recordId={}, taskId={}, templateCode={}, duration={}ms",
|
||||
record.getId(), taskId, request.getTemplateCode(), duration);
|
||||
@@ -331,6 +411,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
record.setContentHash(contentHash);
|
||||
recordMapper.insert(record);
|
||||
|
||||
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
|
||||
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
|
||||
|
||||
// 9. 执行核心生成逻辑
|
||||
return doGenerateInternal(request, template, resolvedScenicId, record, startTime);
|
||||
}
|
||||
@@ -385,40 +468,27 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
// 渲染图片
|
||||
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
|
||||
|
||||
// 上传原图到OSS(未裁切)
|
||||
String originalImageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
|
||||
log.info("原图上传成功: url={}", originalImageUrl);
|
||||
|
||||
// 处理用户区域裁切
|
||||
String finalImageUrl = originalImageUrl;
|
||||
BufferedImage finalImage = resultImage;
|
||||
|
||||
if (StrUtil.isNotBlank(template.getUserArea())) {
|
||||
try {
|
||||
BufferedImage croppedImage = cropImage(resultImage, template.getUserArea());
|
||||
finalImageUrl = uploadImage(croppedImage, template.getCode() + "_cropped", request.getOutputFormat(), request.getQuality());
|
||||
finalImage = croppedImage;
|
||||
log.info("裁切后图片上传成功: userArea={}, url={}", template.getUserArea(), finalImageUrl);
|
||||
} catch (Exception e) {
|
||||
log.error("图片裁切失败,使用原图: userArea={}", template.getUserArea(), e);
|
||||
}
|
||||
}
|
||||
// 上传图片到OSS
|
||||
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
|
||||
log.info("图片上传成功: url={}", imageUrl);
|
||||
|
||||
// 更新记录为成功
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
long fileSize = estimateFileSize(finalImage, request.getOutputFormat());
|
||||
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
|
||||
recordMapper.updateSuccess(
|
||||
record.getId(),
|
||||
finalImageUrl,
|
||||
originalImageUrl,
|
||||
imageUrl,
|
||||
fileSize,
|
||||
finalImage.getWidth(),
|
||||
finalImage.getHeight(),
|
||||
resultImage.getWidth(),
|
||||
resultImage.getHeight(),
|
||||
(int) duration
|
||||
);
|
||||
|
||||
log.info("拼图生成成功: recordId={}, originalUrl={}, finalUrl={}, duration={}ms",
|
||||
record.getId(), originalImageUrl, finalImageUrl, duration);
|
||||
// 清除生成记录缓存(状态已更新)
|
||||
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
|
||||
|
||||
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
|
||||
record.getId(), imageUrl, duration);
|
||||
|
||||
// 检查是否自动添加到打印队列
|
||||
if (template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
|
||||
@@ -427,8 +497,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
request.getUserId(),
|
||||
resolvedScenicId,
|
||||
request.getFaceId(),
|
||||
originalImageUrl,
|
||||
record.getId()
|
||||
imageUrl,
|
||||
record.getId() // 拼图记录ID,用于关联 puzzle_generation_record 表
|
||||
);
|
||||
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
|
||||
} catch (Exception e) {
|
||||
@@ -437,10 +507,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
}
|
||||
|
||||
return PuzzleGenerateResponse.success(
|
||||
finalImageUrl,
|
||||
imageUrl,
|
||||
fileSize,
|
||||
finalImage.getWidth(),
|
||||
finalImage.getHeight(),
|
||||
resultImage.getWidth(),
|
||||
resultImage.getHeight(),
|
||||
(int) duration,
|
||||
record.getId(),
|
||||
false,
|
||||
@@ -450,6 +520,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
} catch (Exception e) {
|
||||
log.error("拼图生成失败: templateCode={}", request.getTemplateCode(), e);
|
||||
recordMapper.updateFail(record.getId(), e.getMessage());
|
||||
// 清除生成记录缓存(状态已更新)
|
||||
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
|
||||
throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
@@ -685,43 +757,4 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
|
||||
return templateScenicId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁切图片
|
||||
* @param image 原图
|
||||
* @param userArea 裁切区域,格式:x,y,w,h
|
||||
* @return 裁切后的图片
|
||||
*/
|
||||
private BufferedImage cropImage(BufferedImage image, String userArea) {
|
||||
if (StrUtil.isBlank(userArea)) {
|
||||
return image;
|
||||
}
|
||||
|
||||
try {
|
||||
String[] parts = userArea.split(",");
|
||||
if (parts.length != 4) {
|
||||
throw new IllegalArgumentException("userArea格式错误,应为:x,y,w,h");
|
||||
}
|
||||
|
||||
int x = Integer.parseInt(parts[0].trim());
|
||||
int y = Integer.parseInt(parts[1].trim());
|
||||
int w = Integer.parseInt(parts[2].trim());
|
||||
int h = Integer.parseInt(parts[3].trim());
|
||||
|
||||
// 边界检查
|
||||
if (x < 0 || y < 0 || w <= 0 || h <= 0) {
|
||||
throw new IllegalArgumentException("裁切区域参数必须为正数");
|
||||
}
|
||||
|
||||
if (x + w > image.getWidth() || y + h > image.getHeight()) {
|
||||
throw new IllegalArgumentException("裁切区域超出图片边界");
|
||||
}
|
||||
|
||||
// 执行裁切
|
||||
return image.getSubimage(x, y, w, h);
|
||||
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("userArea格式错误,参数必须为数字", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
|
||||
entity.setDeleted(0);
|
||||
templateMapper.insert(entity);
|
||||
|
||||
// 清除景区模板列表缓存
|
||||
if (entity.getScenicId() != null) {
|
||||
puzzleRepository.clearTemplateByScenicCache(entity.getScenicId());
|
||||
}
|
||||
|
||||
log.info("拼图模板创建成功: id={}, code={}", entity.getId(), entity.getCode());
|
||||
return entity.getId();
|
||||
}
|
||||
@@ -71,8 +76,11 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
|
||||
throw new IllegalArgumentException("模板不存在: " + id);
|
||||
}
|
||||
|
||||
// 如果修改了编码,检查新编码是否已存在
|
||||
// 记录旧值
|
||||
String oldCode = existing.getCode();
|
||||
Long oldScenicId = existing.getScenicId();
|
||||
|
||||
// 如果修改了编码,检查新编码是否已存在
|
||||
if (request.getCode() != null && !request.getCode().equals(existing.getCode())) {
|
||||
int count = templateMapper.countByCode(request.getCode(), id);
|
||||
if (count > 0) {
|
||||
@@ -85,12 +93,18 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
|
||||
entity.setId(id);
|
||||
templateMapper.update(entity);
|
||||
|
||||
// 清除缓存(如果修改了code,需要同时清除新旧code的缓存)
|
||||
// 清除缓存
|
||||
puzzleRepository.clearTemplateCache(id, oldCode);
|
||||
if (request.getCode() != null && !request.getCode().equals(oldCode)) {
|
||||
puzzleRepository.clearTemplateCache(null, request.getCode());
|
||||
}
|
||||
|
||||
// 如果 scenicId 变更,清除新旧两个景区的缓存
|
||||
Long newScenicId = request.getScenicId();
|
||||
if (newScenicId != null && !newScenicId.equals(oldScenicId)) {
|
||||
puzzleRepository.clearTemplateByScenicCache(newScenicId);
|
||||
}
|
||||
|
||||
log.info("拼图模板更新成功: id={}", id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.ycwl.basic.repository;
|
||||
|
||||
import com.ycwl.basic.facebody.enums.FaceBodyAdapterType;
|
||||
import com.ycwl.basic.integration.common.util.ConfigValueUtil;
|
||||
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.integration.scenic.service.ScenicIntegrationService;
|
||||
@@ -14,8 +12,6 @@ import com.ycwl.basic.model.pc.mp.MpNotifyConfigEntity;
|
||||
import com.ycwl.basic.model.pc.mp.ScenicMpNotifyVO;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
|
||||
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
|
||||
import com.ycwl.basic.pay.enums.PayAdapterType;
|
||||
import com.ycwl.basic.storage.enums.StorageType;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -41,8 +37,6 @@ public class ScenicRepository {
|
||||
|
||||
public static final String SCENIC_MP_CACHE_KEY = "scenic:%s:mp";
|
||||
public static final String SCENIC_MP_NOTIFY_CACHE_KEY = "scenic:%s:mpNotify";
|
||||
@Autowired
|
||||
private MpNotifyConfigMapper mpNotifyConfigMapper;
|
||||
|
||||
public ScenicV2DTO getScenicBasic(Long id) {
|
||||
ScenicV2DTO scenicDTO = scenicIntegrationService.getScenic(id);
|
||||
@@ -70,61 +64,6 @@ public class ScenicRepository {
|
||||
return mpConfigEntity;
|
||||
}
|
||||
|
||||
public ScenicMpNotifyVO getScenicMpNotifyConfig(Long scenicId) {
|
||||
if (redisTemplate.hasKey(String.format(SCENIC_MP_NOTIFY_CACHE_KEY, scenicId))) {
|
||||
return JacksonUtil.parseObject(redisTemplate.opsForValue().get(String.format(SCENIC_MP_NOTIFY_CACHE_KEY, scenicId)), ScenicMpNotifyVO.class);
|
||||
}
|
||||
MpConfigEntity mpConfig = getScenicMpConfig(scenicId);
|
||||
if (mpConfig == null) {
|
||||
return null;
|
||||
}
|
||||
ScenicMpNotifyVO mpNotifyConfig = new ScenicMpNotifyVO();
|
||||
mpNotifyConfig.setAppId(mpConfig.getAppId());
|
||||
mpNotifyConfig.setAppSecret(mpConfig.getAppSecret());
|
||||
mpNotifyConfig.setMpId(mpConfig.getId());
|
||||
mpNotifyConfig.setAppState(mpConfig.getState());
|
||||
List<MpNotifyConfigEntity> mpNotifyConfigList = mpNotifyConfigMapper.listByMpId(mpConfig.getId());
|
||||
mpNotifyConfigList.forEach(item -> {
|
||||
switch (item.getType()) {
|
||||
case 0:
|
||||
mpNotifyConfig.setVideoGeneratedTemplateId(item.getTemplateId());
|
||||
break;
|
||||
case 1:
|
||||
mpNotifyConfig.setVideoDownloadTemplateId(item.getTemplateId());
|
||||
break;
|
||||
case 2:
|
||||
mpNotifyConfig.setVideoPreExpireTemplateId(item.getTemplateId());
|
||||
break;
|
||||
}
|
||||
});
|
||||
redisTemplate.opsForValue().set(String.format(SCENIC_MP_NOTIFY_CACHE_KEY, scenicId), JacksonUtil.toJSONString(mpNotifyConfig));
|
||||
return mpNotifyConfig;
|
||||
}
|
||||
|
||||
public String getVideoGeneratedTemplateId(Long scenicId) {
|
||||
ScenicMpNotifyVO scenicMpNotifyConfig = getScenicMpNotifyConfig(scenicId);
|
||||
if (scenicMpNotifyConfig != null) {
|
||||
return scenicMpNotifyConfig.getVideoGeneratedTemplateId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getVideoDownloadTemplateId(Long scenicId) {
|
||||
ScenicMpNotifyVO scenicMpNotifyConfig = getScenicMpNotifyConfig(scenicId);
|
||||
if (scenicMpNotifyConfig != null) {
|
||||
return scenicMpNotifyConfig.getVideoDownloadTemplateId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getVideoPreExpireTemplateId(Long scenicId) {
|
||||
ScenicMpNotifyVO scenicMpNotifyConfig = getScenicMpNotifyConfig(scenicId);
|
||||
if (scenicMpNotifyConfig != null) {
|
||||
return scenicMpNotifyConfig.getVideoPreExpireTemplateId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public List<ScenicV2DTO> list(ScenicReqQuery scenicReqQuery) {
|
||||
try {
|
||||
// 将 ScenicReqQuery 参数转换为 zt-scenic 服务需要的参数
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user