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;
|
package com.ycwl.basic.biz;
|
||||||
|
|
||||||
import cn.hutool.core.date.DateUtil;
|
import cn.hutool.core.date.DateUtil;
|
||||||
|
import com.ycwl.basic.clickhouse.service.StatsQueryService;
|
||||||
import com.ycwl.basic.mapper.BrokerMapper;
|
import com.ycwl.basic.mapper.BrokerMapper;
|
||||||
import com.ycwl.basic.mapper.BrokerRecordMapper;
|
import com.ycwl.basic.mapper.BrokerRecordMapper;
|
||||||
import com.ycwl.basic.mapper.StatisticsMapper;
|
import com.ycwl.basic.mapper.StatisticsMapper;
|
||||||
@@ -34,7 +35,7 @@ public class BrokerBiz {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ScenicRepository scenicRepository;
|
private ScenicRepository scenicRepository;
|
||||||
@Autowired
|
@Autowired
|
||||||
private StatisticsMapper statisticsMapper;
|
private StatsQueryService statsQueryService;
|
||||||
|
|
||||||
public void processOrder(Long orderId) {
|
public void processOrder(Long orderId) {
|
||||||
log.info("开始处理订单分佣,订单ID:{}", orderId);
|
log.info("开始处理订单分佣,订单ID:{}", orderId);
|
||||||
@@ -52,7 +53,7 @@ public class BrokerBiz {
|
|||||||
if (scenicConfig.getInteger("sample_store_day") != null) {
|
if (scenicConfig.getInteger("sample_store_day") != null) {
|
||||||
expireDay = scenicConfig.getInteger("sample_store_day");
|
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()) {
|
if (brokerIdList == null || brokerIdList.isEmpty()) {
|
||||||
log.info("用户与推客无关,订单ID:{}", orderId);
|
log.info("用户与推客无关,订单ID:{}", orderId);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ public class FaceStatusManager {
|
|||||||
*/
|
*/
|
||||||
private final Cache<String, Integer> templateRenderCache;
|
private final Cache<String, Integer> templateRenderCache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图素材版本缓存
|
||||||
|
* 键:faceId:puzzleTemplateId -> 当时的图片源数量
|
||||||
|
* 用于判断拼图模板的素材是否发生变化,避免重复生成
|
||||||
|
*/
|
||||||
|
private final Cache<String, Integer> puzzleSourceVersionCache;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private TaskMapper taskMapper;
|
private TaskMapper taskMapper;
|
||||||
|
|
||||||
@@ -61,6 +68,11 @@ public class FaceStatusManager {
|
|||||||
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
|
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
|
||||||
.maximumSize(10000)
|
.maximumSize(10000)
|
||||||
.build();
|
.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);
|
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;
|
package com.ycwl.basic.biz;
|
||||||
|
|
||||||
|
import com.ycwl.basic.clickhouse.service.StatsQueryService;
|
||||||
import com.ycwl.basic.enums.StatisticEnum;
|
import com.ycwl.basic.enums.StatisticEnum;
|
||||||
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||||
import com.ycwl.basic.mapper.OrderMapper;
|
import com.ycwl.basic.mapper.OrderMapper;
|
||||||
@@ -66,8 +67,10 @@ public class OrderBiz {
|
|||||||
private PrinterService printerService;
|
private PrinterService printerService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private IPriceCalculationService iPriceCalculationService;
|
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 priceObj = new PriceObj();
|
||||||
priceObj.setGoodsType(goodsType);
|
priceObj.setGoodsType(goodsType);
|
||||||
priceObj.setGoodsId(goodsId);
|
priceObj.setGoodsId(goodsId);
|
||||||
@@ -99,8 +102,10 @@ public class OrderBiz {
|
|||||||
vlogProductItem.setQuantity(videoTaskRepository.getTaskLensNum(video.getTaskId()));
|
vlogProductItem.setQuantity(videoTaskRepository.getTaskLensNum(video.getTaskId()));
|
||||||
vlogProductItem.setScenicId(scenicId.toString());
|
vlogProductItem.setScenicId(scenicId.toString());
|
||||||
vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem));
|
vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem));
|
||||||
|
vlogCalculationRequest.setUserId(memberId);
|
||||||
vlogCalculationRequest.setFaceId(priceObj.getFaceId());
|
vlogCalculationRequest.setFaceId(priceObj.getFaceId());
|
||||||
vlogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
vlogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||||
|
vlogCalculationRequest.setAutoUseCoupon(true);
|
||||||
PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest);
|
PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest);
|
||||||
priceObj.setPrice(vlogCalculationResult.getFinalAmount());
|
priceObj.setPrice(vlogCalculationResult.getFinalAmount());
|
||||||
priceObj.setSlashPrice(vlogCalculationResult.getOriginalAmount());
|
priceObj.setSlashPrice(vlogCalculationResult.getOriginalAmount());
|
||||||
@@ -120,13 +125,33 @@ public class OrderBiz {
|
|||||||
if (face != null) {
|
if (face != null) {
|
||||||
calculationRequest.setUserId(face.getMemberId());
|
calculationRequest.setUserId(face.getMemberId());
|
||||||
}
|
}
|
||||||
|
calculationRequest.setUserId(memberId);
|
||||||
calculationRequest.setFaceId(goodsId);
|
calculationRequest.setFaceId(goodsId);
|
||||||
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||||
|
calculationRequest.setAutoUseCoupon(true);
|
||||||
PriceCalculationResult priceCalculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
PriceCalculationResult priceCalculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
||||||
priceObj.setPrice(priceCalculationResult.getFinalAmount());
|
priceObj.setPrice(priceCalculationResult.getFinalAmount());
|
||||||
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
|
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
|
||||||
priceObj.setFaceId(goodsId);
|
priceObj.setFaceId(goodsId);
|
||||||
break;
|
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:
|
case 13:
|
||||||
PriceCalculationRequest aiCamCalculationRequest = new PriceCalculationRequest();
|
PriceCalculationRequest aiCamCalculationRequest = new PriceCalculationRequest();
|
||||||
ProductItem aiCamProductItem = new ProductItem();
|
ProductItem aiCamProductItem = new ProductItem();
|
||||||
@@ -135,7 +160,10 @@ public class OrderBiz {
|
|||||||
aiCamProductItem.setPurchaseCount(1);
|
aiCamProductItem.setPurchaseCount(1);
|
||||||
aiCamProductItem.setScenicId(scenicId.toString());
|
aiCamProductItem.setScenicId(scenicId.toString());
|
||||||
aiCamCalculationRequest.setProducts(Collections.singletonList(aiCamProductItem));
|
aiCamCalculationRequest.setProducts(Collections.singletonList(aiCamProductItem));
|
||||||
|
aiCamCalculationRequest.setUserId(memberId);
|
||||||
|
aiCamCalculationRequest.setFaceId(goodsId);
|
||||||
aiCamCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
aiCamCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||||
|
aiCamCalculationRequest.setAutoUseCoupon(true);
|
||||||
PriceCalculationResult aiCamPriceCalculationResult = iPriceCalculationService.calculatePrice(aiCamCalculationRequest);
|
PriceCalculationResult aiCamPriceCalculationResult = iPriceCalculationService.calculatePrice(aiCamCalculationRequest);
|
||||||
priceObj.setPrice(aiCamPriceCalculationResult.getFinalAmount());
|
priceObj.setPrice(aiCamPriceCalculationResult.getFinalAmount());
|
||||||
priceObj.setSlashPrice(aiCamPriceCalculationResult.getOriginalAmount());
|
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) {
|
if (priceObj == null) {
|
||||||
return respVO;
|
return respVO;
|
||||||
}
|
}
|
||||||
@@ -229,7 +257,7 @@ public class OrderBiz {
|
|||||||
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
||||||
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
|
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
|
||||||
statisticsRecordAddReq.setMemberId(order.getMemberId());
|
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)){//
|
if(!Long.valueOf(1014).equals(enterType)){//
|
||||||
statisticsRecordAddReq.setType(StatisticEnum.ON_SITE_PAYMENT.code);
|
statisticsRecordAddReq.setType(StatisticEnum.ON_SITE_PAYMENT.code);
|
||||||
}else {
|
}else {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.ycwl.basic.product.capability.ProductTypeCapability;
|
|||||||
import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService;
|
import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService;
|
||||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||||
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
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.FaceRepository;
|
||||||
import com.ycwl.basic.repository.MemberRelationRepository;
|
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||||
import com.ycwl.basic.repository.OrderRepository;
|
import com.ycwl.basic.repository.OrderRepository;
|
||||||
@@ -50,6 +51,8 @@ public class PriceBiz {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private PuzzleTemplateMapper puzzleTemplateMapper;
|
private PuzzleTemplateMapper puzzleTemplateMapper;
|
||||||
@Autowired
|
@Autowired
|
||||||
|
private PuzzleRepository puzzleRepository;
|
||||||
|
@Autowired
|
||||||
private IProductTypeCapabilityManagementService productTypeCapabilityManagementService;
|
private IProductTypeCapabilityManagementService productTypeCapabilityManagementService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private OrderRepository orderRepository;
|
private OrderRepository orderRepository;
|
||||||
@@ -74,8 +77,8 @@ public class PriceBiz {
|
|||||||
goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
|
goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 拼图
|
// 拼图(使用缓存)
|
||||||
puzzleTemplateMapper.list(scenicId, null, 1).forEach(puzzleTemplate -> {
|
puzzleRepository.listTemplateByScenic(scenicId).forEach(puzzleTemplate -> {
|
||||||
GoodsListRespVO goods = new GoodsListRespVO();
|
GoodsListRespVO goods = new GoodsListRespVO();
|
||||||
goods.setGoodsId(puzzleTemplate.getId());
|
goods.setGoodsId(puzzleTemplate.getId());
|
||||||
goods.setGoodsName(puzzleTemplate.getName());
|
goods.setGoodsName(puzzleTemplate.getName());
|
||||||
@@ -131,7 +134,7 @@ public class PriceBiz {
|
|||||||
|
|
||||||
case "PHOTO_LOG":
|
case "PHOTO_LOG":
|
||||||
// 从 template 表查询pLog模板
|
// 从 template 表查询pLog模板
|
||||||
List<PuzzleTemplateEntity> puzzleList = puzzleTemplateMapper.list(scenicId, null, null);
|
List<PuzzleTemplateEntity> puzzleList = puzzleRepository.listTemplateByScenic(scenicId);
|
||||||
puzzleList.stream()
|
puzzleList.stream()
|
||||||
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
|
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
|
||||||
.forEach(goodsList::add);
|
.forEach(goodsList::add);
|
||||||
|
|||||||
@@ -79,12 +79,26 @@ public interface StatsQueryService {
|
|||||||
List<HashMap<String, Object>> getDailyScanStats(Long brokerId, Date startTime, Date endTime);
|
List<HashMap<String, Object>> getDailyScanStats(Long brokerId, Date startTime, Date endTime);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按小时统计扫码人数
|
* 按小时统计扫码人数(仅返回统计数据,不含订单)
|
||||||
|
* 返回格式: [{t: "MM-dd HH", count: "xxx"}, ...]
|
||||||
*/
|
*/
|
||||||
List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query);
|
List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按日期统计扫码人数
|
* 按日期统计扫码人数(仅返回统计数据,不含订单)
|
||||||
|
* 返回格式: [{t: "MM-dd", count: "xxx"}, ...]
|
||||||
*/
|
*/
|
||||||
List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query);
|
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("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||||
sql.append("WHERE r.action = 'LOAD' ");
|
sql.append("WHERE r.action = 'LOAD' ");
|
||||||
sql.append("AND r.identifier = 'pages/videoSynthesis/buy' ");
|
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') = '' ");
|
sql.append("AND JSONExtractString(r.params, 'share') = '' ");
|
||||||
if (query.getStartTime() != null) {
|
if (query.getStartTime() != null) {
|
||||||
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
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("FROM t_stats_record r ");
|
||||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||||
sql.append("WHERE r.action = 'FACE_UPLOAD' ");
|
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) {
|
if (query.getStartTime() != null) {
|
||||||
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
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("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("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||||
sql.append("WHERE r.action = 'FACE_UPLOAD' ");
|
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) {
|
if (query.getStartTime() != null) {
|
||||||
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||||
}
|
}
|
||||||
@@ -357,8 +363,6 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
|||||||
sql.append(") ");
|
sql.append(") ");
|
||||||
sql.append("AND r.action = 'LAUNCH' ");
|
sql.append("AND r.action = 'LAUNCH' ");
|
||||||
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
|
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("GROUP BY toStartOfHour(s.create_time) ");
|
||||||
sql.append("ORDER 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(") ");
|
||||||
sql.append("AND r.action = 'LAUNCH' ");
|
sql.append("AND r.action = 'LAUNCH' ");
|
||||||
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
|
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
|
||||||
sql.append("AND s.create_time BETWEEN ").append(formatDateTime(query.getStartTime()));
|
sql.append("GROUP BY toStartOfDay(s.create_time) ");
|
||||||
sql.append(" AND ").append(formatDateTime(query.getEndTime())).append(" ");
|
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("GROUP BY toStartOfDay(s.create_time) ");
|
||||||
sql.append("ORDER 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) {
|
public List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query) {
|
||||||
return statisticsMapper.scanCodeMemberChartByDate(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")
|
@PostMapping("/{faceId}/bind")
|
||||||
public ApiResponse<String> bind(@PathVariable Long faceId) {
|
public ApiResponse<String> bind(@PathVariable Long faceId) {
|
||||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
// dummy item
|
||||||
Long userId = worker.getUserId();
|
faceService.matchFaceId(faceId, true);
|
||||||
faceService.bindFace(faceId, userId);
|
|
||||||
return ApiResponse.success("OK");
|
return ApiResponse.success("OK");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,12 +53,6 @@ public class AppGoodsController {
|
|||||||
return ApiResponse.success(count);
|
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")
|
@PostMapping("/sourceGoodsList/download")
|
||||||
public ApiResponse<List<GoodsUrlVO>> sourceGoodsListDownload(@RequestBody GoodsReqQuery query) {
|
public ApiResponse<List<GoodsUrlVO>> sourceGoodsListDownload(@RequestBody GoodsReqQuery query) {
|
||||||
List<GoodsUrlVO> goodsUrlList = goodsService.sourceGoodsListDownload(query);
|
List<GoodsUrlVO> goodsUrlList = goodsService.sourceGoodsListDownload(query);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.ycwl.basic.controller.mobile;
|
package com.ycwl.basic.controller.mobile;
|
||||||
|
|
||||||
import com.ycwl.basic.biz.OrderBiz;
|
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.order.IsBuyRespVO;
|
||||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
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.enums.ProductType;
|
||||||
import com.ycwl.basic.pricing.service.IPriceCalculationService;
|
import com.ycwl.basic.pricing.service.IPriceCalculationService;
|
||||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
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.repository.FaceRepository;
|
||||||
import com.ycwl.basic.service.printer.PrinterService;
|
import com.ycwl.basic.service.printer.PrinterService;
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
@@ -32,7 +31,7 @@ import java.util.stream.Collectors;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AppPuzzleController {
|
public class AppPuzzleController {
|
||||||
|
|
||||||
private final PuzzleGenerationRecordMapper recordMapper;
|
private final PuzzleRepository puzzleRepository;
|
||||||
private final FaceRepository faceRepository;
|
private final FaceRepository faceRepository;
|
||||||
private final IPriceCalculationService iPriceCalculationService;
|
private final IPriceCalculationService iPriceCalculationService;
|
||||||
private final PrinterService printerService;
|
private final PrinterService printerService;
|
||||||
@@ -46,7 +45,7 @@ public class AppPuzzleController {
|
|||||||
if (faceId == null) {
|
if (faceId == null) {
|
||||||
return ApiResponse.fail("faceId不能为空");
|
return ApiResponse.fail("faceId不能为空");
|
||||||
}
|
}
|
||||||
int count = recordMapper.countByFaceId(faceId);
|
int count = puzzleRepository.countRecordsByFaceId(faceId);
|
||||||
return ApiResponse.success(count);
|
return ApiResponse.success(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ public class AppPuzzleController {
|
|||||||
if (faceId == null) {
|
if (faceId == null) {
|
||||||
return ApiResponse.fail("faceId不能为空");
|
return ApiResponse.fail("faceId不能为空");
|
||||||
}
|
}
|
||||||
List<PuzzleGenerationRecordEntity> records = recordMapper.listByFaceId(faceId);
|
List<PuzzleGenerationRecordEntity> records = puzzleRepository.getRecordsByFaceId(faceId);
|
||||||
List<ContentPageVO> result = records.stream()
|
List<ContentPageVO> result = records.stream()
|
||||||
.map(this::convertToContentPageVO)
|
.map(this::convertToContentPageVO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
@@ -73,7 +72,7 @@ public class AppPuzzleController {
|
|||||||
if (recordId == null) {
|
if (recordId == null) {
|
||||||
return ApiResponse.fail("recordId不能为空");
|
return ApiResponse.fail("recordId不能为空");
|
||||||
}
|
}
|
||||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||||
if (record == null) {
|
if (record == null) {
|
||||||
return ApiResponse.fail("未找到对应的拼图记录");
|
return ApiResponse.fail("未找到对应的拼图记录");
|
||||||
}
|
}
|
||||||
@@ -89,7 +88,7 @@ public class AppPuzzleController {
|
|||||||
if (recordId == null) {
|
if (recordId == null) {
|
||||||
return ApiResponse.fail("recordId不能为空");
|
return ApiResponse.fail("recordId不能为空");
|
||||||
}
|
}
|
||||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||||
if (record == null) {
|
if (record == null) {
|
||||||
return ApiResponse.fail("未找到对应的拼图记录");
|
return ApiResponse.fail("未找到对应的拼图记录");
|
||||||
}
|
}
|
||||||
@@ -108,7 +107,7 @@ public class AppPuzzleController {
|
|||||||
if (recordId == null) {
|
if (recordId == null) {
|
||||||
return ApiResponse.fail("recordId不能为空");
|
return ApiResponse.fail("recordId不能为空");
|
||||||
}
|
}
|
||||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||||
if (record == null) {
|
if (record == null) {
|
||||||
return ApiResponse.fail("未找到对应的拼图记录");
|
return ApiResponse.fail("未找到对应的拼图记录");
|
||||||
}
|
}
|
||||||
@@ -142,14 +141,14 @@ public class AppPuzzleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 查询拼图记录
|
// 查询拼图记录
|
||||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||||
if (record == null) {
|
if (record == null) {
|
||||||
return ApiResponse.fail("未找到对应的拼图记录");
|
return ApiResponse.fail("未找到对应的拼图记录");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有图片URL
|
// 检查是否有图片URL
|
||||||
String resultImageUrl = record.getResultImageUrl();
|
String imageUrl = record.getResultImageUrl();
|
||||||
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
|
if (imageUrl == null || imageUrl.isEmpty()) {
|
||||||
return ApiResponse.fail("该拼图记录没有可用的图片URL");
|
return ApiResponse.fail("该拼图记录没有可用的图片URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,8 +163,8 @@ public class AppPuzzleController {
|
|||||||
face.getMemberId(),
|
face.getMemberId(),
|
||||||
face.getScenicId(),
|
face.getScenicId(),
|
||||||
record.getFaceId(),
|
record.getFaceId(),
|
||||||
resultImageUrl,
|
imageUrl,
|
||||||
0L // 打印特有
|
recordId // 拼图记录ID,用于关联 puzzle_record 表
|
||||||
);
|
);
|
||||||
|
|
||||||
if (memberPrintId == null) {
|
if (memberPrintId == null) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package com.ycwl.basic.controller.mobile;
|
package com.ycwl.basic.controller.mobile;
|
||||||
|
|
||||||
import com.ycwl.basic.mapper.TemplateMapper;
|
import com.ycwl.basic.annotation.IgnoreToken;
|
||||||
import com.ycwl.basic.model.pc.template.entity.TemplateEntity;
|
|
||||||
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
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.repository.TemplateRepository;
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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 {
|
public class AppTemplateController {
|
||||||
|
|
||||||
private final TemplateRepository templateRepository;
|
private final TemplateRepository templateRepository;
|
||||||
|
private final PuzzleRepository puzzleRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据模板ID获取封面URL
|
* 根据模板ID获取封面URL
|
||||||
@@ -45,4 +51,38 @@ public class AppTemplateController {
|
|||||||
|
|
||||||
return ApiResponse.success(coverUrl);
|
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;
|
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.req.NotificationAuthRecordReq;
|
||||||
import com.ycwl.basic.model.mobile.notify.resp.NotificationAuthRecordResp;
|
import com.ycwl.basic.model.mobile.notify.resp.NotificationAuthRecordResp;
|
||||||
import com.ycwl.basic.model.mobile.notify.resp.ScenicTemplateAuthResp;
|
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.service.UserNotificationAuthorizationService;
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||||
@@ -14,7 +16,9 @@ import org.springframework.util.CollectionUtils;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户通知授权记录Controller (移动端API)
|
* 用户通知授权记录Controller (移动端API)
|
||||||
@@ -41,7 +45,8 @@ public class UserNotificationAuthController {
|
|||||||
@PostMapping("/record")
|
@PostMapping("/record")
|
||||||
public ApiResponse<NotificationAuthRecordResp> recordAuthorization(
|
public ApiResponse<NotificationAuthRecordResp> recordAuthorization(
|
||||||
@RequestBody NotificationAuthRecordReq req) {
|
@RequestBody NotificationAuthRecordReq req) {
|
||||||
log.debug("记录用户通知授权: templateIds={}, scenicId={}", req.getTemplateIds(), req.getScenicId());
|
log.debug("记录用户通知授权: templateIds={}, scenicId={}, requestId={}",
|
||||||
|
req.getTemplateIds(), req.getScenicId(), req.getRequestId());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前用户ID
|
// 获取当前用户ID
|
||||||
@@ -50,7 +55,7 @@ public class UserNotificationAuthController {
|
|||||||
// 调用批量授权记录方法
|
// 调用批量授权记录方法
|
||||||
List<UserNotificationAuthorizationService.AuthorizationRecord> records =
|
List<UserNotificationAuthorizationService.AuthorizationRecord> records =
|
||||||
userNotificationAuthorizationService.batchRecordAuthorization(
|
userNotificationAuthorizationService.batchRecordAuthorization(
|
||||||
memberId, req.getTemplateIds(), req.getScenicId());
|
memberId, req.getTemplateIds(), req.getScenicId(), req.getRequestId());
|
||||||
|
|
||||||
NotificationAuthRecordResp resp = new NotificationAuthRecordResp();
|
NotificationAuthRecordResp resp = new NotificationAuthRecordResp();
|
||||||
|
|
||||||
@@ -93,98 +98,42 @@ public class UserNotificationAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取景区通知模板ID及用户授权余额
|
* 批量查询用户授权余额
|
||||||
* 复制AppWxNotifyController中的逻辑,并额外返回用户对应的授权余额
|
* 返回 Map<wechatTemplateId, remainingCount>
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{scenicId}/templates")
|
@PostMapping("/batch-remaining")
|
||||||
public ApiResponse<ScenicTemplateAuthResp> getScenicTemplatesWithAuth(@PathVariable("scenicId") Long scenicId) {
|
public ApiResponse<Map<String, Integer>> batchGetRemainingCount(
|
||||||
log.debug("获取景区通知模板ID及用户授权余额: scenicId={}", scenicId);
|
@RequestBody BatchRemainingCountReq req) {
|
||||||
|
log.debug("批量查询用户授权余额: templateIds={}, scenicId={}",
|
||||||
|
req.getTemplateIds(), req.getScenicId());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前用户ID
|
|
||||||
Long memberId = JwtTokenUtil.getWorker().getUserId();
|
Long memberId = JwtTokenUtil.getWorker().getUserId();
|
||||||
|
if (memberId == null) {
|
||||||
// 获取景区的所有模板ID(复制自AppWxNotifyController的逻辑)
|
return ApiResponse.fail("用户未登录");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.setTemplates(templateAuthInfos);
|
if (CollectionUtils.isEmpty(req.getTemplateIds())) {
|
||||||
|
return ApiResponse.success(new HashMap<>());
|
||||||
|
}
|
||||||
|
|
||||||
log.debug("成功获取景区通知模板ID及用户授权余额: scenicId={}, templateCount={}, memberId={}",
|
Map<String, UserNotificationAuthorizationEntity> authMap =
|
||||||
scenicId, templateIds.size(), memberId);
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("获取景区通知模板ID及用户授权余额失败: scenicId={}", scenicId, e);
|
log.error("批量查询用户授权余额失败", e);
|
||||||
return ApiResponse.fail("获取授权信息失败: " + e.getMessage());
|
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.entity.DeviceEntity;
|
||||||
import com.ycwl.basic.model.pc.device.req.VideoContinuityReportReq;
|
import com.ycwl.basic.model.pc.device.req.VideoContinuityReportReq;
|
||||||
import com.ycwl.basic.repository.DeviceRepository;
|
import com.ycwl.basic.repository.DeviceRepository;
|
||||||
import com.ycwl.basic.task.DeviceVideoContinuityCheckTask;
|
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -37,7 +36,6 @@ public class DeviceVideoContinuityController {
|
|||||||
|
|
||||||
private final RedisTemplate<String, String> redisTemplate;
|
private final RedisTemplate<String, String> redisTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final DeviceVideoContinuityCheckTask checkTask;
|
|
||||||
private final DeviceRepository deviceRepository;
|
private final DeviceRepository deviceRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,15 +76,7 @@ public class DeviceVideoContinuityController {
|
|||||||
@PostMapping("/{deviceId}/check")
|
@PostMapping("/{deviceId}/check")
|
||||||
public ApiResponse<DeviceVideoContinuityCache> manualCheck(@PathVariable Long deviceId) {
|
public ApiResponse<DeviceVideoContinuityCache> manualCheck(@PathVariable Long deviceId) {
|
||||||
log.info("手动触发设备 {} 的视频连续性检查", deviceId);
|
log.info("手动触发设备 {} 的视频连续性检查", deviceId);
|
||||||
|
return ApiResponse.success(null);
|
||||||
try {
|
|
||||||
DeviceVideoContinuityCache result = checkTask.manualCheck(deviceId);
|
|
||||||
return ApiResponse.success(result);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("手动检查设备 {} 视频连续性失败", deviceId, e);
|
|
||||||
return ApiResponse.buildResponse(500, null, "检查失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ public class SourceController {
|
|||||||
Map<String, Object> result = printerService.createVirtualOrder(
|
Map<String, Object> result = printerService.createVirtualOrder(
|
||||||
request.getSourceId(),
|
request.getSourceId(),
|
||||||
request.getScenicId(),
|
request.getScenicId(),
|
||||||
request.getPrinterId()
|
request.getPrinterId(),
|
||||||
|
request.getNeedEnhance(),
|
||||||
|
request.getPrintImgUrl()
|
||||||
);
|
);
|
||||||
return ApiResponse.success(result);
|
return ApiResponse.success(result);
|
||||||
} catch (Exception e) {
|
} 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")
|
@GetMapping("/face/{faceId}/qrcode")
|
||||||
public void getFaceQrcode(@PathVariable("faceId") Long faceId, HttpServletResponse response) throws Exception {
|
public void getFaceQrcode(@PathVariable("faceId") Long faceId, HttpServletResponse response) throws Exception {
|
||||||
File qrcode = new File("qrcode_face_" + faceId + ".jpg");
|
String url = pcFaceService.bindWxaCode(faceId);
|
||||||
try {
|
response.sendRedirect(url);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,5 +159,36 @@ public class PrinterTvController {
|
|||||||
return ApiResponse.success(resp);
|
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) {
|
} catch (Exception e) {
|
||||||
// 异步任务失败不影响主流程,仅记录日志
|
// 异步任务失败不影响主流程,仅记录日志
|
||||||
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
|
log.error("异步生成拼图模板失败: scenicId={}, faceId={}, e={}", scenicId, faceId, e.getMessage());
|
||||||
}
|
}
|
||||||
}, "PuzzleTemplateGenerator-" + scenicId + "-" + faceId).start();
|
}, "PuzzleTemplateGenerator-" + scenicId + "-" + faceId).start();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class DeleteOldRelationsStage extends AbstractPipelineStage<FaceMatchingC
|
|||||||
@Autowired
|
@Autowired
|
||||||
private MemberRelationRepository memberRelationRepository;
|
private MemberRelationRepository memberRelationRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.ycwl.basic.biz.FaceStatusManager faceStatusManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return "DeleteOldRelations";
|
return "DeleteOldRelations";
|
||||||
@@ -60,6 +63,7 @@ public class DeleteOldRelationsStage extends AbstractPipelineStage<FaceMatchingC
|
|||||||
|
|
||||||
// 3. 清除缓存
|
// 3. 清除缓存
|
||||||
memberRelationRepository.clearSCacheByFace(faceId);
|
memberRelationRepository.clearSCacheByFace(faceId);
|
||||||
|
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
|
||||||
|
|
||||||
log.debug("人脸旧关系数据删除完成:faceId={}", faceId);
|
log.debug("人脸旧关系数据删除完成:faceId={}", faceId);
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class PersistRelationsStage extends AbstractPipelineStage<FaceMatchingCon
|
|||||||
@Autowired
|
@Autowired
|
||||||
private MemberRelationRepository memberRelationRepository;
|
private MemberRelationRepository memberRelationRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.ycwl.basic.biz.FaceStatusManager faceStatusManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return "PersistRelations";
|
return "PersistRelations";
|
||||||
@@ -87,6 +90,7 @@ public class PersistRelationsStage extends AbstractPipelineStage<FaceMatchingCon
|
|||||||
|
|
||||||
// 4. 清除缓存
|
// 4. 清除缓存
|
||||||
memberRelationRepository.clearSCacheByFace(faceId);
|
memberRelationRepository.clearSCacheByFace(faceId);
|
||||||
|
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
|
||||||
|
|
||||||
return StageResult.success(String.format("持久化了%d条关联关系", validFiltered.size()));
|
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);
|
JSONObject retryResponse = client.addUser(base64Image, "BASE64", dbName, entityId, options);
|
||||||
if (retryResponse.getInt("error_code") == 0) {
|
if (retryResponse.getInt("error_code") == 0) {
|
||||||
log.info("使用base64重试添加人脸成功,entityId: {}", entityId);
|
|
||||||
AddFaceResp resp = new AddFaceResp();
|
AddFaceResp resp = new AddFaceResp();
|
||||||
resp.setScore(100f);
|
resp.setScore(100f);
|
||||||
return resp;
|
return resp;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.ycwl.basic.image.pipeline.stages;
|
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.image.watermark.enums.ImageWatermarkOperatorEnum;
|
||||||
|
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@@ -41,4 +43,28 @@ public class WatermarkConfig {
|
|||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private final Double scale = 1.0;
|
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.core.PhotoProcessContext;
|
||||||
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
||||||
import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
|
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.entity.WatermarkInfo;
|
||||||
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
|
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
|
||||||
import com.ycwl.basic.image.watermark.operator.IOperator;
|
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.AbstractPipelineStage;
|
||||||
import com.ycwl.basic.pipeline.core.StageResult;
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* 水印处理Stage
|
* 水印处理Stage
|
||||||
* 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印
|
* 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印
|
||||||
|
* 支持边缘端渲染(可选)
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@StageConfig(
|
@StageConfig(
|
||||||
@@ -127,6 +130,19 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
|
|||||||
File watermarkedFile = context.getTempFileManager()
|
File watermarkedFile = context.getTempFileManager()
|
||||||
.createTempFile("watermark_" + type.getType(), "." + fileExt);
|
.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);
|
WatermarkInfo watermarkInfo = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
|
||||||
|
|
||||||
IOperator operator = ImageWatermarkFactory.get(type);
|
IOperator operator = ImageWatermarkFactory.get(type);
|
||||||
@@ -143,6 +159,46 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
|
|||||||
type.getType(), result.length() / 1024));
|
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.enums.ImageWatermarkOperatorEnum;
|
||||||
import com.ycwl.basic.image.watermark.exception.ImageWatermarkUnsupportedException;
|
import com.ycwl.basic.image.watermark.exception.ImageWatermarkUnsupportedException;
|
||||||
import com.ycwl.basic.image.watermark.operator.IOperator;
|
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.LeicaWatermarkOperator;
|
||||||
import com.ycwl.basic.image.watermark.operator.NormalWatermarkOperator;
|
import com.ycwl.basic.image.watermark.operator.NormalWatermarkOperator;
|
||||||
import com.ycwl.basic.image.watermark.operator.PrinterDefaultWatermarkOperator;
|
import com.ycwl.basic.image.watermark.operator.PrinterDefaultWatermarkOperator;
|
||||||
@@ -18,11 +17,11 @@ public class ImageWatermarkFactory {
|
|||||||
}
|
}
|
||||||
public static IOperator get(ImageWatermarkOperatorEnum type) {
|
public static IOperator get(ImageWatermarkOperatorEnum type) {
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
case WATERMARK -> new DefaultImageWatermarkOperator();
|
|
||||||
case NORMAL -> new NormalWatermarkOperator();
|
case NORMAL -> new NormalWatermarkOperator();
|
||||||
case LEICA -> new LeicaWatermarkOperator();
|
case LEICA -> new LeicaWatermarkOperator();
|
||||||
case PRINTER_DEFAULT -> new PrinterDefaultWatermarkOperator();
|
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
|
@Getter
|
||||||
public enum ImageWatermarkOperatorEnum {
|
public enum ImageWatermarkOperatorEnum {
|
||||||
WATERMARK("defW", "jpg"),
|
LEICA("leica", "jpg"),
|
||||||
LEICA("leica", "png"),
|
NORMAL("normal", "jpg"),
|
||||||
NORMAL("normal", "png"),
|
PRINTER_DEFAULT("pDefault", "jpg"),
|
||||||
PRINTER_DEFAULT("pDefault", "png");
|
PUZZLE_PRINT("puzzle_print", "jpg");
|
||||||
|
|
||||||
private final String type;
|
private final String type;
|
||||||
private final String preferFileType;
|
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>> orderChartByHour(CommonQueryReq query);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按小时统计扫码人数(仅统计数据,不含订单)
|
||||||
|
*/
|
||||||
List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query);
|
List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按日期统计扫码人数(仅统计数据,不含订单)
|
||||||
|
*/
|
||||||
List<HashMap<String, String>> scanCodeMemberChartByDate(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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计分销员扫码次数
|
* 统计分销员扫码次数
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -89,4 +89,18 @@ public interface UserNotificationAuthorizationMapper extends BaseMapper<UserNoti
|
|||||||
@Param("templateId") String templateId,
|
@Param("templateId") String templateId,
|
||||||
@Param("scenicId") Long scenicId
|
@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;
|
||||||
|
}
|
||||||
@@ -26,4 +26,13 @@ public class NotificationAuthRecordReq {
|
|||||||
*/
|
*/
|
||||||
@NotNull(message = "景区ID不能为空")
|
@NotNull(message = "景区ID不能为空")
|
||||||
private Long scenicId;
|
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 memberId;
|
||||||
private Long faceId;
|
private Long faceId;
|
||||||
private Long sourceId;
|
private Long sourceId;
|
||||||
|
private String imageType;
|
||||||
private String origUrl;
|
private String origUrl;
|
||||||
private String cropUrl;
|
private String cropUrl;
|
||||||
private String printUrl;
|
private String printUrl;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public class MemberPrintResp {
|
|||||||
private Integer id;
|
private Integer id;
|
||||||
private Long scenicId;
|
private Long scenicId;
|
||||||
private Long sourceId;
|
private Long sourceId;
|
||||||
|
private String imageType;
|
||||||
private String scenicName;
|
private String scenicName;
|
||||||
private Long faceId;
|
private Long faceId;
|
||||||
private Long memberId;
|
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(可选)
|
* 打印机ID(可选)
|
||||||
*/
|
*/
|
||||||
private Integer printerId;
|
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 lombok.Data;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.Date;
|
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> {
|
public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询有效的优惠券配置
|
* 查询有效的优惠券配置(可领取的)
|
||||||
*/
|
*/
|
||||||
@Select("SELECT * FROM price_coupon_config WHERE is_active = 1 " +
|
@Select("SELECT * FROM price_coupon_config WHERE is_active = 1 " +
|
||||||
"AND valid_from <= NOW() AND valid_until > NOW() " +
|
"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();
|
List<PriceCouponConfig> selectValidCoupons();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据ID查询优惠券(包括使用数量检查)
|
* 根据ID查询优惠券(包括库存检查)
|
||||||
*/
|
*/
|
||||||
@Select("SELECT * FROM price_coupon_config WHERE id = #{couponId} " +
|
@Select("SELECT * FROM price_coupon_config WHERE id = #{couponId} " +
|
||||||
"AND is_active = 1 AND valid_from <= NOW() AND valid_until > NOW() " +
|
"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);
|
PriceCouponConfig selectValidCouponById(Long couponId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 增加优惠券使用数量
|
* 增加优惠券使用数量(支持无限量优惠券)
|
||||||
|
* 当 total_quantity 为 NULL 或 <= 0 时表示不限制使用数量
|
||||||
*/
|
*/
|
||||||
@Update("UPDATE price_coupon_config SET used_quantity = used_quantity + 1, " +
|
@Update("UPDATE price_coupon_config SET used_quantity = COALESCE(used_quantity, 0) + 1, " +
|
||||||
"update_time = NOW() WHERE id = #{couponId} AND used_quantity < total_quantity")
|
"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);
|
int incrementUsedQuantity(Long couponId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 原子性增加已领取数量(仅对有限库存的优惠券生效)
|
* 原子性增加已领取数量(仅对有限库存的优惠券生效,带库存检查)
|
||||||
*/
|
*/
|
||||||
@Update("UPDATE price_coupon_config SET claimed_quantity = COALESCE(claimed_quantity, 0) + 1, " +
|
@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 " +
|
"update_time = NOW() WHERE id = #{couponId} AND total_quantity IS NOT NULL AND total_quantity > 0 " +
|
||||||
"AND COALESCE(claimed_quantity, 0) < total_quantity")
|
"AND COALESCE(claimed_quantity, 0) < total_quantity")
|
||||||
int incrementClaimedQuantityIfAvailable(@Param("couponId") Long couponId);
|
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, " +
|
@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 " +
|
"is_active, scenic_id, create_time, update_time) VALUES " +
|
||||||
"(#{couponName}, #{couponType}, #{discountValue}, #{minAmount}, #{maxDiscount}, " +
|
"(#{couponName}, #{couponType}, #{discountValue}, #{minAmount}, #{maxDiscount}, " +
|
||||||
"#{applicableProducts}, #{requiredAttributeKeys}, #{totalQuantity}, #{usedQuantity}, #{validFrom}, #{validUntil}, " +
|
"#{applicableProducts}, #{requiredAttributeKeys}, #{totalQuantity}, #{usedQuantity}, " +
|
||||||
|
"#{claimedQuantity}, #{userClaimLimit}, #{validFrom}, #{validUntil}, " +
|
||||||
"#{isActive}, #{scenicId}, NOW(), NOW())")
|
"#{isActive}, #{scenicId}, NOW(), NOW())")
|
||||||
int insertCoupon(PriceCouponConfig coupon);
|
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}, " +
|
@Update("UPDATE price_coupon_config SET coupon_name = #{couponName}, coupon_type = #{couponType}, " +
|
||||||
"discount_value = #{discountValue}, min_amount = #{minAmount}, max_discount = #{maxDiscount}, " +
|
"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}, " +
|
"valid_from = #{validFrom}, valid_until = #{validUntil}, is_active = #{isActive}, " +
|
||||||
"scenic_id = #{scenicId}, update_time = NOW() WHERE id = #{id}")
|
"scenic_id = #{scenicId}, update_time = NOW() WHERE id = #{id}")
|
||||||
int updateCoupon(PriceCouponConfig coupon);
|
int updateCoupon(PriceCouponConfig coupon);
|
||||||
@@ -117,11 +129,11 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
|
|||||||
int deleteCoupon(Long id);
|
int deleteCoupon(Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询指定景区的有效优惠券配置
|
* 查询指定景区的有效优惠券配置(可领取的)
|
||||||
*/
|
*/
|
||||||
@Select("SELECT * FROM price_coupon_config WHERE is_active = 1 " +
|
@Select("SELECT * FROM price_coupon_config WHERE is_active = 1 " +
|
||||||
"AND valid_from <= NOW() AND valid_until > NOW() " +
|
"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})")
|
"AND (scenic_id IS NULL OR scenic_id = #{scenicId})")
|
||||||
List<PriceCouponConfig> selectValidCouponsByScenicId(@Param("scenicId") String 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.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -125,12 +124,17 @@ public class CouponServiceImpl implements ICouponService {
|
|||||||
List<String> applicableProductTypes = objectMapper.readValue(
|
List<String> applicableProductTypes = objectMapper.readValue(
|
||||||
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
|
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
|
||||||
|
|
||||||
discountableProducts = products.stream()
|
// 空数组表示不限制商品类型,适用于所有商品
|
||||||
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
|
if (applicableProductTypes == null || applicableProductTypes.isEmpty()) {
|
||||||
.toList();
|
// 不过滤,使用全部商品
|
||||||
|
} else {
|
||||||
|
discountableProducts = products.stream()
|
||||||
|
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (discountableProducts.isEmpty()) {
|
if (discountableProducts.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("解析适用商品类型失败", e);
|
log.error("解析适用商品类型失败", e);
|
||||||
@@ -198,6 +202,13 @@ public class CouponServiceImpl implements ICouponService {
|
|||||||
List<String> applicableProductTypes = objectMapper.readValue(
|
List<String> applicableProductTypes = objectMapper.readValue(
|
||||||
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
|
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()
|
return products.stream()
|
||||||
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
|
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
|
||||||
@@ -304,11 +315,11 @@ public class CouponServiceImpl implements ICouponService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 检查优惠券有效期
|
// 4. 检查优惠券有效期
|
||||||
LocalDateTime now = LocalDateTime.now();
|
Date now = new Date();
|
||||||
if (coupon.getValidFrom() != null && now.isBefore(coupon.getValidFrom())) {
|
if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
|
||||||
return CouponClaimResult.failure(CouponClaimResult.ERROR_COUPON_EXPIRED, "优惠券尚未生效");
|
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, "优惠券已过期");
|
return CouponClaimResult.failure(CouponClaimResult.ERROR_COUPON_EXPIRED, "优惠券已过期");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,17 +365,20 @@ public class CouponServiceImpl implements ICouponService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 9. 更新优惠券已领取数量(区分于已使用数量)
|
// 9. 更新优惠券已领取数量(区分于已使用数量)
|
||||||
// 仅在有总量限制时才更新claimedQuantity(totalQuantity为正整数)
|
|
||||||
if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) {
|
if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) {
|
||||||
|
// 有总量限制:使用带库存检查的原子更新
|
||||||
int affected = couponConfigMapper.incrementClaimedQuantityIfAvailable(coupon.getId());
|
int affected = couponConfigMapper.incrementClaimedQuantityIfAvailable(coupon.getId());
|
||||||
if (affected == 0) {
|
if (affected == 0) {
|
||||||
throw new CouponInvalidException(
|
throw new CouponInvalidException(
|
||||||
CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK,
|
CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK,
|
||||||
"优惠券已被领取完,请稍后重试");
|
"优惠券已被领取完,请稍后重试");
|
||||||
}
|
}
|
||||||
int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1;
|
} else {
|
||||||
coupon.setClaimedQuantity(updatedClaimedQuantity);
|
// 无总量限制:无条件增加已领取数量(用于统计)
|
||||||
|
couponConfigMapper.incrementClaimedQuantity(coupon.getId());
|
||||||
}
|
}
|
||||||
|
int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1;
|
||||||
|
coupon.setClaimedQuantity(updatedClaimedQuantity);
|
||||||
|
|
||||||
log.info("优惠券领取成功: userId={}, couponId={}, claimRecordId={}",
|
log.info("优惠券领取成功: userId={}, couponId={}, claimRecordId={}",
|
||||||
request.getUserId(), request.getCouponId(), claimRecord.getId());
|
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;
|
private Integer canPrint;
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户查看区域(裁切区域),格式:x,y,w,h
|
|
||||||
*/
|
|
||||||
private String userArea;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 元素列表
|
* 元素列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -71,11 +71,6 @@ public class TemplateCreateRequest {
|
|||||||
*/
|
*/
|
||||||
private Integer canPrint;
|
private Integer canPrint;
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户查看区域(裁切区域),格式:x,y,w,h
|
|
||||||
*/
|
|
||||||
private String userArea;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态:0-禁用 1-启用
|
* 状态: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.PuzzleEdgeTaskFailRequest;
|
||||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskSuccessRequest;
|
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskSuccessRequest;
|
||||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeUploadUrlsResponse;
|
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.PuzzleEdgeWorkerSyncRequest;
|
||||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncResponse;
|
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeWorkerSyncResponse;
|
||||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||||
@@ -36,9 +35,8 @@ public class PuzzleEdgeRenderTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/task/{taskId}/uploadUrls")
|
@PostMapping("/task/{taskId}/uploadUrls")
|
||||||
public ApiResponse<PuzzleEdgeUploadUrlsResponse> uploadUrls(@PathVariable Long taskId,
|
public ApiResponse<PuzzleEdgeUploadUrlsResponse> uploadUrls(@PathVariable Long taskId) {
|
||||||
@RequestBody PuzzleEdgeWorkerAuthRequest req) {
|
return ApiResponse.success(puzzleEdgeRenderTaskService.getUploadUrls(taskId));
|
||||||
return ApiResponse.success(puzzleEdgeRenderTaskService.getUploadUrls(taskId, req != null ? req.getAccessKey() : null));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/task/{taskId}/success")
|
@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")
|
@TableField("face_id")
|
||||||
private Long faceId;
|
private Long faceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务类型:PUZZLE-原始拼图 WATERMARK-水印拼图
|
||||||
|
*/
|
||||||
|
@TableField("task_type")
|
||||||
|
private String taskType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 水印类型(仅task_type=WATERMARK时有效)
|
||||||
|
*/
|
||||||
|
@TableField("watermark_type")
|
||||||
|
private String watermarkType;
|
||||||
|
|
||||||
@TableField("content_hash")
|
@TableField("content_hash")
|
||||||
private String contentHash;
|
private String contentHash;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ public class PuzzleEdgeWorkerIpInterceptor implements HandlerInterceptor {
|
|||||||
if (Ipv4CidrMatcher.matches(clientIp, properties.getAllowedIpCidr())) {
|
if (Ipv4CidrMatcher.matches(clientIp, properties.getAllowedIpCidr())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (Ipv4CidrMatcher.matches(clientIp, "127.0.0.1/8")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
log.warn("拒绝边缘 Worker 请求: uri={}, ip={}, allowedIpCidr={}",
|
log.warn("拒绝边缘 Worker 请求: uri={}, ip={}, allowedIpCidr={}",
|
||||||
request != null ? request.getRequestURI() : null,
|
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.Cache;
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
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.model.task.req.ClientStatusReqVo;
|
||||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeRenderTaskDTO;
|
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeRenderTaskDTO;
|
||||||
import com.ycwl.basic.puzzle.edge.dto.PuzzleEdgeTaskFailRequest;
|
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.PuzzleGenerationRecordEntity;
|
||||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
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.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.service.printer.PrinterService;
|
||||||
import com.ycwl.basic.storage.StorageFactory;
|
import com.ycwl.basic.storage.StorageFactory;
|
||||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
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_SUCCESS = 2;
|
||||||
private static final int STATUS_FAIL = 3;
|
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 int MAX_SYNC_TASKS = 5;
|
||||||
private static final long LEASE_MILLIS = TimeUnit.SECONDS.toMillis(20);
|
private static final long LEASE_MILLIS = TimeUnit.SECONDS.toMillis(20);
|
||||||
private static final long UPLOAD_URL_EXPIRE_MILLIS = TimeUnit.HOURS.toMillis(1);
|
private static final 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 ConcurrentHashMap<Long, WaitFutureEntry> waitFutures = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private final PuzzleGenerationRecordMapper recordMapper;
|
private final PuzzleGenerationRecordMapper recordMapper;
|
||||||
|
private final PuzzleWatermarkMapper puzzleWatermarkMapper;
|
||||||
private final PuzzleRepository puzzleRepository;
|
private final PuzzleRepository puzzleRepository;
|
||||||
private final PrinterService printerService;
|
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) {
|
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;
|
ClientStatusReqVo clientStatus = req != null ? req.getClientStatus() : null;
|
||||||
if (clientStatus != null) {
|
if (clientStatus != null) {
|
||||||
renderWorkerRepository.setWorkerHostStatus(worker.getId(), clientStatus);
|
log.debug("收到客户端状态上报: {}", clientStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
int maxTasks = req != null && req.getMaxTasks() != null ? req.getMaxTasks() : 1;
|
int maxTasks = req != null && req.getMaxTasks() != null ? req.getMaxTasks() : 1;
|
||||||
@@ -156,12 +175,12 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
|
|
||||||
PuzzleEdgeWorkerSyncResponse resp = new PuzzleEdgeWorkerSyncResponse();
|
PuzzleEdgeWorkerSyncResponse resp = new PuzzleEdgeWorkerSyncResponse();
|
||||||
for (int i = 0; i < maxTasks; i++) {
|
for (int i = 0; i < maxTasks; i++) {
|
||||||
PuzzleEdgeRenderTaskEntity task = claimOne(worker.getId());
|
PuzzleEdgeRenderTaskEntity task = claimOne(workerId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
PuzzleEdgeRenderTaskDTO dto = toTaskDTOOrFail(task, worker.getId());
|
PuzzleEdgeRenderTaskDTO dto = toTaskDTOOrFail(task, workerId);
|
||||||
if (dto != null) {
|
if (dto != null) {
|
||||||
resp.getTasks().add(dto);
|
resp.getTasks().add(dto);
|
||||||
}
|
}
|
||||||
@@ -170,15 +189,17 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PuzzleEdgeUploadUrlsResponse getUploadUrls(Long taskId, String accessKey) {
|
public PuzzleEdgeUploadUrlsResponse getUploadUrls(Long taskId) {
|
||||||
RenderWorkerEntity worker = requireWorker(accessKey);
|
// IP 验证已在拦截器层完成,此处无需验证 accessKey
|
||||||
PuzzleEdgeRenderTaskEntity task = getAndCheckRunningTask(taskId, worker.getId());
|
Long workerId = DEFAULT_WORKER_ID;
|
||||||
|
PuzzleEdgeRenderTaskEntity task = getAndCheckRunningTask(taskId, workerId);
|
||||||
return buildUploadUrls(task);
|
return buildUploadUrls(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void taskSuccess(Long taskId, PuzzleEdgeTaskSuccessRequest req) {
|
public void taskSuccess(Long taskId, PuzzleEdgeTaskSuccessRequest req) {
|
||||||
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
|
// IP 验证已在拦截器层完成,此处无需验证 accessKey
|
||||||
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, worker.getId());
|
Long workerId = DEFAULT_WORKER_ID;
|
||||||
|
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, workerId);
|
||||||
if (task.getStatus() != null && task.getStatus() == STATUS_SUCCESS) {
|
if (task.getStatus() != null && task.getStatus() == STATUS_SUCCESS) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -186,22 +207,39 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
throw new IllegalArgumentException("任务状态非法");
|
throw new IllegalArgumentException("任务状态非法");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean updated = tryMarkSuccess(task, worker.getId());
|
boolean updated = tryMarkSuccess(task, workerId);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new IllegalStateException("任务状态更新失败");
|
throw new IllegalStateException("任务状态更新失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
PuzzleGenerationRecordEntity record = recordMapper.getById(task.getRecordId());
|
IStorageAdapter storage = StorageFactory.use();
|
||||||
if (record == null) {
|
String resultImageUrl = storage.getUrl(task.getOriginalObjectKey());
|
||||||
log.warn("边缘渲染任务回报成功,但生成记录不存在: taskId={}, recordId={}", taskId, task.getRecordId());
|
|
||||||
return;
|
// 根据任务类型决定写入哪个表
|
||||||
|
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());
|
completeWaitFuture(taskId, TaskWaitResult.success(resultImageUrl));
|
||||||
String resultImageUrl = StrUtil.isNotBlank(task.getCroppedObjectKey())
|
}
|
||||||
? storage.getUrl(task.getCroppedObjectKey())
|
|
||||||
: originalImageUrl;
|
/**
|
||||||
|
* 处理原始拼图任务成功
|
||||||
|
*/
|
||||||
|
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;
|
Long resultFileSize = req != null ? req.getResultFileSize() : null;
|
||||||
Integer resultWidth = req != null ? req.getResultWidth() : null;
|
Integer resultWidth = req != null ? req.getResultWidth() : null;
|
||||||
@@ -211,15 +249,22 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
recordMapper.updateSuccess(
|
recordMapper.updateSuccess(
|
||||||
record.getId(),
|
record.getId(),
|
||||||
resultImageUrl,
|
resultImageUrl,
|
||||||
originalImageUrl,
|
|
||||||
resultFileSize,
|
resultFileSize,
|
||||||
resultWidth,
|
resultWidth,
|
||||||
resultHeight,
|
resultHeight,
|
||||||
renderDurationMs
|
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());
|
PuzzleTemplateEntity template = puzzleRepository.getTemplateById(task.getTemplateId());
|
||||||
if (template != null && template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
|
if (template != null && template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
|
||||||
@@ -228,8 +273,8 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
record.getUserId(),
|
record.getUserId(),
|
||||||
record.getScenicId(),
|
record.getScenicId(),
|
||||||
record.getFaceId(),
|
record.getFaceId(),
|
||||||
originalImageUrl,
|
resultImageUrl,
|
||||||
record.getId()
|
record.getId() // 拼图记录ID,用于关联 puzzle_generation_record 表
|
||||||
);
|
);
|
||||||
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
|
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
|
||||||
} catch (Exception e) {
|
} 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) {
|
public void taskFail(Long taskId, PuzzleEdgeTaskFailRequest req) {
|
||||||
RenderWorkerEntity worker = requireWorker(req != null ? req.getAccessKey() : null);
|
// IP 验证已在拦截器层完成,此处无需验证 accessKey
|
||||||
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, worker.getId());
|
Long workerId = DEFAULT_WORKER_ID;
|
||||||
|
PuzzleEdgeRenderTaskEntity task = getAndCheckTaskOwned(taskId, workerId);
|
||||||
if (task.getStatus() != null && task.getStatus() == STATUS_FAIL) {
|
if (task.getStatus() != null && task.getStatus() == STATUS_FAIL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -252,12 +318,15 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
? req.getErrorMessage()
|
? req.getErrorMessage()
|
||||||
: "边缘渲染失败";
|
: "边缘渲染失败";
|
||||||
|
|
||||||
boolean updated = tryMarkFail(task, worker.getId(), errorMessage);
|
boolean updated = tryMarkFail(task, workerId, errorMessage);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new IllegalStateException("任务状态更新失败");
|
throw new IllegalStateException("任务状态更新失败");
|
||||||
}
|
}
|
||||||
recordMapper.updateFail(task.getRecordId(), errorMessage);
|
recordMapper.updateFail(task.getRecordId(), errorMessage);
|
||||||
|
|
||||||
|
// 清除生成记录缓存(状态已更新)
|
||||||
|
puzzleRepository.clearRecordCache(task.getRecordId(), task.getFaceId());
|
||||||
|
|
||||||
// 通知等待方任务失败
|
// 通知等待方任务失败
|
||||||
completeWaitFuture(taskId, TaskWaitResult.fail(errorMessage));
|
completeWaitFuture(taskId, TaskWaitResult.fail(errorMessage));
|
||||||
}
|
}
|
||||||
@@ -273,6 +342,7 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
List<Long> retryRecordIds = new ArrayList<>();
|
List<Long> retryRecordIds = new ArrayList<>();
|
||||||
Map<Long, String> failRecordMessages = new HashMap<>();
|
Map<Long, String> failRecordMessages = new HashMap<>();
|
||||||
Map<Long, String> failTaskMessages = new HashMap<>(); // taskId -> errorMessage
|
Map<Long, String> failTaskMessages = new HashMap<>(); // taskId -> errorMessage
|
||||||
|
Map<Long, Long> failRecordFaceIds = new HashMap<>(); // recordId -> faceId,用于缓存清除
|
||||||
|
|
||||||
synchronized (taskLock) {
|
synchronized (taskLock) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
@@ -303,6 +373,7 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
task.setUpdateTime(new Date(now));
|
task.setUpdateTime(new Date(now));
|
||||||
if (task.getRecordId() != null) {
|
if (task.getRecordId() != null) {
|
||||||
failRecordMessages.put(task.getRecordId(), errorMessage);
|
failRecordMessages.put(task.getRecordId(), errorMessage);
|
||||||
|
failRecordFaceIds.put(task.getRecordId(), task.getFaceId());
|
||||||
}
|
}
|
||||||
// 记录需要通知的任务
|
// 记录需要通知的任务
|
||||||
failTaskMessages.put(task.getId(), errorMessage);
|
failTaskMessages.put(task.getId(), errorMessage);
|
||||||
@@ -329,6 +400,9 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
|
|
||||||
for (Map.Entry<Long, String> entry : failRecordMessages.entrySet()) {
|
for (Map.Entry<Long, String> entry : failRecordMessages.entrySet()) {
|
||||||
recordMapper.updateFail(entry.getKey(), entry.getValue());
|
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 fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
|
||||||
|
|
||||||
String originalObjectKey = String.format("puzzle/%s/%s", template.getCode(), fileName);
|
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<>();
|
Map<String, Object> payload = new HashMap<>();
|
||||||
payload.put("recordId", record.getId());
|
payload.put("recordId", record.getId());
|
||||||
@@ -417,7 +488,6 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
templatePayload.put("backgroundType", template.getBackgroundType());
|
templatePayload.put("backgroundType", template.getBackgroundType());
|
||||||
templatePayload.put("backgroundColor", template.getBackgroundColor());
|
templatePayload.put("backgroundColor", template.getBackgroundColor());
|
||||||
templatePayload.put("backgroundImage", template.getBackgroundImage());
|
templatePayload.put("backgroundImage", template.getBackgroundImage());
|
||||||
templatePayload.put("userArea", template.getUserArea());
|
|
||||||
payload.put("template", templatePayload);
|
payload.put("template", templatePayload);
|
||||||
|
|
||||||
List<Map<String, Object>> elementPayloadList = new ArrayList<>();
|
List<Map<String, Object>> elementPayloadList = new ArrayList<>();
|
||||||
@@ -451,13 +521,127 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
task.setTemplateCode(template.getCode());
|
task.setTemplateCode(template.getCode());
|
||||||
task.setScenicId(record.getScenicId());
|
task.setScenicId(record.getScenicId());
|
||||||
task.setFaceId(record.getFaceId());
|
task.setFaceId(record.getFaceId());
|
||||||
|
task.setTaskType(TASK_TYPE_PUZZLE);
|
||||||
|
task.setWatermarkType(null);
|
||||||
task.setContentHash(record.getContentHash());
|
task.setContentHash(record.getContentHash());
|
||||||
task.setStatus(STATUS_PENDING);
|
task.setStatus(STATUS_PENDING);
|
||||||
task.setAttemptCount(0);
|
task.setAttemptCount(0);
|
||||||
task.setOutputFormat(normalizedFormat);
|
task.setOutputFormat(normalizedFormat);
|
||||||
task.setOutputQuality(outputQuality);
|
task.setOutputQuality(outputQuality);
|
||||||
task.setOriginalObjectKey(originalObjectKey);
|
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));
|
task.setPayloadJson(JacksonUtil.toJson(payload));
|
||||||
|
|
||||||
Long taskId = taskIdSequence.incrementAndGet();
|
Long taskId = taskIdSequence.incrementAndGet();
|
||||||
@@ -749,20 +933,6 @@ public class PuzzleEdgeRenderTaskService {
|
|||||||
return attemptCount < MAX_RETRY_ATTEMPTS;
|
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) {
|
private String normalizeOutputFormat(String format) {
|
||||||
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
|
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
|
||||||
if ("JPG".equals(outputFormat)) {
|
if ("JPG".equals(outputFormat)) {
|
||||||
|
|||||||
@@ -29,9 +29,83 @@ public class ImageConfig implements ElementConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 圆角半径(像素,0为直角)
|
* 圆角半径(像素,0为直角)
|
||||||
|
* 注意:当 borderRadius >= min(width, height) / 2 时,效果为圆形
|
||||||
*/
|
*/
|
||||||
private Integer borderRadius = 0;
|
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
|
@Override
|
||||||
public void validate() {
|
public void validate() {
|
||||||
// 校验圆角半径
|
// 校验圆角半径
|
||||||
@@ -50,12 +124,55 @@ public class ImageConfig implements ElementConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验图片URL
|
// 校验图片URL(注意:现在可以通过 dynamicData 动态填充,所以允许为空)
|
||||||
if (StrUtil.isBlank(defaultImageUrl)) {
|
if (StrUtil.isNotBlank(defaultImageUrl)) {
|
||||||
throw new IllegalArgumentException("默认图片URL不能为空");
|
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" +
|
return "{\n" +
|
||||||
" \"defaultImageUrl\": \"https://example.com/image.jpg\",\n" +
|
" \"defaultImageUrl\": \"https://example.com/image.jpg\",\n" +
|
||||||
" \"imageFitMode\": \"CONTAIN|COVER|FILL|SCALE_DOWN\",\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")
|
@TableField("result_image_url")
|
||||||
private String resultImageUrl;
|
private String resultImageUrl;
|
||||||
|
|
||||||
/**
|
|
||||||
* 原始图片URL(未裁切的图片,用于打印)
|
|
||||||
*/
|
|
||||||
@TableField("original_image_url")
|
|
||||||
private String originalImageUrl;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件大小(字节)
|
* 文件大小(字节)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -109,12 +109,6 @@ public class PuzzleTemplateEntity {
|
|||||||
@TableField("can_print")
|
@TableField("can_print")
|
||||||
private Integer canPrint;
|
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,
|
int updateSuccess(@Param("id") Long id,
|
||||||
@Param("resultImageUrl") String resultImageUrl,
|
@Param("resultImageUrl") String resultImageUrl,
|
||||||
@Param("originalImageUrl") String originalImageUrl,
|
|
||||||
@Param("resultFileSize") Long resultFileSize,
|
@Param("resultFileSize") Long resultFileSize,
|
||||||
@Param("resultWidth") Integer resultWidth,
|
@Param("resultWidth") Integer resultWidth,
|
||||||
@Param("resultHeight") Integer resultHeight,
|
@Param("resultHeight") Integer resultHeight,
|
||||||
@@ -77,4 +76,15 @@ public interface PuzzleGenerationRecordMapper {
|
|||||||
PuzzleGenerationRecordEntity findByContentHash(@Param("templateId") Long templateId,
|
PuzzleGenerationRecordEntity findByContentHash(@Param("templateId") Long templateId,
|
||||||
@Param("contentHash") String contentHash,
|
@Param("contentHash") String contentHash,
|
||||||
@Param("scenicId") Long scenicId);
|
@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.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
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.entity.PuzzleTemplateEntity;
|
||||||
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
|
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
|
||||||
|
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||||
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||||
import com.ycwl.basic.utils.JacksonUtil;
|
import com.ycwl.basic.utils.JacksonUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -26,6 +28,7 @@ public class PuzzleRepository {
|
|||||||
|
|
||||||
private final PuzzleTemplateMapper templateMapper;
|
private final PuzzleTemplateMapper templateMapper;
|
||||||
private final PuzzleElementMapper elementMapper;
|
private final PuzzleElementMapper elementMapper;
|
||||||
|
private final PuzzleGenerationRecordMapper recordMapper;
|
||||||
private final RedisTemplate<String, String> redisTemplate;
|
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";
|
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 CACHE_EXPIRE_HOURS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成记录缓存过期时间(分钟)- 较短,因为可能频繁变化
|
||||||
|
*/
|
||||||
|
private static final long RECORD_CACHE_EXPIRE_MINUTES = 30;
|
||||||
|
|
||||||
public PuzzleRepository(
|
public PuzzleRepository(
|
||||||
PuzzleTemplateMapper templateMapper,
|
PuzzleTemplateMapper templateMapper,
|
||||||
PuzzleElementMapper elementMapper,
|
PuzzleElementMapper elementMapper,
|
||||||
|
PuzzleGenerationRecordMapper recordMapper,
|
||||||
RedisTemplate<String, String> redisTemplate) {
|
RedisTemplate<String, String> redisTemplate) {
|
||||||
this.templateMapper = templateMapper;
|
this.templateMapper = templateMapper;
|
||||||
this.elementMapper = elementMapper;
|
this.elementMapper = elementMapper;
|
||||||
|
this.recordMapper = recordMapper;
|
||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +172,8 @@ public class PuzzleRepository {
|
|||||||
* @param code 模板编码(可为null,此时需要先查询获取)
|
* @param code 模板编码(可为null,此时需要先查询获取)
|
||||||
*/
|
*/
|
||||||
public void clearTemplateCache(Long id, String code) {
|
public void clearTemplateCache(Long id, String code) {
|
||||||
|
Long scenicId = null;
|
||||||
|
|
||||||
// 如果没有传code,尝试从缓存或数据库获取
|
// 如果没有传code,尝试从缓存或数据库获取
|
||||||
if (code == null && id != null) {
|
if (code == null && id != null) {
|
||||||
String idKey = String.format(PUZZLE_TEMPLATE_BY_ID_KEY, id);
|
String idKey = String.format(PUZZLE_TEMPLATE_BY_ID_KEY, id);
|
||||||
@@ -154,10 +181,25 @@ public class PuzzleRepository {
|
|||||||
if (cacheValue != null) {
|
if (cacheValue != null) {
|
||||||
PuzzleTemplateEntity template = JacksonUtil.parseObject(cacheValue, PuzzleTemplateEntity.class);
|
PuzzleTemplateEntity template = JacksonUtil.parseObject(cacheValue, PuzzleTemplateEntity.class);
|
||||||
code = template.getCode();
|
code = template.getCode();
|
||||||
|
scenicId = template.getScenicId();
|
||||||
} else {
|
} else {
|
||||||
PuzzleTemplateEntity template = templateMapper.getById(id);
|
PuzzleTemplateEntity template = templateMapper.getById(id);
|
||||||
if (template != null) {
|
if (template != null) {
|
||||||
code = template.getCode();
|
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) {
|
if (id != null) {
|
||||||
clearElementsCache(id);
|
clearElementsCache(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除景区模板列表缓存(确保列表数据一致性)
|
||||||
|
if (scenicId != null) {
|
||||||
|
clearTemplateByScenicCache(scenicId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 元素缓存 ====================
|
// ==================== 元素缓存 ====================
|
||||||
@@ -225,6 +272,58 @@ public class PuzzleRepository {
|
|||||||
log.debug("清除元素缓存: templateId={}", templateId);
|
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:*");
|
deleteByPattern("puzzle:template:*");
|
||||||
// 使用 SCAN 删除元素缓存
|
// 使用 SCAN 删除元素缓存
|
||||||
deleteByPattern("puzzle:elements:*");
|
deleteByPattern("puzzle:elements:*");
|
||||||
|
// 使用 SCAN 删除景区模板列表缓存
|
||||||
|
deleteByPattern("puzzle:templates:*");
|
||||||
|
|
||||||
log.warn("拼图缓存清除完成");
|
log.warn("拼图缓存清除完成");
|
||||||
}
|
}
|
||||||
@@ -257,4 +358,122 @@ public class PuzzleRepository {
|
|||||||
log.error("删除缓存失败: pattern={}", pattern, e);
|
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.core.util.StrUtil;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.ycwl.basic.biz.FaceStatusManager;
|
||||||
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
||||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
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.PuzzleDuplicationDetector;
|
||||||
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||||
import com.ycwl.basic.repository.ScenicRepository;
|
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.service.printer.PrinterService;
|
||||||
import com.ycwl.basic.storage.StorageFactory;
|
import com.ycwl.basic.storage.StorageFactory;
|
||||||
import com.ycwl.basic.utils.WxMpUtil;
|
import com.ycwl.basic.utils.WxMpUtil;
|
||||||
@@ -56,6 +58,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
private final PuzzleDuplicationDetector duplicationDetector;
|
private final PuzzleDuplicationDetector duplicationDetector;
|
||||||
private final PrinterService printerService;
|
private final PrinterService printerService;
|
||||||
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
|
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
|
||||||
|
private final FaceStatusManager faceStatusManager;
|
||||||
|
private final PuzzleRelationProcessor puzzleRelationProcessor;
|
||||||
|
|
||||||
public PuzzleGenerateServiceImpl(
|
public PuzzleGenerateServiceImpl(
|
||||||
PuzzleRepository puzzleRepository,
|
PuzzleRepository puzzleRepository,
|
||||||
@@ -65,7 +69,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
@Lazy ScenicRepository scenicRepository,
|
@Lazy ScenicRepository scenicRepository,
|
||||||
@Lazy PuzzleDuplicationDetector duplicationDetector,
|
@Lazy PuzzleDuplicationDetector duplicationDetector,
|
||||||
@Lazy PrinterService printerService,
|
@Lazy PrinterService printerService,
|
||||||
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService) {
|
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService,
|
||||||
|
@Lazy FaceStatusManager faceStatusManager,
|
||||||
|
@Lazy PuzzleRelationProcessor puzzleRelationProcessor) {
|
||||||
this.puzzleRepository = puzzleRepository;
|
this.puzzleRepository = puzzleRepository;
|
||||||
this.recordMapper = recordMapper;
|
this.recordMapper = recordMapper;
|
||||||
this.imageRenderer = imageRenderer;
|
this.imageRenderer = imageRenderer;
|
||||||
@@ -74,6 +80,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
this.duplicationDetector = duplicationDetector;
|
this.duplicationDetector = duplicationDetector;
|
||||||
this.printerService = printerService;
|
this.printerService = printerService;
|
||||||
this.puzzleEdgeRenderTaskService = puzzleEdgeRenderTaskService;
|
this.puzzleEdgeRenderTaskService = puzzleEdgeRenderTaskService;
|
||||||
|
this.faceStatusManager = faceStatusManager;
|
||||||
|
this.puzzleRelationProcessor = puzzleRelationProcessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -101,6 +109,32 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
}
|
}
|
||||||
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
|
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. 查询并排序元素
|
// 3. 查询并排序元素
|
||||||
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
|
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
|
||||||
if (elements.isEmpty()) {
|
if (elements.isEmpty()) {
|
||||||
@@ -124,6 +158,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
long duration = System.currentTimeMillis() - startTime;
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
|
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
|
||||||
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
|
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
|
||||||
|
// 标记素材版本缓存
|
||||||
|
if (request.getFaceId() != null) {
|
||||||
|
faceStatusManager.markPuzzleSourceVersion(request.getFaceId(), template.getId(), 0);
|
||||||
|
}
|
||||||
return PuzzleGenerateResponse.success(
|
return PuzzleGenerateResponse.success(
|
||||||
duplicateRecord.getResultImageUrl(),
|
duplicateRecord.getResultImageUrl(),
|
||||||
duplicateRecord.getResultFileSize(),
|
duplicateRecord.getResultFileSize(),
|
||||||
@@ -141,6 +179,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
record.setContentHash(contentHash);
|
record.setContentHash(contentHash);
|
||||||
recordMapper.insert(record);
|
recordMapper.insert(record);
|
||||||
|
|
||||||
|
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
|
||||||
|
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
|
||||||
|
|
||||||
// 8. 创建边缘渲染任务并等待完成
|
// 8. 创建边缘渲染任务并等待完成
|
||||||
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
|
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
|
||||||
record,
|
record,
|
||||||
@@ -164,6 +205,19 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
log.info("同步拼图边缘渲染完成: recordId={}, taskId={}, imageUrl={}, duration={}ms",
|
log.info("同步拼图边缘渲染完成: recordId={}, taskId={}, imageUrl={}, duration={}ms",
|
||||||
record.getId(), taskId, waitResult.getImageUrl(), duration);
|
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());
|
PuzzleGenerationRecordEntity updatedRecord = recordMapper.getById(record.getId());
|
||||||
if (updatedRecord != null && updatedRecord.getResultImageUrl() != null) {
|
if (updatedRecord != null && updatedRecord.getResultImageUrl() != null) {
|
||||||
@@ -212,6 +266,23 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
}
|
}
|
||||||
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
|
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. 查询并排序元素
|
// 3. 查询并排序元素
|
||||||
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
|
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
|
||||||
if (elements.isEmpty()) {
|
if (elements.isEmpty()) {
|
||||||
@@ -235,6 +306,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
long duration = System.currentTimeMillis() - startTime;
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
|
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
|
||||||
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
|
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
|
||||||
|
// 标记素材版本缓存
|
||||||
|
if (request.getFaceId() != null) {
|
||||||
|
faceStatusManager.markPuzzleSourceVersion(request.getFaceId(), template.getId(), 0);
|
||||||
|
}
|
||||||
return duplicateRecord.getId();
|
return duplicateRecord.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +318,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
record.setContentHash(contentHash);
|
record.setContentHash(contentHash);
|
||||||
recordMapper.insert(record);
|
recordMapper.insert(record);
|
||||||
|
|
||||||
|
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
|
||||||
|
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
|
||||||
|
|
||||||
// 8. 创建边缘渲染任务
|
// 8. 创建边缘渲染任务
|
||||||
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
|
Long taskId = puzzleEdgeRenderTaskService.createRenderTask(
|
||||||
record,
|
record,
|
||||||
@@ -253,6 +331,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
request.getQuality()
|
request.getQuality()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 异步任务:在回调成功后标记缓存(由边缘渲染服务在成功回调中处理)
|
||||||
|
// 这里只记录请求信息供后续使用
|
||||||
long duration = System.currentTimeMillis() - startTime;
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
log.info("异步拼图任务已进入边缘渲染队列: recordId={}, taskId={}, templateCode={}, duration={}ms",
|
log.info("异步拼图任务已进入边缘渲染队列: recordId={}, taskId={}, templateCode={}, duration={}ms",
|
||||||
record.getId(), taskId, request.getTemplateCode(), duration);
|
record.getId(), taskId, request.getTemplateCode(), duration);
|
||||||
@@ -331,6 +411,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
record.setContentHash(contentHash);
|
record.setContentHash(contentHash);
|
||||||
recordMapper.insert(record);
|
recordMapper.insert(record);
|
||||||
|
|
||||||
|
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
|
||||||
|
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
|
||||||
|
|
||||||
// 9. 执行核心生成逻辑
|
// 9. 执行核心生成逻辑
|
||||||
return doGenerateInternal(request, template, resolvedScenicId, record, startTime);
|
return doGenerateInternal(request, template, resolvedScenicId, record, startTime);
|
||||||
}
|
}
|
||||||
@@ -385,40 +468,27 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
// 渲染图片
|
// 渲染图片
|
||||||
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
|
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
|
||||||
|
|
||||||
// 上传原图到OSS(未裁切)
|
// 上传图片到OSS
|
||||||
String originalImageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
|
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
|
||||||
log.info("原图上传成功: url={}", originalImageUrl);
|
log.info("图片上传成功: url={}", imageUrl);
|
||||||
|
|
||||||
// 处理用户区域裁切
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新记录为成功
|
// 更新记录为成功
|
||||||
long duration = System.currentTimeMillis() - startTime;
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
long fileSize = estimateFileSize(finalImage, request.getOutputFormat());
|
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
|
||||||
recordMapper.updateSuccess(
|
recordMapper.updateSuccess(
|
||||||
record.getId(),
|
record.getId(),
|
||||||
finalImageUrl,
|
imageUrl,
|
||||||
originalImageUrl,
|
|
||||||
fileSize,
|
fileSize,
|
||||||
finalImage.getWidth(),
|
resultImage.getWidth(),
|
||||||
finalImage.getHeight(),
|
resultImage.getHeight(),
|
||||||
(int) duration
|
(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) {
|
if (template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
|
||||||
@@ -427,8 +497,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
request.getUserId(),
|
request.getUserId(),
|
||||||
resolvedScenicId,
|
resolvedScenicId,
|
||||||
request.getFaceId(),
|
request.getFaceId(),
|
||||||
originalImageUrl,
|
imageUrl,
|
||||||
record.getId()
|
record.getId() // 拼图记录ID,用于关联 puzzle_generation_record 表
|
||||||
);
|
);
|
||||||
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
|
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -437,10 +507,10 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return PuzzleGenerateResponse.success(
|
return PuzzleGenerateResponse.success(
|
||||||
finalImageUrl,
|
imageUrl,
|
||||||
fileSize,
|
fileSize,
|
||||||
finalImage.getWidth(),
|
resultImage.getWidth(),
|
||||||
finalImage.getHeight(),
|
resultImage.getHeight(),
|
||||||
(int) duration,
|
(int) duration,
|
||||||
record.getId(),
|
record.getId(),
|
||||||
false,
|
false,
|
||||||
@@ -450,6 +520,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("拼图生成失败: templateCode={}", request.getTemplateCode(), e);
|
log.error("拼图生成失败: templateCode={}", request.getTemplateCode(), e);
|
||||||
recordMapper.updateFail(record.getId(), e.getMessage());
|
recordMapper.updateFail(record.getId(), e.getMessage());
|
||||||
|
// 清除生成记录缓存(状态已更新)
|
||||||
|
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
|
||||||
throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
|
throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -685,43 +757,4 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
|||||||
|
|
||||||
return templateScenicId;
|
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);
|
entity.setDeleted(0);
|
||||||
templateMapper.insert(entity);
|
templateMapper.insert(entity);
|
||||||
|
|
||||||
|
// 清除景区模板列表缓存
|
||||||
|
if (entity.getScenicId() != null) {
|
||||||
|
puzzleRepository.clearTemplateByScenicCache(entity.getScenicId());
|
||||||
|
}
|
||||||
|
|
||||||
log.info("拼图模板创建成功: id={}, code={}", entity.getId(), entity.getCode());
|
log.info("拼图模板创建成功: id={}, code={}", entity.getId(), entity.getCode());
|
||||||
return entity.getId();
|
return entity.getId();
|
||||||
}
|
}
|
||||||
@@ -71,8 +76,11 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
|
|||||||
throw new IllegalArgumentException("模板不存在: " + id);
|
throw new IllegalArgumentException("模板不存在: " + id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果修改了编码,检查新编码是否已存在
|
// 记录旧值
|
||||||
String oldCode = existing.getCode();
|
String oldCode = existing.getCode();
|
||||||
|
Long oldScenicId = existing.getScenicId();
|
||||||
|
|
||||||
|
// 如果修改了编码,检查新编码是否已存在
|
||||||
if (request.getCode() != null && !request.getCode().equals(existing.getCode())) {
|
if (request.getCode() != null && !request.getCode().equals(existing.getCode())) {
|
||||||
int count = templateMapper.countByCode(request.getCode(), id);
|
int count = templateMapper.countByCode(request.getCode(), id);
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
@@ -85,12 +93,18 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
|
|||||||
entity.setId(id);
|
entity.setId(id);
|
||||||
templateMapper.update(entity);
|
templateMapper.update(entity);
|
||||||
|
|
||||||
// 清除缓存(如果修改了code,需要同时清除新旧code的缓存)
|
// 清除缓存
|
||||||
puzzleRepository.clearTemplateCache(id, oldCode);
|
puzzleRepository.clearTemplateCache(id, oldCode);
|
||||||
if (request.getCode() != null && !request.getCode().equals(oldCode)) {
|
if (request.getCode() != null && !request.getCode().equals(oldCode)) {
|
||||||
puzzleRepository.clearTemplateCache(null, request.getCode());
|
puzzleRepository.clearTemplateCache(null, request.getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果 scenicId 变更,清除新旧两个景区的缓存
|
||||||
|
Long newScenicId = request.getScenicId();
|
||||||
|
if (newScenicId != null && !newScenicId.equals(oldScenicId)) {
|
||||||
|
puzzleRepository.clearTemplateByScenicCache(newScenicId);
|
||||||
|
}
|
||||||
|
|
||||||
log.info("拼图模板更新成功: id={}", id);
|
log.info("拼图模板更新成功: id={}", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package com.ycwl.basic.repository;
|
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.scenic.dto.scenic.ScenicV2DTO;
|
||||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||||
import com.ycwl.basic.integration.scenic.service.ScenicIntegrationService;
|
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.mp.ScenicMpNotifyVO;
|
||||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
|
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
|
||||||
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
|
import com.ycwl.basic.model.pc.scenic.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.utils.JacksonUtil;
|
||||||
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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_CACHE_KEY = "scenic:%s:mp";
|
||||||
public static final String SCENIC_MP_NOTIFY_CACHE_KEY = "scenic:%s:mpNotify";
|
public static final String SCENIC_MP_NOTIFY_CACHE_KEY = "scenic:%s:mpNotify";
|
||||||
@Autowired
|
|
||||||
private MpNotifyConfigMapper mpNotifyConfigMapper;
|
|
||||||
|
|
||||||
public ScenicV2DTO getScenicBasic(Long id) {
|
public ScenicV2DTO getScenicBasic(Long id) {
|
||||||
ScenicV2DTO scenicDTO = scenicIntegrationService.getScenic(id);
|
ScenicV2DTO scenicDTO = scenicIntegrationService.getScenic(id);
|
||||||
@@ -70,61 +64,6 @@ public class ScenicRepository {
|
|||||||
return mpConfigEntity;
|
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) {
|
public List<ScenicV2DTO> list(ScenicReqQuery scenicReqQuery) {
|
||||||
try {
|
try {
|
||||||
// 将 ScenicReqQuery 参数转换为 zt-scenic 服务需要的参数
|
// 将 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