You've already forked FrameTour-BE
Compare commits
136 Commits
2a3b4ca19f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ed12af8c9 | |||
| ecbdec4518 | |||
| bf6b866e67 | |||
| 93f9c1486f | |||
| e87e38be03 | |||
| 85d0fc0996 | |||
| d25d09cb66 | |||
| c40c6a0966 | |||
| 4fc0984994 | |||
| 918ff860c3 | |||
| 8b3bea8bed | |||
| be54bbaa82 | |||
| 68a674ba51 | |||
| 80f8a6b56b | |||
| 973bd73e9a | |||
| 819caab047 | |||
| 00bf4b5a8b | |||
| 6c305f4cd1 | |||
| 82e844a779 | |||
| c3fcfdd633 | |||
| a8156976be | |||
| ce48bd00c9 | |||
| c5df277e6c | |||
| 9a31e71e42 | |||
| e268d236f4 | |||
| 143426db1f | |||
| fcc4b06295 | |||
| f876dc59fa | |||
| 8e6d10ad95 | |||
| 42bf3d3d0a | |||
| 679f2d3a79 | |||
| 3084afc6a7 | |||
| 91626626f4 | |||
| b1cfef278d | |||
| c42474256e | |||
| 63180159d2 | |||
| e647ad75c6 | |||
| 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 | |||
| 1df6a4bc23 | |||
| 981a4ba7bd | |||
| 017ced34fa | |||
| a9ae00d580 | |||
| 99f75b6805 | |||
| 295815f1fa | |||
| 010bac1091 | |||
| eb9b781fd3 | |||
| 8d3dae32f3 | |||
| 43775f550b | |||
| 24f72091b3 | |||
| cc62fb4c18 | |||
| d1962ed615 | |||
| e1023b6ea8 | |||
| aec5e57df7 | |||
| 52ce26e630 | |||
| 32297dc29c | |||
| 21d8c56e82 | |||
| f8374519c3 | |||
| 44f5008fd1 | |||
| 6e0ebcd1bd | |||
| 5caf9a0ebf | |||
| 06bc2c2020 | |||
| f1a2958251 | |||
| 81dc2f1b86 | |||
| 41e90bab9c | |||
| b4628bd3e8 | |||
| cfb4284b7c | |||
| 5a61432dc9 | |||
| 91160a1adb | |||
| 991a8b10e3 | |||
| ab1e8cf7ef | |||
| 5d5643e7d7 | |||
| dc4091e058 | |||
| a5c815b6ed |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,7 +1,8 @@
|
||||
.idea/
|
||||
logs/
|
||||
target/
|
||||
.serena
|
||||
.*
|
||||
.claude
|
||||
.vscode
|
||||
*.jpg
|
||||
!.gitignore
|
||||
8
pom.xml
8
pom.xml
@@ -303,6 +303,14 @@
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.4.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ClickHouse JDBC Driver -->
|
||||
<dependency>
|
||||
<groupId>com.clickhouse</groupId>
|
||||
<artifactId>clickhouse-jdbc</artifactId>
|
||||
<version>0.8.5</version>
|
||||
<classifier>all</classifier>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ycwl.basic.biz;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import com.ycwl.basic.clickhouse.service.StatsQueryService;
|
||||
import com.ycwl.basic.mapper.BrokerMapper;
|
||||
import com.ycwl.basic.mapper.BrokerRecordMapper;
|
||||
import com.ycwl.basic.mapper.StatisticsMapper;
|
||||
@@ -34,7 +35,7 @@ public class BrokerBiz {
|
||||
@Autowired
|
||||
private ScenicRepository scenicRepository;
|
||||
@Autowired
|
||||
private StatisticsMapper statisticsMapper;
|
||||
private StatsQueryService statsQueryService;
|
||||
|
||||
public void processOrder(Long orderId) {
|
||||
log.info("开始处理订单分佣,订单ID:{}", orderId);
|
||||
@@ -52,7 +53,7 @@ public class BrokerBiz {
|
||||
if (scenicConfig.getInteger("sample_store_day") != null) {
|
||||
expireDay = scenicConfig.getInteger("sample_store_day");
|
||||
}
|
||||
List<Long> brokerIdList = statisticsMapper.getBrokerIdListForUser(order.getMemberId(), DateUtil.offsetDay(DateUtil.beginOfDay(order.getCreateAt()), -expireDay), order.getCreateAt());
|
||||
List<Long> brokerIdList = statsQueryService.getBrokerIdListForUser(order.getMemberId(), DateUtil.offsetDay(DateUtil.beginOfDay(order.getCreateAt()), -expireDay), order.getCreateAt());
|
||||
if (brokerIdList == null || brokerIdList.isEmpty()) {
|
||||
log.info("用户与推客无关,订单ID:{}", orderId);
|
||||
return;
|
||||
|
||||
@@ -42,6 +42,13 @@ public class FaceStatusManager {
|
||||
*/
|
||||
private final Cache<String, Integer> templateRenderCache;
|
||||
|
||||
/**
|
||||
* 拼图素材版本缓存
|
||||
* 键:faceId:puzzleTemplateId -> 当时的图片源数量
|
||||
* 用于判断拼图模板的素材是否发生变化,避免重复生成
|
||||
*/
|
||||
private final Cache<String, Integer> puzzleSourceVersionCache;
|
||||
|
||||
@Autowired
|
||||
private TaskMapper taskMapper;
|
||||
|
||||
@@ -61,6 +68,11 @@ public class FaceStatusManager {
|
||||
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
|
||||
.maximumSize(10000)
|
||||
.build();
|
||||
|
||||
this.puzzleSourceVersionCache = Caffeine.newBuilder()
|
||||
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
|
||||
.maximumSize(10000)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ==================== 切片状态相关方法 ====================
|
||||
@@ -293,4 +305,80 @@ public class FaceStatusManager {
|
||||
log.debug("批量删除模板渲染状态缓存: faceId={}, count={}", faceId, count);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 拼图素材版本相关方法 ====================
|
||||
|
||||
/**
|
||||
* 标记拼图素材版本(记录当前的图片源数量)
|
||||
* 在拼图生成成功后调用,用于后续判断素材是否变化
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @param puzzleTemplateId 拼图模板ID(全局唯一)
|
||||
* @param sourceCount 当前的图片源数量
|
||||
*/
|
||||
public void markPuzzleSourceVersion(Long faceId, Long puzzleTemplateId, int sourceCount) {
|
||||
if (faceId == null || puzzleTemplateId == null) {
|
||||
log.warn("标记拼图素材版本参数为空: faceId={}, puzzleTemplateId={}", faceId, puzzleTemplateId);
|
||||
return;
|
||||
}
|
||||
String key = faceId + ":" + puzzleTemplateId;
|
||||
puzzleSourceVersionCache.put(key, sourceCount);
|
||||
log.debug("标记拼图素材版本: faceId={}, puzzleTemplateId={}, sourceCount={}", faceId, puzzleTemplateId, sourceCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断拼图素材是否发生变化
|
||||
* 通过比较当前的图片源数量与缓存中记录的数量
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @param puzzleTemplateId 拼图模板ID(全局唯一)
|
||||
* @param currentSourceCount 当前的图片源数量
|
||||
* @return true=素材已变化(需要重新生成),false=素材未变化(可以跳过生成)
|
||||
*/
|
||||
public boolean isPuzzleSourceChanged(Long faceId, Long puzzleTemplateId, int currentSourceCount) {
|
||||
if (faceId == null || puzzleTemplateId == null) {
|
||||
log.warn("判断拼图素材变化参数为空: faceId={}, puzzleTemplateId={}", faceId, puzzleTemplateId);
|
||||
return true; // 参数不合法时默认认为有变化
|
||||
}
|
||||
|
||||
String key = faceId + ":" + puzzleTemplateId;
|
||||
Integer cachedCount = puzzleSourceVersionCache.getIfPresent(key);
|
||||
|
||||
if (cachedCount == null) {
|
||||
// 缓存不存在,认为有变化(首次生成或缓存过期)
|
||||
log.debug("拼图素材版本缓存不存在,需要生成: faceId={}, puzzleTemplateId={}", faceId, puzzleTemplateId);
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean changed = !cachedCount.equals(currentSourceCount);
|
||||
if (changed) {
|
||||
log.debug("拼图素材已变化: faceId={}, puzzleTemplateId={}, cachedCount={}, currentCount={}",
|
||||
faceId, puzzleTemplateId, cachedCount, currentSourceCount);
|
||||
} else {
|
||||
log.debug("拼图素材未变化,可跳过生成: faceId={}, puzzleTemplateId={}, sourceCount={}",
|
||||
faceId, puzzleTemplateId, currentSourceCount);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使指定人脸的所有拼图素材版本缓存失效
|
||||
* 当人脸的图片关联发生变化时调用(如人脸匹配后新增了关联)
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
*/
|
||||
public void invalidatePuzzleSourceVersion(Long faceId) {
|
||||
if (faceId == null) {
|
||||
return;
|
||||
}
|
||||
String prefix = faceId + ":";
|
||||
long count = puzzleSourceVersionCache.asMap().keySet().stream()
|
||||
.filter(key -> key.startsWith(prefix))
|
||||
.peek(puzzleSourceVersionCache::invalidate)
|
||||
.count();
|
||||
if (count > 0) {
|
||||
log.debug("批量使拼图素材版本缓存失效: faceId={}, count={}", faceId, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ycwl.basic.biz;
|
||||
|
||||
import com.ycwl.basic.clickhouse.service.StatsQueryService;
|
||||
import com.ycwl.basic.enums.StatisticEnum;
|
||||
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||
import com.ycwl.basic.mapper.OrderMapper;
|
||||
@@ -66,8 +67,10 @@ public class OrderBiz {
|
||||
private PrinterService printerService;
|
||||
@Autowired
|
||||
private IPriceCalculationService iPriceCalculationService;
|
||||
@Autowired
|
||||
private StatsQueryService statsQueryService;
|
||||
|
||||
public PriceObj queryPrice(Long scenicId, int goodsType, Long goodsId) {
|
||||
public PriceObj queryPrice(Long scenicId, Long memberId, int goodsType, Long goodsId) {
|
||||
PriceObj priceObj = new PriceObj();
|
||||
priceObj.setGoodsType(goodsType);
|
||||
priceObj.setGoodsId(goodsId);
|
||||
@@ -99,8 +102,10 @@ public class OrderBiz {
|
||||
vlogProductItem.setQuantity(videoTaskRepository.getTaskLensNum(video.getTaskId()));
|
||||
vlogProductItem.setScenicId(scenicId.toString());
|
||||
vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem));
|
||||
vlogCalculationRequest.setUserId(memberId);
|
||||
vlogCalculationRequest.setFaceId(priceObj.getFaceId());
|
||||
vlogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
vlogCalculationRequest.setAutoUseCoupon(true);
|
||||
PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest);
|
||||
priceObj.setPrice(vlogCalculationResult.getFinalAmount());
|
||||
priceObj.setSlashPrice(vlogCalculationResult.getOriginalAmount());
|
||||
@@ -120,13 +125,33 @@ public class OrderBiz {
|
||||
if (face != null) {
|
||||
calculationRequest.setUserId(face.getMemberId());
|
||||
}
|
||||
calculationRequest.setUserId(memberId);
|
||||
calculationRequest.setFaceId(goodsId);
|
||||
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
calculationRequest.setAutoUseCoupon(true);
|
||||
PriceCalculationResult priceCalculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
||||
priceObj.setPrice(priceCalculationResult.getFinalAmount());
|
||||
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
|
||||
priceObj.setFaceId(goodsId);
|
||||
break;
|
||||
case 5:
|
||||
PriceCalculationRequest plogCalculationRequest = new PriceCalculationRequest();
|
||||
ProductItem plogProductItem = new ProductItem();
|
||||
plogProductItem.setProductType(ProductType.PHOTO_LOG);
|
||||
plogProductItem.setProductId(scenicId.toString());
|
||||
plogProductItem.setPurchaseCount(1);
|
||||
plogProductItem.setScenicId(scenicId.toString());
|
||||
plogCalculationRequest.setProducts(Collections.singletonList(plogProductItem));
|
||||
plogCalculationRequest.setUserId(memberId);
|
||||
plogCalculationRequest.setFaceId(goodsId);
|
||||
plogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
plogCalculationRequest.setAutoUseCoupon(true);
|
||||
PriceCalculationResult plogPriceCalculationResult = iPriceCalculationService.calculatePrice(plogCalculationRequest);
|
||||
priceObj.setPrice(plogPriceCalculationResult.getFinalAmount());
|
||||
priceObj.setSlashPrice(plogPriceCalculationResult.getOriginalAmount());
|
||||
priceObj.setFaceId(goodsId);
|
||||
priceObj.setScenicId(scenicId);
|
||||
break;
|
||||
case 13:
|
||||
PriceCalculationRequest aiCamCalculationRequest = new PriceCalculationRequest();
|
||||
ProductItem aiCamProductItem = new ProductItem();
|
||||
@@ -135,7 +160,10 @@ public class OrderBiz {
|
||||
aiCamProductItem.setPurchaseCount(1);
|
||||
aiCamProductItem.setScenicId(scenicId.toString());
|
||||
aiCamCalculationRequest.setProducts(Collections.singletonList(aiCamProductItem));
|
||||
aiCamCalculationRequest.setUserId(memberId);
|
||||
aiCamCalculationRequest.setFaceId(goodsId);
|
||||
aiCamCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
aiCamCalculationRequest.setAutoUseCoupon(true);
|
||||
PriceCalculationResult aiCamPriceCalculationResult = iPriceCalculationService.calculatePrice(aiCamCalculationRequest);
|
||||
priceObj.setPrice(aiCamPriceCalculationResult.getFinalAmount());
|
||||
priceObj.setSlashPrice(aiCamPriceCalculationResult.getOriginalAmount());
|
||||
@@ -190,7 +218,7 @@ public class OrderBiz {
|
||||
}
|
||||
}
|
||||
}
|
||||
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId);
|
||||
PriceObj priceObj = queryPrice(scenicId, memberId, goodsType, goodsId);
|
||||
if (priceObj == null) {
|
||||
return respVO;
|
||||
}
|
||||
@@ -229,7 +257,7 @@ public class OrderBiz {
|
||||
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
||||
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
|
||||
statisticsRecordAddReq.setMemberId(order.getMemberId());
|
||||
Long enterType = statisticsMapper.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());
|
||||
Long enterType = statsQueryService.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());
|
||||
if(!Long.valueOf(1014).equals(enterType)){//
|
||||
statisticsRecordAddReq.setType(StatisticEnum.ON_SITE_PAYMENT.code);
|
||||
}else {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.ycwl.basic.biz;
|
||||
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.pc.order.entity.OrderEntity;
|
||||
import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity;
|
||||
@@ -15,6 +13,7 @@ import com.ycwl.basic.product.capability.ProductTypeCapability;
|
||||
import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||
import com.ycwl.basic.repository.OrderRepository;
|
||||
@@ -50,6 +49,8 @@ public class PriceBiz {
|
||||
@Autowired
|
||||
private PuzzleTemplateMapper puzzleTemplateMapper;
|
||||
@Autowired
|
||||
private PuzzleRepository puzzleRepository;
|
||||
@Autowired
|
||||
private IProductTypeCapabilityManagementService productTypeCapabilityManagementService;
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
@@ -74,8 +75,8 @@ public class PriceBiz {
|
||||
goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
|
||||
}
|
||||
}
|
||||
// 拼图
|
||||
puzzleTemplateMapper.list(scenicId, null, 1).forEach(puzzleTemplate -> {
|
||||
// 拼图(使用缓存)
|
||||
puzzleRepository.listTemplateByScenic(scenicId).forEach(puzzleTemplate -> {
|
||||
GoodsListRespVO goods = new GoodsListRespVO();
|
||||
goods.setGoodsId(puzzleTemplate.getId());
|
||||
goods.setGoodsName(puzzleTemplate.getName());
|
||||
@@ -131,7 +132,7 @@ public class PriceBiz {
|
||||
|
||||
case "PHOTO_LOG":
|
||||
// 从 template 表查询pLog模板
|
||||
List<PuzzleTemplateEntity> puzzleList = puzzleTemplateMapper.list(scenicId, null, null);
|
||||
List<PuzzleTemplateEntity> puzzleList = puzzleRepository.listTemplateByScenic(scenicId);
|
||||
puzzleList.stream()
|
||||
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
|
||||
.forEach(goodsList::add);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.ycwl.basic.clickhouse.service;
|
||||
|
||||
import com.ycwl.basic.model.mobile.statistic.req.CommonQueryReq;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统计数据查询服务接口
|
||||
* 用于抽象 t_stats 和 t_stats_record 表的查询
|
||||
* 支持 MySQL 和 ClickHouse 两种实现
|
||||
*/
|
||||
public interface StatsQueryService {
|
||||
|
||||
/**
|
||||
* 统计预览视频人数
|
||||
*/
|
||||
Integer countPreviewVideoOfMember(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 统计扫码访问人数
|
||||
*/
|
||||
Integer countScanCodeOfMember(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 统计推送订阅人数
|
||||
*/
|
||||
Integer countPushOfMember(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 统计上传头像人数
|
||||
*/
|
||||
Integer countUploadFaceOfMember(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 统计生成视频人数
|
||||
*/
|
||||
Integer countCompleteVideoOfMember(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 统计生成视频条数
|
||||
*/
|
||||
Integer countCompleteOfVideo(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 统计总访问人数
|
||||
*/
|
||||
Integer countTotalVisitorOfMember(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 统计预览视频条数
|
||||
*/
|
||||
Integer countPreviewOfVideo(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 获取用户分销员 ID 列表
|
||||
*/
|
||||
List<Long> getBrokerIdListForUser(Long memberId, Date startTime, Date endTime);
|
||||
|
||||
/**
|
||||
* 获取用户最近进入类型
|
||||
*/
|
||||
Long getUserRecentEnterType(Long memberId, Date endTime);
|
||||
|
||||
/**
|
||||
* 获取用户项目 ID 列表
|
||||
*/
|
||||
List<Long> getProjectIdListForUser(Long memberId, Date startTime, Date endTime);
|
||||
|
||||
/**
|
||||
* 统计分销员扫码次数
|
||||
*/
|
||||
Integer countBrokerScanCount(Long brokerId);
|
||||
|
||||
/**
|
||||
* 按日期统计分销员扫码数据
|
||||
*/
|
||||
List<HashMap<String, Object>> getDailyScanStats(Long brokerId, Date startTime, Date endTime);
|
||||
|
||||
/**
|
||||
* 按小时统计扫码人数(仅返回统计数据,不含订单)
|
||||
* 返回格式: [{t: "MM-dd HH", count: "xxx"}, ...]
|
||||
*/
|
||||
List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按日期统计扫码人数(仅返回统计数据,不含订单)
|
||||
* 返回格式: [{t: "MM-dd", count: "xxx"}, ...]
|
||||
*/
|
||||
List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按小时统计访问打印样片页面人数
|
||||
* 返回格式: [{t: "MM-dd HH", count: "xxx"}, ...]
|
||||
*/
|
||||
List<HashMap<String, String>> printerFromSampleChartByHour(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按日期统计访问打印样片页面人数
|
||||
* 返回格式: [{t: "MM-dd", count: "xxx"}, ...]
|
||||
*/
|
||||
List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
package com.ycwl.basic.clickhouse.service.impl;
|
||||
|
||||
import com.ycwl.basic.clickhouse.service.StatsQueryService;
|
||||
import com.ycwl.basic.mapper.TaskMapper;
|
||||
import com.ycwl.basic.model.mobile.statistic.req.CommonQueryReq;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* ClickHouse 统计数据查询服务实现
|
||||
* 当 clickhouse.enabled=true 时启用
|
||||
*
|
||||
* 注意:ClickHouse JDBC 驱动 0.6.x 对参数绑定支持有问题,
|
||||
* 因此使用字符串格式化方式构建 SQL(参数均为内部生成的数值或日期,无 SQL 注入风险)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnProperty(prefix = "clickhouse", name = "enabled", havingValue = "true")
|
||||
public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
||||
|
||||
private static final TimeZone CLICKHOUSE_TIMEZONE = TimeZone.getTimeZone("Asia/Shanghai");
|
||||
|
||||
/**
|
||||
* 创建日期格式化器(SimpleDateFormat 非线程安全,每次创建新实例)
|
||||
*/
|
||||
private SimpleDateFormat createDateFormat() {
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
||||
sdf.setTimeZone(CLICKHOUSE_TIMEZONE);
|
||||
return sdf;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建日期时间格式化器
|
||||
*/
|
||||
private SimpleDateFormat createDateTimeFormat() {
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
sdf.setTimeZone(CLICKHOUSE_TIMEZONE);
|
||||
return sdf;
|
||||
}
|
||||
|
||||
@Autowired
|
||||
@Qualifier("clickHouseJdbcTemplate")
|
||||
private NamedParameterJdbcTemplate namedJdbcTemplate;
|
||||
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private TaskMapper taskMapper;
|
||||
|
||||
private JdbcTemplate getJdbcTemplate() {
|
||||
if (jdbcTemplate == null) {
|
||||
jdbcTemplate = namedJdbcTemplate.getJdbcTemplate();
|
||||
}
|
||||
return jdbcTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间为 ClickHouse 可识别的字符串
|
||||
*/
|
||||
private String formatDateTime(Date date) {
|
||||
return date != null ? "'" + createDateTimeFormat().format(date) + "'" : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为 ClickHouse 可识别的字符串
|
||||
*/
|
||||
private String formatDate(Date date) {
|
||||
return date != null ? "'" + createDateFormat().format(date) + "'" : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接“进入景区”的 trace_id 子查询。
|
||||
* <p>
|
||||
* ClickHouse 上 t_stats_record 往往按时间分区/排序;给子查询补充时间范围可显著减少扫描量。
|
||||
*/
|
||||
private void appendEnterScenicTraceIdSubQuery(StringBuilder sql, Long scenicId, Date startTime, Date endTime) {
|
||||
sql.append("SELECT DISTINCT trace_id FROM t_stats_record ");
|
||||
sql.append("WHERE action = 'ENTER_SCENIC' ");
|
||||
if (scenicId != null) {
|
||||
sql.append("AND identifier = '").append(scenicId).append("' ");
|
||||
}
|
||||
if (startTime != null) {
|
||||
sql.append("AND create_time >= ").append(formatDateTime(startTime)).append(" ");
|
||||
}
|
||||
if (endTime != null) {
|
||||
sql.append("AND create_time <= ").append(formatDateTime(endTime)).append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countPreviewVideoOfMember(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT toInt32(uniqExact(s.member_id)) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'LOAD' ");
|
||||
sql.append("AND r.identifier = 'pages/videoSynthesis/buy' ");
|
||||
sql.append("AND r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
sql.append("AND JSONExtractString(r.params, 'share') = '' ");
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
sql.append("AND r.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
}
|
||||
|
||||
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countScanCodeOfMember(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT toInt32(uniqExact(s.member_id)) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
sql.append("AND r.action = 'LAUNCH' ");
|
||||
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
sql.append("AND s.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
}
|
||||
|
||||
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countPushOfMember(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT toInt32(uniqExact(s.member_id)) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
sql.append("AND r.action = 'PERM_REQ' ");
|
||||
sql.append("AND r.identifier = 'NOTIFY' ");
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
sql.append("AND r.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
}
|
||||
|
||||
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countUploadFaceOfMember(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT toInt32(uniqExact(s.member_id)) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'FACE_UPLOAD' ");
|
||||
sql.append("AND r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
sql.append("AND s.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
}
|
||||
|
||||
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
|
||||
}
|
||||
|
||||
private List<String> listFaceIdsWithUpload(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT DISTINCT r.identifier FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'FACE_UPLOAD' ");
|
||||
sql.append("AND r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
sql.append("AND s.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
}
|
||||
|
||||
return getJdbcTemplate().queryForList(sql.toString(), String.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countCompleteVideoOfMember(CommonQueryReq query) {
|
||||
List<String> faceIds = listFaceIdsWithUpload(query);
|
||||
if (faceIds == null || faceIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return taskMapper.countCompletedTaskMembersByFaceIds(faceIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countCompleteOfVideo(CommonQueryReq query) {
|
||||
List<String> faceIds = listFaceIdsWithUpload(query);
|
||||
if (faceIds == null || faceIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return taskMapper.countCompletedTasksByFaceIds(faceIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countTotalVisitorOfMember(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT toInt32(uniqExact(s.member_id)) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
sql.append("AND r.action = 'LAUNCH' ");
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
sql.append("AND s.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
}
|
||||
|
||||
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countPreviewOfVideo(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("WITH JSONExtractString(params, 'id') AS videoId, ");
|
||||
sql.append(" JSONExtractString(params, 'share') AS share ");
|
||||
sql.append("SELECT toInt32(uniqExact(videoId)) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
sql.append("AND r.action = 'LOAD' ");
|
||||
sql.append("AND r.identifier = 'pages/videoSynthesis/buy' ");
|
||||
sql.append("AND videoId != '' ");
|
||||
sql.append("AND share = '' ");
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND s.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
sql.append("AND s.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
}
|
||||
|
||||
return getJdbcTemplate().queryForObject(sql.toString(), Integer.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> getBrokerIdListForUser(Long memberId, Date startTime, Date endTime) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT toInt64(r.identifier) AS identifier ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'CODE_SCAN' ");
|
||||
sql.append(" AND s.member_id = ").append(memberId).append(" ");
|
||||
if (startTime != null) {
|
||||
sql.append(" AND r.create_time >= ").append(formatDateTime(startTime)).append(" ");
|
||||
}
|
||||
if (endTime != null) {
|
||||
sql.append(" AND r.create_time <= ").append(formatDateTime(endTime)).append(" ");
|
||||
}
|
||||
sql.append("GROUP BY r.identifier ");
|
||||
sql.append("ORDER BY max(r.create_time) DESC");
|
||||
|
||||
return getJdbcTemplate().queryForList(sql.toString(), Long.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getUserRecentEnterType(Long memberId, Date endTime) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT JSONExtractInt(r.params, 'scene') AS scene ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'LAUNCH' ");
|
||||
sql.append(" AND s.member_id = ").append(memberId).append(" ");
|
||||
if (endTime != null) {
|
||||
sql.append(" AND r.create_time <= ").append(formatDateTime(endTime)).append(" ");
|
||||
}
|
||||
sql.append("ORDER BY r.create_time DESC LIMIT 1");
|
||||
|
||||
try {
|
||||
return getJdbcTemplate().queryForObject(sql.toString(), Long.class);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> getProjectIdListForUser(Long memberId, Date startTime, Date endTime) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT toInt64(r.identifier) AS identifier ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE s.member_id = ").append(memberId).append(" ");
|
||||
sql.append(" AND r.action = 'ENTER_PROJECT' ");
|
||||
sql.append(" AND r.create_time < ").append(formatDateTime(endTime)).append(" ");
|
||||
sql.append(" AND r.create_time > ").append(formatDateTime(startTime)).append(" ");
|
||||
sql.append("ORDER BY r.create_time DESC LIMIT 1");
|
||||
|
||||
return getJdbcTemplate().queryForList(sql.toString(), Long.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countBrokerScanCount(Long brokerId) {
|
||||
String sql = "SELECT count(1) AS count FROM t_stats_record " +
|
||||
"WHERE action = 'CODE_SCAN' AND identifier = '" + brokerId + "'";
|
||||
|
||||
return getJdbcTemplate().queryForObject(sql, Integer.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, Object>> getDailyScanStats(Long brokerId, Date startTime, Date endTime) {
|
||||
SimpleDateFormat dateFormat = createDateFormat();
|
||||
String startDateStr = dateFormat.format(startTime);
|
||||
String endDateStr = dateFormat.format(endTime);
|
||||
String startDateTimeStr = "'" + startDateStr + " 00:00:00'";
|
||||
String endDateTimeStr = "'" + endDateStr + " 23:59:59'";
|
||||
|
||||
String sql = "SELECT toDate(create_time) AS date, count(DISTINCT id) AS scanCount " +
|
||||
"FROM t_stats_record " +
|
||||
"WHERE action = 'CODE_SCAN' " +
|
||||
" AND identifier = '" + brokerId + "' " +
|
||||
" AND create_time >= " + startDateTimeStr + " " +
|
||||
" AND create_time <= " + endDateTimeStr + " " +
|
||||
"GROUP BY toDate(create_time) " +
|
||||
"ORDER BY toDate(create_time)";
|
||||
|
||||
return getJdbcTemplate().query(sql, (rs, rowNum) -> {
|
||||
HashMap<String, Object> map = new HashMap<>();
|
||||
map.put("date", rs.getDate("date"));
|
||||
map.put("scanCount", rs.getLong("scanCount"));
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT formatDateTime(toStartOfHour(s.create_time), '%m-%d %H') AS t, ");
|
||||
sql.append(" uniqExact(s.member_id) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
sql.append("AND r.action = 'LAUNCH' ");
|
||||
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
|
||||
sql.append("GROUP BY toStartOfHour(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfHour(s.create_time)");
|
||||
|
||||
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
map.put("t", rs.getString("t"));
|
||||
map.put("count", rs.getString("count"));
|
||||
return map;
|
||||
});
|
||||
|
||||
return fillHourSeries(rawData, query.getStartTime(), query.getEndTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT formatDateTime(toStartOfDay(s.create_time), '%m-%d') AS t, ");
|
||||
sql.append(" uniqExact(s.member_id) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.trace_id IN (");
|
||||
appendEnterScenicTraceIdSubQuery(sql, query.getScenicId(), query.getStartTime(), query.getEndTime());
|
||||
sql.append(") ");
|
||||
sql.append("AND r.action = 'LAUNCH' ");
|
||||
sql.append("AND JSONExtractInt(r.params, 'scene') IN (1047, 1048, 1049) ");
|
||||
sql.append("GROUP BY toStartOfDay(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfDay(s.create_time)");
|
||||
|
||||
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
map.put("t", rs.getString("t"));
|
||||
map.put("count", rs.getString("count"));
|
||||
return map;
|
||||
});
|
||||
|
||||
return fillDateSeries(rawData, query.getStartTime(), query.getEndTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
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)");
|
||||
|
||||
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
map.put("t", rs.getString("t"));
|
||||
map.put("count", rs.getString("count"));
|
||||
return map;
|
||||
});
|
||||
|
||||
return fillHourSeries(rawData, query.getStartTime(), query.getEndTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT formatDateTime(toStartOfDay(s.create_time), '%m-%d') AS t, ");
|
||||
sql.append(" uniqExact(s.member_id) AS count ");
|
||||
sql.append("FROM t_stats_record r ");
|
||||
sql.append("INNER JOIN t_stats s ON r.trace_id = s.trace_id ");
|
||||
sql.append("WHERE r.action = 'LOAD' ");
|
||||
sql.append("AND r.identifier = 'pages/printer/hello' ");
|
||||
if (query.getScenicId() != null) {
|
||||
sql.append("AND JSONExtractString(r.params, 'scenicId') = '").append(query.getScenicId()).append("' ");
|
||||
}
|
||||
if (query.getStartTime() != null) {
|
||||
sql.append("AND r.create_time >= ").append(formatDateTime(query.getStartTime())).append(" ");
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
sql.append("AND r.create_time <= ").append(formatDateTime(query.getEndTime())).append(" ");
|
||||
}
|
||||
sql.append("GROUP BY toStartOfDay(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfDay(s.create_time)");
|
||||
|
||||
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
map.put("t", rs.getString("t"));
|
||||
map.put("count", rs.getString("count"));
|
||||
return map;
|
||||
});
|
||||
|
||||
return fillDateSeries(rawData, query.getStartTime(), query.getEndTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充小时序列,确保每个小时都有数据(缺失的填充为0)
|
||||
*/
|
||||
private List<HashMap<String, String>> fillHourSeries(List<HashMap<String, String>> rawData, Date startTime, Date endTime) {
|
||||
if (startTime == null || endTime == null) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd HH");
|
||||
LocalDateTime start = startTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().truncatedTo(ChronoUnit.HOURS);
|
||||
LocalDateTime end = endTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().truncatedTo(ChronoUnit.HOURS);
|
||||
|
||||
// 将原始数据转为 Map 以便快速查找
|
||||
Map<String, String> dataMap = rawData.stream()
|
||||
.collect(Collectors.toMap(
|
||||
m -> m.get("t"),
|
||||
m -> m.get("count"),
|
||||
(existing, replacement) -> existing
|
||||
));
|
||||
|
||||
List<HashMap<String, String>> result = new ArrayList<>();
|
||||
LocalDateTime current = start;
|
||||
while (!current.isAfter(end)) {
|
||||
String timeKey = current.format(formatter);
|
||||
HashMap<String, String> item = new HashMap<>();
|
||||
item.put("t", timeKey);
|
||||
item.put("count", dataMap.getOrDefault(timeKey, "0"));
|
||||
result.add(item);
|
||||
current = current.plusHours(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充日期序列,确保每天都有数据(缺失的填充为0)
|
||||
*/
|
||||
private List<HashMap<String, String>> fillDateSeries(List<HashMap<String, String>> rawData, Date startTime, Date endTime) {
|
||||
if (startTime == null || endTime == null) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd");
|
||||
LocalDate start = startTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
LocalDate end = endTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
|
||||
// 将原始数据转为 Map 以便快速查找
|
||||
Map<String, String> dataMap = rawData.stream()
|
||||
.collect(Collectors.toMap(
|
||||
m -> m.get("t"),
|
||||
m -> m.get("count"),
|
||||
(existing, replacement) -> existing
|
||||
));
|
||||
|
||||
List<HashMap<String, String>> result = new ArrayList<>();
|
||||
LocalDate current = start;
|
||||
while (!current.isAfter(end)) {
|
||||
String timeKey = current.format(formatter);
|
||||
HashMap<String, String> item = new HashMap<>();
|
||||
item.put("t", timeKey);
|
||||
item.put("count", dataMap.getOrDefault(timeKey, "0"));
|
||||
result.add(item);
|
||||
current = current.plusDays(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.ycwl.basic.clickhouse.service.impl;
|
||||
|
||||
import com.ycwl.basic.clickhouse.service.StatsQueryService;
|
||||
import com.ycwl.basic.mapper.StatisticsMapper;
|
||||
import com.ycwl.basic.model.mobile.statistic.req.CommonQueryReq;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MySQL 统计数据查询服务实现
|
||||
* 当 clickhouse.enabled 未启用时使用此实现(兜底)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ConditionalOnProperty(prefix = "clickhouse", name = "enabled", havingValue = "false", matchIfMissing = true)
|
||||
public class MySqlStatsQueryServiceImpl implements StatsQueryService {
|
||||
|
||||
@Autowired
|
||||
private StatisticsMapper statisticsMapper;
|
||||
|
||||
@Override
|
||||
public Integer countPreviewVideoOfMember(CommonQueryReq query) {
|
||||
return statisticsMapper.countPreviewVideoOfMember(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countScanCodeOfMember(CommonQueryReq query) {
|
||||
return statisticsMapper.countScanCodeOfMember(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countPushOfMember(CommonQueryReq query) {
|
||||
return statisticsMapper.countPushOfMember(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countUploadFaceOfMember(CommonQueryReq query) {
|
||||
return statisticsMapper.countUploadFaceOfMember(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countCompleteVideoOfMember(CommonQueryReq query) {
|
||||
return statisticsMapper.countCompleteVideoOfMember(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countCompleteOfVideo(CommonQueryReq query) {
|
||||
return statisticsMapper.countCompleteOfVideo(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countTotalVisitorOfMember(CommonQueryReq query) {
|
||||
return statisticsMapper.countTotalVisitorOfMember(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countPreviewOfVideo(CommonQueryReq query) {
|
||||
return statisticsMapper.countPreviewOfVideo(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> getBrokerIdListForUser(Long memberId, Date startTime, Date endTime) {
|
||||
return statisticsMapper.getBrokerIdListForUser(memberId, startTime, endTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getUserRecentEnterType(Long memberId, Date endTime) {
|
||||
return statisticsMapper.getUserRecentEnterType(memberId, endTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> getProjectIdListForUser(Long memberId, Date startTime, Date endTime) {
|
||||
return statisticsMapper.getProjectIdListForUser(memberId, startTime, endTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer countBrokerScanCount(Long brokerId) {
|
||||
return statisticsMapper.countBrokerScanCount(brokerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, Object>> getDailyScanStats(Long brokerId, Date startTime, Date endTime) {
|
||||
return statisticsMapper.getDailyScanStats(brokerId, startTime, endTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query) {
|
||||
return statisticsMapper.scanCodeMemberChartByHour(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query) {
|
||||
return statisticsMapper.scanCodeMemberChartByDate(query);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ycwl.basic.config;
|
||||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/**
|
||||
* ClickHouse 数据源配置
|
||||
* 用于 t_stats 和 t_stats_record 表的查询
|
||||
*
|
||||
* 使用 NamedParameterJdbcTemplate 而非 MyBatis,以避免干扰 MyBatis-Plus 的自动配置
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "clickhouse", name = "enabled", havingValue = "true")
|
||||
public class ClickHouseDataSourceConfig {
|
||||
|
||||
/**
|
||||
* ClickHouse 数据源(非 Primary)
|
||||
*/
|
||||
@Bean(name = "clickHouseDataSource")
|
||||
@ConfigurationProperties(prefix = "clickhouse.datasource")
|
||||
public DataSource clickHouseDataSource() {
|
||||
return new HikariDataSource();
|
||||
}
|
||||
|
||||
@Bean(name = "clickHouseJdbcTemplate")
|
||||
public NamedParameterJdbcTemplate clickHouseJdbcTemplate(
|
||||
@Qualifier("clickHouseDataSource") DataSource dataSource) {
|
||||
return new NamedParameterJdbcTemplate(dataSource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ycwl.basic.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/**
|
||||
* MySQL 主数据源配置
|
||||
*
|
||||
* 当 ClickHouse 启用时,需要显式配置 MySQL 数据源并标记为 @Primary,
|
||||
* 以确保 MyBatis-Plus 和其他组件使用正确的数据源
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "clickhouse", name = "enabled", havingValue = "true")
|
||||
public class MySqlPrimaryDataSourceConfig {
|
||||
|
||||
/**
|
||||
* MySQL 数据源属性
|
||||
*/
|
||||
@Primary
|
||||
@Bean
|
||||
@ConfigurationProperties(prefix = "spring.datasource")
|
||||
public DataSourceProperties mysqlDataSourceProperties() {
|
||||
return new DataSourceProperties();
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 主数据源
|
||||
* 使用 @Primary 确保这是默认数据源
|
||||
*/
|
||||
@Primary
|
||||
@Bean(name = "dataSource")
|
||||
public DataSource mysqlDataSource(DataSourceProperties properties) {
|
||||
return properties.initializeDataSourceBuilder().build();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package com.ycwl.basic.config;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.ycwl.basic.interceptor.AuthInterceptor;
|
||||
import com.ycwl.basic.stats.interceptor.StatsInterceptor;
|
||||
import com.ycwl.basic.puzzle.edge.interceptor.PuzzleEdgeWorkerIpInterceptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -25,20 +25,19 @@ import java.util.List;
|
||||
*/
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Autowired
|
||||
private AuthInterceptor authInterceptor;
|
||||
@Autowired
|
||||
private StatsInterceptor statsInterceptor;
|
||||
private PuzzleEdgeWorkerIpInterceptor puzzleEdgeWorkerIpInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(puzzleEdgeWorkerIpInterceptor)
|
||||
.addPathPatterns("/puzzle/render/v1/**");
|
||||
registry.addInterceptor(authInterceptor)
|
||||
// 拦截除指定接口外的所有请求,通过判断 注解 来决定是否需要做登录验证
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/swagger-resources/**", "/webjars/**", "/v2/api-docs", "/doc.html/**", "/error", "/");
|
||||
registry.addInterceptor(statsInterceptor)
|
||||
.addPathPatterns("/api/mobile/**");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -203,10 +203,17 @@ public class LyCompatibleController {
|
||||
return response;
|
||||
}
|
||||
List<Map<String, Object>> videoList = collect.get(0).stream().collect(Collectors.groupingBy(ContentPageVO::getTemplateId))
|
||||
.values().stream().map(contentPageVOs -> {
|
||||
ContentPageVO contentPageVO = contentPageVOs.getFirst();
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
.values().stream()
|
||||
.map(contentPageVOs -> {
|
||||
ContentPageVO contentPageVO = contentPageVOs.stream().filter(vo -> vo.getContentId() != null).findFirst().orElse(null);
|
||||
if (contentPageVO == null) {
|
||||
return null;
|
||||
}
|
||||
VideoEntity videoRespVO = videoRepository.getVideo(contentPageVO.getContentId());
|
||||
if (videoRespVO == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("id", videoRespVO.getId().toString());
|
||||
map.put("task_id", videoRespVO.getTaskId().toString());
|
||||
if (videoRespVO.getFaceId() != null) {
|
||||
@@ -220,7 +227,7 @@ public class LyCompatibleController {
|
||||
map.put("title", contentPageVO.getName());
|
||||
map.put("ossurldm", videoRespVO.getVideoUrl());
|
||||
return map;
|
||||
}).collect(Collectors.toList());
|
||||
}).filter(java.util.Objects::nonNull).collect(Collectors.toList());
|
||||
GoodsReqQuery goodsReqQuery = new GoodsReqQuery();
|
||||
goodsReqQuery.setFaceId(faceVO.getId());
|
||||
goodsReqQuery.setSourceType(1);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import com.ycwl.basic.constant.BaseContextHandler;
|
||||
import com.ycwl.basic.model.mobile.coupon.req.ClaimCouponReq;
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
|
||||
import com.ycwl.basic.service.mobile.AppCouponRecordService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/coupon/v1")
|
||||
public class AppCouponController {
|
||||
|
||||
@Autowired
|
||||
private AppCouponRecordService appCouponRecordService;
|
||||
|
||||
/**
|
||||
* 根据memberId、faceId和type查找优惠券记录
|
||||
*/
|
||||
@GetMapping("/record")
|
||||
public ApiResponse<CouponRecordEntity> getCouponRecords(
|
||||
@RequestParam Long faceId,
|
||||
@RequestParam Integer type) {
|
||||
CouponRecordEntity record = appCouponRecordService.queryByMemberIdAndFaceIdAndType(Long.valueOf(BaseContextHandler.getUserId()), faceId, type);
|
||||
return ApiResponse.success(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取优惠券
|
||||
*/
|
||||
@PostMapping("/claim")
|
||||
public ApiResponse<CouponEntity> claimCoupon(@RequestBody ClaimCouponReq request) {
|
||||
request.setMemberId(Long.valueOf(BaseContextHandler.getUserId()));
|
||||
try {
|
||||
CouponEntity coupon = appCouponRecordService.claimCoupon(
|
||||
request.getMemberId(),
|
||||
request.getFaceId(),
|
||||
request.getType()
|
||||
);
|
||||
return ApiResponse.success(coupon);
|
||||
} catch (RuntimeException e) {
|
||||
return ApiResponse.fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,23 +64,17 @@ public class AppFaceController {
|
||||
}
|
||||
|
||||
@GetMapping("/{faceId}")
|
||||
public ApiResponse<FaceRespVO> getById(@PathVariable("faceId") Long faceId) {
|
||||
return faceService.getById(faceId);
|
||||
public ApiResponse<FaceEntity> getById(@PathVariable("faceId") Long faceId) {
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
return ApiResponse.success(face);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{faceId}")
|
||||
public ApiResponse<String> deleteFace(@PathVariable("faceId") Long faceId) {
|
||||
// 添加权限检查:验证当前用户是否拥有该 face
|
||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||
Long userId = worker.getUserId();
|
||||
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
throw new BaseException("人脸数据不存在");
|
||||
}
|
||||
if (!face.getMemberId().equals(userId)) {
|
||||
throw new BaseException("无权删除此人脸");
|
||||
}
|
||||
|
||||
return faceService.deleteFace(faceId);
|
||||
}
|
||||
@@ -102,9 +96,8 @@ public class AppFaceController {
|
||||
// 绑定人脸
|
||||
@PostMapping("/{faceId}/bind")
|
||||
public ApiResponse<String> bind(@PathVariable Long faceId) {
|
||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||
Long userId = worker.getUserId();
|
||||
faceService.bindFace(faceId, userId);
|
||||
// dummy item
|
||||
faceService.matchFaceId(faceId, true);
|
||||
return ApiResponse.success("OK");
|
||||
}
|
||||
|
||||
|
||||
@@ -53,12 +53,6 @@ public class AppGoodsController {
|
||||
return ApiResponse.success(count);
|
||||
}
|
||||
|
||||
@PostMapping("/sourceGoodsList/preview")
|
||||
public ApiResponse<List<GoodsUrlVO>> sourceGoodsListPreview(@RequestBody GoodsReqQuery query) {
|
||||
List<GoodsUrlVO> goodsUrlList = goodsService.sourceGoodsListPreview(query);
|
||||
return ApiResponse.success(goodsUrlList);
|
||||
}
|
||||
|
||||
@PostMapping("/sourceGoodsList/download")
|
||||
public ApiResponse<List<GoodsUrlVO>> sourceGoodsListDownload(@RequestBody GoodsReqQuery query) {
|
||||
List<GoodsUrlVO> goodsUrlList = goodsService.sourceGoodsListDownload(query);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.constant.BaseContextHandler;
|
||||
import com.ycwl.basic.model.mobile.weChat.DTO.WeChatUserInfoDTO;
|
||||
import com.ycwl.basic.model.mobile.weChat.DTO.WeChatUserInfoUpdateDTO;
|
||||
import com.ycwl.basic.model.pc.member.resp.MemberRespVO;
|
||||
@@ -67,7 +68,8 @@ public class AppMemberController {
|
||||
// 修改用户信息
|
||||
@PostMapping("/update")
|
||||
public ApiResponse<?> update(@RequestBody WeChatUserInfoUpdateDTO userInfoUpdateDTO) {
|
||||
return memberService.update(userInfoUpdateDTO);
|
||||
Long userId = Long.parseLong(BaseContextHandler.getUserId());
|
||||
return memberService.update(userId, userInfoUpdateDTO);
|
||||
}
|
||||
|
||||
// 新增或修改景区服务通知状态
|
||||
|
||||
@@ -27,6 +27,12 @@ import com.ycwl.basic.order.dto.OrderV2PageRequest;
|
||||
import com.ycwl.basic.order.dto.PaymentParamsRequest;
|
||||
import com.ycwl.basic.order.dto.PaymentParamsResponse;
|
||||
import com.ycwl.basic.order.dto.PaymentCallbackResponse;
|
||||
import com.ycwl.basic.order.exception.DuplicatePurchaseException;
|
||||
import com.ycwl.basic.order.factory.DuplicatePurchaseCheckerFactory;
|
||||
import com.ycwl.basic.order.strategy.DuplicateCheckContext;
|
||||
import com.ycwl.basic.order.strategy.IDuplicatePurchaseChecker;
|
||||
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
|
||||
import com.ycwl.basic.product.service.IProductTypeCapabilityService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
@@ -57,10 +63,11 @@ public class AppOrderV2Controller {
|
||||
private final TemplateRepository templateRepository;
|
||||
private final VideoRepository videoRepository;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final IProductTypeCapabilityService productTypeCapabilityService;
|
||||
private final DuplicatePurchaseCheckerFactory duplicatePurchaseCheckerFactory;
|
||||
|
||||
/**
|
||||
* 移动端价格计算
|
||||
* 包含权限验证:验证人脸所属景区与当前用户匹配
|
||||
* 集成Redis缓存机制,提升查询性能
|
||||
*/
|
||||
@PostMapping("/calculate")
|
||||
@@ -102,6 +109,12 @@ public class AppOrderV2Controller {
|
||||
Long scenicId = face.getScenicId();
|
||||
|
||||
request.getProducts().forEach(product -> {
|
||||
// 获取商品的重复检查策略
|
||||
DuplicateCheckStrategy strategy = productTypeCapabilityService
|
||||
.getDuplicateCheckStrategy(product.getProductType().name());
|
||||
|
||||
boolean hasPurchasedFlag;
|
||||
|
||||
switch (product.getProductType()) {
|
||||
case VLOG_VIDEO:
|
||||
List<MemberVideoEntity> videoEntities = videoMapper.listRelationByFaceAndTemplate(face.getId(), Long.valueOf(product.getProductId()));
|
||||
@@ -132,6 +145,13 @@ public class AppOrderV2Controller {
|
||||
log.warn("未知的商品类型,跳过重复购买检查: productType={}", product.getProductType());
|
||||
break;
|
||||
}
|
||||
|
||||
// 使用 DuplicatePurchaseChecker 检查是否已购买
|
||||
hasPurchasedFlag = checkIfPurchased(strategy, currentUserId, String.valueOf(scenicId),
|
||||
product.getProductType().name(), product.getProductId(), face.getId());
|
||||
|
||||
// 设置是否已购买标识
|
||||
product.setHasPurchased(hasPurchasedFlag);
|
||||
});
|
||||
|
||||
// 转换为标准价格计算请求
|
||||
@@ -140,6 +160,12 @@ public class AppOrderV2Controller {
|
||||
// 执行价格计算
|
||||
PriceCalculationResult result = priceCalculationService.calculatePrice(standardRequest);
|
||||
|
||||
// 设置是否已购买标识(基于请求中的商品 hasPurchased 判断)
|
||||
// 只要有一个商品 hasPurchased = true,则整体 isPurchased = true
|
||||
boolean isPurchased = request.getProducts().stream()
|
||||
.anyMatch(product -> Boolean.TRUE.equals(product.getHasPurchased()));
|
||||
result.setIsPurchased(isPurchased);
|
||||
|
||||
// 将计算结果缓存到Redis
|
||||
String cacheKey = priceCacheService.cachePriceResult(currentUserId, scenicId, request.getProducts(), result);
|
||||
|
||||
@@ -355,4 +381,55 @@ public class AppOrderV2Controller {
|
||||
public ApiResponse<Boolean> getDownloadableOrder(@PathVariable("orderId") Long orderId) {
|
||||
return ApiResponse.success(!redisTemplate.hasKey("order_content_not_downloadable_" + orderId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查商品是否已购买
|
||||
* 使用 DuplicatePurchaseChecker 通过异常捕获判断
|
||||
*
|
||||
* @param strategy 重复检查策略
|
||||
* @param userId 用户ID
|
||||
* @param scenicId 景区ID
|
||||
* @param productType 商品类型
|
||||
* @param productId 商品ID
|
||||
* @param faceId 人脸ID
|
||||
* @return true-已购买, false-未购买
|
||||
*/
|
||||
private boolean checkIfPurchased(DuplicateCheckStrategy strategy, Long userId, String scenicId,
|
||||
String productType, String productId, Long faceId) {
|
||||
// NO_CHECK 策略表示允许重复购买,直接返回 false
|
||||
if (strategy == DuplicateCheckStrategy.NO_CHECK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取对应的检查器
|
||||
IDuplicatePurchaseChecker checker = duplicatePurchaseCheckerFactory.getChecker(strategy);
|
||||
|
||||
// 构建检查上下文
|
||||
DuplicateCheckContext context = new DuplicateCheckContext();
|
||||
context.setUserId(String.valueOf(userId));
|
||||
context.setScenicId(scenicId);
|
||||
context.setProductType(productType);
|
||||
context.setProductId(productId);
|
||||
context.addParam("faceId", faceId);
|
||||
|
||||
// 执行检查,如果抛出异常则表示已购买
|
||||
checker.check(context);
|
||||
|
||||
// 没有抛出异常,表示未购买
|
||||
return false;
|
||||
|
||||
} catch (DuplicatePurchaseException e) {
|
||||
// 捕获到重复购买异常,表示已购买
|
||||
log.debug("检测到已购买: userId={}, scenicId={}, productType={}, productId={}",
|
||||
userId, scenicId, productType, productId);
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
// 其他异常,记录日志并返回 false(保守处理)
|
||||
log.warn("检查是否已购买时发生异常: userId={}, scenicId={}, productType={}, productId={}, error={}",
|
||||
userId, scenicId, productType, productId, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import com.ycwl.basic.biz.OrderBiz;
|
||||
import com.ycwl.basic.constant.SourceType;
|
||||
import com.ycwl.basic.constant.FreeStatus;
|
||||
import com.ycwl.basic.image.watermark.edge.PuzzleDefaultWatermarkTemplateBuilder;
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeTaskCreator;
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkRequest;
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.pc.puzzle.entity.MemberPuzzleEntity;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
|
||||
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
|
||||
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
|
||||
import com.ycwl.basic.pricing.dto.ProductItem;
|
||||
import com.ycwl.basic.pricing.enums.ProductType;
|
||||
import com.ycwl.basic.pricing.service.IPriceCalculationService;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.MemberPuzzleMapper;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.service.pc.FaceService;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/puzzle/v1")
|
||||
@RequiredArgsConstructor
|
||||
public class AppPuzzleController {
|
||||
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
private final PuzzleRepository puzzleRepository;
|
||||
private final FaceRepository faceRepository;
|
||||
private final IPriceCalculationService iPriceCalculationService;
|
||||
private final PrinterService printerService;
|
||||
private final OrderBiz orderBiz;
|
||||
private final MemberPuzzleMapper memberPuzzleMapper;
|
||||
private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator;
|
||||
private final FaceService faceService;
|
||||
private final ScenicRepository scenicRepository;
|
||||
|
||||
/**
|
||||
* 根据faceId查询三拼图数量
|
||||
@@ -46,8 +61,9 @@ public class AppPuzzleController {
|
||||
if (faceId == null) {
|
||||
return ApiResponse.fail("faceId不能为空");
|
||||
}
|
||||
int count = recordMapper.countByFaceId(faceId);
|
||||
return ApiResponse.success(count);
|
||||
// 通过关联表查询数量
|
||||
List<MemberPuzzleEntity> relations = memberPuzzleMapper.listByFaceId(faceId);
|
||||
return ApiResponse.success(relations.size());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,9 +74,17 @@ public class AppPuzzleController {
|
||||
if (faceId == null) {
|
||||
return ApiResponse.fail("faceId不能为空");
|
||||
}
|
||||
List<PuzzleGenerationRecordEntity> records = recordMapper.listByFaceId(faceId);
|
||||
List<ContentPageVO> result = records.stream()
|
||||
.map(this::convertToContentPageVO)
|
||||
// 通过关联表查询,获取关联的拼图记录
|
||||
List<MemberPuzzleEntity> relations = memberPuzzleMapper.listByFaceId(faceId);
|
||||
List<ContentPageVO> result = relations.stream()
|
||||
.map(relation -> {
|
||||
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(relation.getRecordId());
|
||||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
return convertToContentPageVO(record, relation);
|
||||
})
|
||||
.filter(vo -> vo != null)
|
||||
.collect(Collectors.toList());
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
@@ -73,23 +97,26 @@ public class AppPuzzleController {
|
||||
if (recordId == null) {
|
||||
return ApiResponse.fail("recordId不能为空");
|
||||
}
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
ContentPageVO result = convertToContentPageVO(record);
|
||||
// 查询关联记录
|
||||
MemberPuzzleEntity relation = memberPuzzleMapper.getByFaceAndRecord(record.getFaceId(), recordId);
|
||||
ContentPageVO result = convertToContentPageVO(record, relation);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据recordId下载拼图资源
|
||||
* 如果是免费赠送的拼图,会添加水印后返回
|
||||
*/
|
||||
@GetMapping("/download/{recordId}")
|
||||
public ApiResponse<List<String>> download(@PathVariable("recordId") Long recordId) {
|
||||
if (recordId == null) {
|
||||
return ApiResponse.fail("recordId不能为空");
|
||||
}
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
@@ -97,9 +124,88 @@ public class AppPuzzleController {
|
||||
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
|
||||
return ApiResponse.fail("该拼图记录没有可用的图片URL");
|
||||
}
|
||||
|
||||
// 查询该拼图的关联记录,判断是否为免费赠送
|
||||
Long faceId = record.getFaceId();
|
||||
if (faceId != null) {
|
||||
MemberPuzzleEntity memberPuzzle = memberPuzzleMapper.getByFaceAndRecord(faceId, recordId);
|
||||
if (memberPuzzle != null && FreeStatus.isFree(memberPuzzle.getIsFree())) {
|
||||
// 免费赠送的拼图,需要添加水印
|
||||
String watermarkedUrl = addWatermarkForFreePuzzle(record);
|
||||
if (watermarkedUrl != null) {
|
||||
return ApiResponse.success(Collections.singletonList(watermarkedUrl));
|
||||
}
|
||||
// 如果水印添加失败,记录日志并返回原图
|
||||
log.warn("免费拼图水印添加失败,返回原图: recordId={}", recordId);
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponse.success(Collections.singletonList(resultImageUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为免费赠送的拼图添加水印
|
||||
*
|
||||
* @param record 拼图记录
|
||||
* @return 带水印的图片URL,失败返回null
|
||||
*/
|
||||
private String addWatermarkForFreePuzzle(PuzzleGenerationRecordEntity record) {
|
||||
try {
|
||||
Long faceId = record.getFaceId();
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
log.warn("添加水印失败:未找到人脸信息, faceId={}", faceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取景区信息
|
||||
ScenicEntity scenic = scenicRepository.getScenic(face.getScenicId());
|
||||
String scenicLine = scenic != null ? scenic.getName() : "";
|
||||
|
||||
// 获取二维码URL
|
||||
String qrcodeUrl = faceService.bindWxaCode(faceId);
|
||||
|
||||
// 格式化日期时间
|
||||
String datetimeLine = record.getCreateTime() != null
|
||||
? DateUtil.format(record.getCreateTime(), "yyyy-MM-dd")
|
||||
: "";
|
||||
|
||||
// 构建水印请求
|
||||
WatermarkRequest request = WatermarkRequest.builder()
|
||||
.originalImageUrl(record.getResultImageUrl())
|
||||
.imageWidth(record.getResultWidth() != null ? record.getResultWidth() : 0)
|
||||
.imageHeight(record.getResultHeight() != null ? record.getResultHeight() : 0)
|
||||
.qrcodeUrl(qrcodeUrl)
|
||||
.faceUrl(face.getFaceUrl())
|
||||
.scenicLine(scenicLine)
|
||||
.datetimeLine(datetimeLine)
|
||||
.outputFormat("JPEG")
|
||||
.outputQuality(90)
|
||||
.build();
|
||||
|
||||
// 创建水印任务并等待结果
|
||||
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
|
||||
PuzzleDefaultWatermarkTemplateBuilder.STYLE,
|
||||
request,
|
||||
record.getId(),
|
||||
faceId,
|
||||
"free_puzzle_download",
|
||||
30_000L // 30秒超时
|
||||
);
|
||||
|
||||
if (result.isSuccess()) {
|
||||
log.info("免费拼图水印添加成功: recordId={}, url={}", record.getId(), result.getImageUrl());
|
||||
return result.getImageUrl();
|
||||
} else {
|
||||
log.error("免费拼图水印添加失败: recordId={}, error={}", record.getId(), result.getErrorMessage());
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("免费拼图水印添加异常: recordId={}", record.getId(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据recordId查询拼图价格
|
||||
*/
|
||||
@@ -108,7 +214,7 @@ public class AppPuzzleController {
|
||||
if (recordId == null) {
|
||||
return ApiResponse.fail("recordId不能为空");
|
||||
}
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
@@ -142,14 +248,14 @@ public class AppPuzzleController {
|
||||
}
|
||||
|
||||
// 查询拼图记录
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
|
||||
// 检查是否有图片URL
|
||||
String resultImageUrl = record.getResultImageUrl();
|
||||
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
|
||||
String imageUrl = record.getResultImageUrl();
|
||||
if (imageUrl == null || imageUrl.isEmpty()) {
|
||||
return ApiResponse.fail("该拼图记录没有可用的图片URL");
|
||||
}
|
||||
|
||||
@@ -164,8 +270,8 @@ public class AppPuzzleController {
|
||||
face.getMemberId(),
|
||||
face.getScenicId(),
|
||||
record.getFaceId(),
|
||||
resultImageUrl,
|
||||
0L // 打印特有
|
||||
imageUrl,
|
||||
recordId // 拼图记录ID,用于关联 puzzle_record 表
|
||||
);
|
||||
|
||||
if (memberPrintId == null) {
|
||||
@@ -177,8 +283,11 @@ public class AppPuzzleController {
|
||||
|
||||
/**
|
||||
* 将PuzzleGenerationRecordEntity转换为ContentPageVO
|
||||
*
|
||||
* @param record 拼图生成记录
|
||||
* @param relation 会员拼图关联记录,用于获取免费状态
|
||||
*/
|
||||
private ContentPageVO convertToContentPageVO(PuzzleGenerationRecordEntity record) {
|
||||
private ContentPageVO convertToContentPageVO(PuzzleGenerationRecordEntity record, MemberPuzzleEntity relation) {
|
||||
ContentPageVO vo = new ContentPageVO();
|
||||
|
||||
// 内容类型为3(拼图)
|
||||
@@ -214,21 +323,11 @@ public class AppPuzzleController {
|
||||
vo.setIsBuy(1);
|
||||
} else {
|
||||
vo.setIsBuy(0);
|
||||
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
|
||||
ProductItem productItem = new ProductItem();
|
||||
productItem.setProductType(ProductType.PHOTO_LOG);
|
||||
productItem.setProductId(record.getTemplateId().toString());
|
||||
productItem.setPurchaseCount(1);
|
||||
productItem.setScenicId(face.getScenicId().toString());
|
||||
calculationRequest.setProducts(Collections.singletonList(productItem));
|
||||
calculationRequest.setUserId(face.getMemberId());
|
||||
calculationRequest.setFaceId(record.getFaceId());
|
||||
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
PriceCalculationResult calculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
||||
if (calculationResult.getFinalAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
vo.setFreeCount(0);
|
||||
} else {
|
||||
// 从关联记录读取免费状态
|
||||
if (relation != null && FreeStatus.isFree(relation.getIsFree())) {
|
||||
vo.setFreeCount(1);
|
||||
} else {
|
||||
vo.setFreeCount(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.constant.BaseContextHandler;
|
||||
import com.ycwl.basic.model.mobile.scenic.ScenicAppVO;
|
||||
import com.ycwl.basic.model.mobile.scenic.ScenicDeviceCountVO;
|
||||
import com.ycwl.basic.model.mobile.scenic.ScenicIndexVO;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
|
||||
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
|
||||
import com.ycwl.basic.model.pc.scenic.resp.ScenicConfigResp;
|
||||
import com.ycwl.basic.model.pc.scenic.resp.ScenicRespVO;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.service.mobile.AppScenicService;
|
||||
import com.ycwl.basic.service.pc.FaceService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Author:longbinbin
|
||||
* @Date:2024/12/5 10:22
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/scenic/v1")
|
||||
// 景区相关接口
|
||||
public class AppScenicController {
|
||||
|
||||
@Autowired
|
||||
private FaceService faceService;
|
||||
@Autowired
|
||||
private AppScenicService appScenicService;
|
||||
@Autowired
|
||||
private ScenicRepository scenicRepository;
|
||||
private static final List<String> ENABLED_USER_IDs = new ArrayList<>(){{
|
||||
add("3932535453961555968");
|
||||
add("3936121342868459520");
|
||||
add("3936940597855784960");
|
||||
add("4049850382325780480");
|
||||
}};
|
||||
|
||||
// 分页查询景区列表
|
||||
@PostMapping("/page")
|
||||
public ApiResponse<PageInfo<ScenicEntity>> pageQuery(@RequestBody ScenicReqQuery scenicReqQuery){
|
||||
String userId = BaseContextHandler.getUserId();
|
||||
if (ENABLED_USER_IDs.contains(userId)) {
|
||||
return appScenicService.pageQuery(scenicReqQuery);
|
||||
} else {
|
||||
return ApiResponse.success(new PageInfo<>(new ArrayList<>()));
|
||||
}
|
||||
}
|
||||
// 根据id查询景区详情
|
||||
@IgnoreToken
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<ScenicRespVO> getDetails(@PathVariable Long id){
|
||||
return appScenicService.getDetails(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/config")
|
||||
@IgnoreToken
|
||||
public ApiResponse<ScenicConfigResp> getConfig(@PathVariable Long id){
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(id);
|
||||
ScenicConfigResp resp = new ScenicConfigResp();
|
||||
resp.setWatermarkUrl(scenicConfig.getString("watermark_url"));
|
||||
resp.setVideoStoreDay(scenicConfig.getInteger("video_store_day"));
|
||||
resp.setAntiScreenRecordType(scenicConfig.getInteger("anti_screen_record_type"));
|
||||
resp.setGroupingEnable(scenicConfig.getBoolean("grouping_enable", false));
|
||||
resp.setVoucherEnable(scenicConfig.getBoolean("voucher_enable", false));
|
||||
resp.setShowPhotoWhenWaiting(scenicConfig.getBoolean("show_photo_when_waiting", false));
|
||||
resp.setImageSourcePackHint(scenicConfig.getString("image_source_pack_hint"));
|
||||
resp.setVideoSourcePackHint(scenicConfig.getString("video_source_pack_hint"));
|
||||
resp.setShareBeforeBuy(scenicConfig.getBoolean("share_before_buy"));
|
||||
resp.setFaceSelectFirst(scenicConfig.getBoolean("face_select_first", false));
|
||||
resp.setPrintEnableSource(scenicConfig.getBoolean("print_enable_source", true));
|
||||
resp.setPrintForceFaceUpload(scenicConfig.getBoolean("print_force_face_upload", false));
|
||||
resp.setPrintEnableManual(scenicConfig.getBoolean("print_enable_manual", true));
|
||||
resp.setSceneMode(scenicConfig.getInteger("scene_mode", 0));
|
||||
resp.setPrintEnable(scenicConfig.getBoolean("print_enable", false));
|
||||
resp.setShowMyPagePaid(scenicConfig.getBoolean("show_my_page_paid", true));
|
||||
resp.setShowMyPageUnpaid(scenicConfig.getBoolean("show_my_page_unpaid", true));
|
||||
return ApiResponse.success(resp);
|
||||
}
|
||||
|
||||
// 查询景区设备总数和拍到用户的机位数量
|
||||
@GetMapping("/{scenicId}/deviceCount/")
|
||||
public ApiResponse<ScenicDeviceCountVO> deviceCountByScenicId(@PathVariable Long scenicId){
|
||||
return appScenicService.deviceCountByScenicId(scenicId);
|
||||
}
|
||||
|
||||
// 景区视频源素材列表
|
||||
@GetMapping("/contentList/")
|
||||
public ApiResponse<List<ContentPageVO>> contentList() {
|
||||
return faceService.contentListUseDefaultFace();
|
||||
}
|
||||
|
||||
// 景区视频源素材列表
|
||||
@GetMapping("/face/{faceId}/contentList")
|
||||
public ApiResponse<List<ContentPageVO>> contentList(@PathVariable Long faceId) {
|
||||
List<ContentPageVO> contentPageVOS = faceService.faceContentList(faceId);
|
||||
return ApiResponse.success(contentPageVOS);
|
||||
}
|
||||
|
||||
@PostMapping("/nearby")
|
||||
public ApiResponse<List<ScenicAppVO>> nearby(@RequestBody ScenicIndexVO scenicIndexVO) {
|
||||
List<ScenicAppVO> list = appScenicService.scenicListByLnLa(scenicIndexVO);
|
||||
return ApiResponse.success(list);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import com.ycwl.basic.mapper.TemplateMapper;
|
||||
import com.ycwl.basic.model.pc.template.entity.TemplateEntity;
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ScenicTemplateContentVO;
|
||||
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.repository.TemplateRepository;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -11,6 +13,10 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 移动端模板接口
|
||||
*/
|
||||
@@ -20,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class AppTemplateController {
|
||||
|
||||
private final TemplateRepository templateRepository;
|
||||
private final PuzzleRepository puzzleRepository;
|
||||
|
||||
/**
|
||||
* 根据模板ID获取封面URL
|
||||
@@ -45,4 +52,80 @@ public class AppTemplateController {
|
||||
|
||||
return ApiResponse.success(coverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据景区ID获取所有模板封面URL列表(用于前端预缓存)
|
||||
*
|
||||
* @param scenicId 景区ID
|
||||
* @return 模板封面URL列表
|
||||
*/
|
||||
@GetMapping("/scenic/{scenicId}/covers")
|
||||
@IgnoreToken
|
||||
public ApiResponse<List<String>> getScenicTemplateCoverUrls(@PathVariable("scenicId") Long scenicId) {
|
||||
if (scenicId == null) {
|
||||
return ApiResponse.fail("景区ID不能为空");
|
||||
}
|
||||
|
||||
List<String> coverUrls = new ArrayList<>();
|
||||
|
||||
// 获取普通模板封面
|
||||
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(scenicId);
|
||||
templateList.stream()
|
||||
.map(TemplateRespVO::getCoverUrl)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(url -> !url.isEmpty())
|
||||
.forEach(coverUrls::add);
|
||||
|
||||
// 获取拼图模板封面(使用缓存)
|
||||
List<PuzzleTemplateEntity> puzzleTemplateList = puzzleRepository.listTemplateByScenic(scenicId);
|
||||
puzzleTemplateList.stream()
|
||||
.map(PuzzleTemplateEntity::getCoverImage)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(url -> !url.isEmpty())
|
||||
.forEach(coverUrls::add);
|
||||
|
||||
return ApiResponse.success(coverUrls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据景区ID获取所有模板内容列表(返回模板基础信息,与 faceId 无关)
|
||||
*
|
||||
* @param scenicId 景区ID
|
||||
* @return 景区模板内容列表
|
||||
*/
|
||||
@GetMapping("/scenic/{scenicId}/contents")
|
||||
@IgnoreToken
|
||||
public ApiResponse<List<ScenicTemplateContentVO>> getScenicTemplateContents(@PathVariable("scenicId") Long scenicId) {
|
||||
if (scenicId == null) {
|
||||
return ApiResponse.fail("景区ID不能为空");
|
||||
}
|
||||
|
||||
List<ScenicTemplateContentVO> contentList = new ArrayList<>();
|
||||
|
||||
// 获取普通模板
|
||||
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(scenicId);
|
||||
for (TemplateRespVO template : templateList) {
|
||||
ScenicTemplateContentVO content = new ScenicTemplateContentVO();
|
||||
content.setGoodsType(0); // 普通模板默认商品类型为 0
|
||||
content.setName(template.getName());
|
||||
content.setGroup(template.getGroup());
|
||||
content.setTemplateId(template.getId());
|
||||
content.setTemplateCoverUrl(template.getCoverUrl());
|
||||
contentList.add(content);
|
||||
}
|
||||
|
||||
// 获取拼图模板
|
||||
List<PuzzleTemplateEntity> puzzleTemplateList = puzzleRepository.listTemplateByScenic(scenicId);
|
||||
for (PuzzleTemplateEntity puzzleTemplate : puzzleTemplateList) {
|
||||
ScenicTemplateContentVO content = new ScenicTemplateContentVO();
|
||||
content.setGoodsType(3); // 拼图模板商品类型为 3
|
||||
content.setName(puzzleTemplate.getName());
|
||||
content.setGroup("氛围拼图"); // 拼图模板固定分组
|
||||
content.setTemplateId(puzzleTemplate.getId());
|
||||
content.setTemplateCoverUrl(puzzleTemplate.getCoverImage());
|
||||
contentList.add(content);
|
||||
}
|
||||
|
||||
return ApiResponse.success(contentList);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.ycwl.basic.controller.mobile.notify;
|
||||
|
||||
import com.ycwl.basic.model.mobile.notify.req.BatchRemainingCountReq;
|
||||
import com.ycwl.basic.model.mobile.notify.req.NotificationAuthRecordReq;
|
||||
import com.ycwl.basic.model.mobile.notify.resp.NotificationAuthRecordResp;
|
||||
import com.ycwl.basic.model.mobile.notify.resp.ScenicTemplateAuthResp;
|
||||
import com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationEntity;
|
||||
import com.ycwl.basic.service.UserNotificationAuthorizationService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||
@@ -14,7 +16,9 @@ import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户通知授权记录Controller (移动端API)
|
||||
@@ -41,7 +45,8 @@ public class UserNotificationAuthController {
|
||||
@PostMapping("/record")
|
||||
public ApiResponse<NotificationAuthRecordResp> recordAuthorization(
|
||||
@RequestBody NotificationAuthRecordReq req) {
|
||||
log.debug("记录用户通知授权: templateIds={}, scenicId={}", req.getTemplateIds(), req.getScenicId());
|
||||
log.debug("记录用户通知授权: templateIds={}, scenicId={}, requestId={}",
|
||||
req.getTemplateIds(), req.getScenicId(), req.getRequestId());
|
||||
|
||||
try {
|
||||
// 获取当前用户ID
|
||||
@@ -50,7 +55,7 @@ public class UserNotificationAuthController {
|
||||
// 调用批量授权记录方法
|
||||
List<UserNotificationAuthorizationService.AuthorizationRecord> records =
|
||||
userNotificationAuthorizationService.batchRecordAuthorization(
|
||||
memberId, req.getTemplateIds(), req.getScenicId());
|
||||
memberId, req.getTemplateIds(), req.getScenicId(), req.getRequestId());
|
||||
|
||||
NotificationAuthRecordResp resp = new NotificationAuthRecordResp();
|
||||
|
||||
@@ -93,98 +98,42 @@ public class UserNotificationAuthController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取景区通知模板ID及用户授权余额
|
||||
* 复制AppWxNotifyController中的逻辑,并额外返回用户对应的授权余额
|
||||
* 批量查询用户授权余额
|
||||
* 返回 Map<wechatTemplateId, remainingCount>
|
||||
*/
|
||||
@GetMapping("/{scenicId}/templates")
|
||||
public ApiResponse<ScenicTemplateAuthResp> getScenicTemplatesWithAuth(@PathVariable("scenicId") Long scenicId) {
|
||||
log.debug("获取景区通知模板ID及用户授权余额: scenicId={}", scenicId);
|
||||
@PostMapping("/batch-remaining")
|
||||
public ApiResponse<Map<String, Integer>> batchGetRemainingCount(
|
||||
@RequestBody BatchRemainingCountReq req) {
|
||||
log.debug("批量查询用户授权余额: templateIds={}, scenicId={}",
|
||||
req.getTemplateIds(), req.getScenicId());
|
||||
|
||||
try {
|
||||
// 获取当前用户ID
|
||||
Long memberId = JwtTokenUtil.getWorker().getUserId();
|
||||
|
||||
// 获取景区的所有模板ID(复制自AppWxNotifyController的逻辑)
|
||||
List<String> templateIds = new ArrayList<>() {{
|
||||
String videoGeneratedTemplateId = scenicRepository.getVideoGeneratedTemplateId(scenicId);
|
||||
if (StringUtils.isNotBlank(videoGeneratedTemplateId)) {
|
||||
add(videoGeneratedTemplateId);
|
||||
}
|
||||
String videoDownloadTemplateId = scenicRepository.getVideoDownloadTemplateId(scenicId);
|
||||
if (StringUtils.isNotBlank(videoDownloadTemplateId)) {
|
||||
add(videoDownloadTemplateId);
|
||||
}
|
||||
String videoPreExpireTemplateId = scenicRepository.getVideoPreExpireTemplateId(scenicId);
|
||||
if (StringUtils.isNotBlank(videoPreExpireTemplateId)) {
|
||||
add(videoPreExpireTemplateId);
|
||||
}
|
||||
}};
|
||||
|
||||
// 构建响应对象
|
||||
ScenicTemplateAuthResp resp = new ScenicTemplateAuthResp();
|
||||
resp.setScenicId(scenicId);
|
||||
|
||||
// 查询每个模板的授权余额信息
|
||||
List<ScenicTemplateAuthResp.TemplateAuthInfo> templateAuthInfos = new ArrayList<>();
|
||||
for (String templateId : templateIds) {
|
||||
ScenicTemplateAuthResp.TemplateAuthInfo templateAuthInfo =
|
||||
new ScenicTemplateAuthResp.TemplateAuthInfo();
|
||||
templateAuthInfo.setTemplateId(templateId);
|
||||
|
||||
if (templateId.equals(scenicRepository.getVideoGeneratedTemplateId(scenicId))) {
|
||||
templateAuthInfo.setTitle("视频生成通知");
|
||||
templateAuthInfo.setDescription("当视频生成完成时,我们将提醒您");
|
||||
} else if (templateId.equals(scenicRepository.getVideoDownloadTemplateId(scenicId))) {
|
||||
templateAuthInfo.setTitle("视频下载通知");
|
||||
templateAuthInfo.setDescription("当您的视频未购买时,我们将提醒您");
|
||||
} else if (templateId.equals(scenicRepository.getVideoPreExpireTemplateId(scenicId))) {
|
||||
templateAuthInfo.setTitle("视频即将过期通知");
|
||||
templateAuthInfo.setDescription("当您的视频即将过期时,我们将提醒您及时下载");
|
||||
} else {
|
||||
templateAuthInfo.setTitle("未知模板类型");
|
||||
templateAuthInfo.setDescription("未知的模板类型");
|
||||
if (memberId == null) {
|
||||
return ApiResponse.fail("用户未登录");
|
||||
}
|
||||
|
||||
// 获取授权详情
|
||||
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);
|
||||
if (CollectionUtils.isEmpty(req.getTemplateIds())) {
|
||||
return ApiResponse.success(new HashMap<>());
|
||||
}
|
||||
|
||||
Map<String, UserNotificationAuthorizationEntity> authMap =
|
||||
userNotificationAuthorizationService.batchCheckAuthorization(
|
||||
memberId, req.getTemplateIds(), req.getScenicId());
|
||||
|
||||
// 转换为 templateId -> remainingCount
|
||||
Map<String, Integer> result = new HashMap<>();
|
||||
for (String templateId : req.getTemplateIds()) {
|
||||
UserNotificationAuthorizationEntity entity = authMap.get(templateId);
|
||||
int remaining = (entity != null && entity.getRemainingCount() != null)
|
||||
? entity.getRemainingCount() : 0;
|
||||
result.put(templateId, remaining);
|
||||
}
|
||||
|
||||
return ApiResponse.success(result);
|
||||
} catch (Exception e) {
|
||||
log.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);
|
||||
|
||||
log.debug("成功获取景区通知模板ID及用户授权余额: scenicId={}, templateCount={}, memberId={}",
|
||||
scenicId, templateIds.size(), memberId);
|
||||
|
||||
return ApiResponse.success(resp);
|
||||
} catch (Exception e) {
|
||||
log.error("获取景区通知模板ID及用户授权余额失败: scenicId={}", scenicId, e);
|
||||
return ApiResponse.fail("获取授权信息失败: " + e.getMessage());
|
||||
log.error("批量查询用户授权余额失败", e);
|
||||
return ApiResponse.fail("查询失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.ycwl.basic.controller.mobile.notify;
|
||||
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeAllScenesResp;
|
||||
import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeSceneTemplatesResp;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
|
||||
import com.ycwl.basic.service.notify.WechatSubscribeNotifyConfigService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||
import com.ycwl.basic.utils.NotificationAuthUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 微信小程序订阅消息:场景模板查询(移动端API)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/notify/subscribe")
|
||||
@Slf4j
|
||||
public class WechatSubscribeNotifyController {
|
||||
|
||||
private final WechatSubscribeNotifyConfigService configService;
|
||||
private final NotificationAuthUtils notificationAuthUtils;
|
||||
|
||||
public WechatSubscribeNotifyController(WechatSubscribeNotifyConfigService configService,
|
||||
NotificationAuthUtils notificationAuthUtils) {
|
||||
this.configService = configService;
|
||||
this.notificationAuthUtils = notificationAuthUtils;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取“场景”下可申请授权的模板列表(支持按 scenicId 覆盖模板ID/开关/文案)
|
||||
*/
|
||||
@GetMapping("/scenic/{scenicId}/scenes/{sceneKey}/templates")
|
||||
@IgnoreToken
|
||||
public ApiResponse<WechatSubscribeSceneTemplatesResp> listSceneTemplates(@PathVariable("scenicId") Long scenicId,
|
||||
@PathVariable("sceneKey") String sceneKey) {
|
||||
if (scenicId == null) {
|
||||
return ApiResponse.fail("scenicId不能为空");
|
||||
}
|
||||
if (StringUtils.isBlank(sceneKey)) {
|
||||
return ApiResponse.fail("sceneKey不能为空");
|
||||
}
|
||||
|
||||
Long memberId = JwtTokenUtil.getWorker().getUserId();
|
||||
List<WechatSubscribeTemplateConfigEntity> configs = configService.listSceneTemplateConfigs(scenicId, sceneKey);
|
||||
|
||||
WechatSubscribeSceneTemplatesResp resp = new WechatSubscribeSceneTemplatesResp();
|
||||
resp.setScenicId(scenicId);
|
||||
resp.setSceneKey(sceneKey);
|
||||
if (memberId == null) {
|
||||
return ApiResponse.success(resp);
|
||||
}
|
||||
|
||||
List<WechatSubscribeSceneTemplatesResp.TemplateInfo> templates = new ArrayList<>();
|
||||
for (WechatSubscribeTemplateConfigEntity cfg : configs) {
|
||||
if (cfg == null || StringUtils.isBlank(cfg.getWechatTemplateId())) {
|
||||
continue;
|
||||
}
|
||||
String title = StringUtils.isNotBlank(cfg.getTitleTemplate())
|
||||
? cfg.getTitleTemplate()
|
||||
: cfg.getTemplateKey();
|
||||
int remaining = notificationAuthUtils.getRemainingCount(memberId, cfg.getWechatTemplateId(), scenicId);
|
||||
|
||||
WechatSubscribeSceneTemplatesResp.TemplateInfo info = new WechatSubscribeSceneTemplatesResp.TemplateInfo();
|
||||
info.setTemplateKey(cfg.getTemplateKey());
|
||||
info.setWechatTemplateId(cfg.getWechatTemplateId());
|
||||
info.setTitle(title);
|
||||
info.setDescription(cfg.getDescription());
|
||||
info.setRemainingCount(remaining);
|
||||
info.setHasAuthorization(remaining > 0);
|
||||
templates.add(info);
|
||||
}
|
||||
resp.setTemplates(templates);
|
||||
|
||||
log.debug("场景模板查询: scenicId={}, sceneKey={}, memberId={}, templateCount={}",
|
||||
scenicId, sceneKey, memberId, Objects.requireNonNullElse(templates.size(), 0));
|
||||
return ApiResponse.success(resp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取景区下所有场景及其模板列表(静态配置,带缓存)
|
||||
* 不含用户授权信息,用户授权信息通过 /api/mobile/notify/auth/batch-remaining 接口获取
|
||||
*/
|
||||
@GetMapping("/scenic/{scenicId}/scenes")
|
||||
@IgnoreToken
|
||||
public ApiResponse<WechatSubscribeAllScenesResp> listAllSceneTemplates(@PathVariable("scenicId") Long scenicId) {
|
||||
if (scenicId == null) {
|
||||
return ApiResponse.fail("scenicId不能为空");
|
||||
}
|
||||
|
||||
WechatSubscribeAllScenesResp resp = configService.getAllScenesWithTemplatesCached(scenicId);
|
||||
log.debug("所有场景模板查询: scenicId={}, sceneCount={}",
|
||||
scenicId, resp.getScenes() != null ? resp.getScenes().size() : 0);
|
||||
return ApiResponse.success(resp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.ycwl.basic.controller.pc;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.biz.PriceBiz;
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.coupon.req.CouponQueryReq;
|
||||
import com.ycwl.basic.model.pc.coupon.resp.CouponRespVO;
|
||||
import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
|
||||
import com.ycwl.basic.service.pc.CouponService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/coupon/v1")
|
||||
// 优惠券管理
|
||||
public class CouponController {
|
||||
@Autowired
|
||||
private CouponService couponService;
|
||||
@Autowired
|
||||
private PriceBiz priceBiz;
|
||||
|
||||
@GetMapping("/{scenicId}/goodsList")
|
||||
public ApiResponse<List<GoodsListRespVO>> scenicGoodsList(@PathVariable Long scenicId) {
|
||||
List<GoodsListRespVO> data = priceBiz.listGoodsByScenic(scenicId);
|
||||
data.add(new GoodsListRespVO(-1L, "一口价", -1));
|
||||
return ApiResponse.success(data);
|
||||
}
|
||||
|
||||
// 新增优惠券
|
||||
@PostMapping("/add")
|
||||
public ApiResponse<Integer> add(@RequestBody CouponEntity coupon) {
|
||||
return ApiResponse.success(couponService.add(coupon));
|
||||
}
|
||||
|
||||
// 更新优惠券
|
||||
@PostMapping("/update/{id}")
|
||||
public ApiResponse<Boolean> update(@PathVariable Integer id, @RequestBody CouponEntity coupon) {
|
||||
coupon.setId(id);
|
||||
return ApiResponse.success(couponService.update(coupon));
|
||||
}
|
||||
|
||||
@PutMapping("/updateStatus/{id}")
|
||||
public ApiResponse<Boolean> updateStatus(@PathVariable Integer id) {
|
||||
return ApiResponse.success(couponService.updateStatus(id));
|
||||
}
|
||||
|
||||
// 删除优惠券
|
||||
@DeleteMapping("/delete/{id}")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Integer id) {
|
||||
return ApiResponse.success(couponService.delete(id));
|
||||
}
|
||||
|
||||
// 根据ID查询优惠券
|
||||
@GetMapping("/get/{id}")
|
||||
public ApiResponse<CouponEntity> getById(@PathVariable Integer id) {
|
||||
return ApiResponse.success(couponService.getById(id));
|
||||
}
|
||||
|
||||
// 分页查询优惠券列表
|
||||
@PostMapping("/page")
|
||||
public ApiResponse<PageInfo<CouponRespVO>> list(@RequestBody CouponQueryReq couponQuery) {
|
||||
PageHelper.startPage(couponQuery.getPageNum(), couponQuery.getPageSize());
|
||||
List<CouponRespVO> list = couponService.list(couponQuery);
|
||||
PageInfo<CouponRespVO> pageInfo = new PageInfo<>(list);
|
||||
return ApiResponse.success(pageInfo);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.ycwl.basic.controller.pc;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.model.pc.couponRecord.req.CouponRecordPageQueryReq;
|
||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordPageResp;
|
||||
import com.ycwl.basic.service.pc.CouponRecordService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/coupon/record/v1")
|
||||
public class CouponRecordController {
|
||||
|
||||
@Autowired
|
||||
private CouponRecordService couponRecordService;
|
||||
|
||||
@PostMapping("/page")
|
||||
public ApiResponse<PageInfo<CouponRecordPageResp>> pageQuery(@RequestBody CouponRecordPageQueryReq query) {
|
||||
return couponRecordService.pageQuery(query);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||
import com.ycwl.basic.model.pc.device.req.VideoContinuityReportReq;
|
||||
import com.ycwl.basic.repository.DeviceRepository;
|
||||
import com.ycwl.basic.task.DeviceVideoContinuityCheckTask;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -37,7 +36,6 @@ public class DeviceVideoContinuityController {
|
||||
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final DeviceVideoContinuityCheckTask checkTask;
|
||||
private final DeviceRepository deviceRepository;
|
||||
|
||||
/**
|
||||
@@ -78,15 +76,7 @@ public class DeviceVideoContinuityController {
|
||||
@PostMapping("/{deviceId}/check")
|
||||
public ApiResponse<DeviceVideoContinuityCache> manualCheck(@PathVariable Long deviceId) {
|
||||
log.info("手动触发设备 {} 的视频连续性检查", deviceId);
|
||||
|
||||
try {
|
||||
DeviceVideoContinuityCache result = checkTask.manualCheck(deviceId);
|
||||
return ApiResponse.success(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("手动检查设备 {} 视频连续性失败", deviceId, e);
|
||||
return ApiResponse.buildResponse(500, null, "检查失败: " + e.getMessage());
|
||||
}
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,7 +64,9 @@ public class SourceController {
|
||||
Map<String, Object> result = printerService.createVirtualOrder(
|
||||
request.getSourceId(),
|
||||
request.getScenicId(),
|
||||
request.getPrinterId()
|
||||
request.getPrinterId(),
|
||||
request.getNeedEnhance(),
|
||||
request.getPrintImgUrl()
|
||||
);
|
||||
return ApiResponse.success(result);
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.ycwl.basic.controller.pc;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplatePageReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplateSaveReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplatePageReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplateSaveReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSendLogPageReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigPageReq;
|
||||
import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigSaveReq;
|
||||
import com.ycwl.basic.service.pc.WechatSubscribeNotifyAdminService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 微信小程序订阅消息:配置管理(管理后台)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/wechatSubscribeNotify/v1")
|
||||
@RequiredArgsConstructor
|
||||
public class WechatSubscribeNotifyAdminController {
|
||||
|
||||
private final WechatSubscribeNotifyAdminService adminService;
|
||||
|
||||
// ========================= 模板配置 =========================
|
||||
|
||||
@PostMapping("/templateConfig/page")
|
||||
public ApiResponse<PageInfo<WechatSubscribeTemplateConfigEntity>> pageTemplateConfig(
|
||||
@RequestBody WechatSubscribeTemplateConfigPageReq req) {
|
||||
return adminService.pageTemplateConfig(req);
|
||||
}
|
||||
|
||||
@GetMapping("/templateConfig/detail/{id}")
|
||||
public ApiResponse<WechatSubscribeTemplateConfigEntity> getTemplateConfig(@PathVariable("id") Long id) {
|
||||
return adminService.getTemplateConfig(id);
|
||||
}
|
||||
|
||||
@PostMapping("/templateConfig/save")
|
||||
public ApiResponse<Boolean> saveTemplateConfig(@RequestBody WechatSubscribeTemplateConfigSaveReq req) {
|
||||
return adminService.saveTemplateConfig(req);
|
||||
}
|
||||
|
||||
@DeleteMapping("/templateConfig/delete/{id}")
|
||||
public ApiResponse<Boolean> deleteTemplateConfig(@PathVariable("id") Long id) {
|
||||
return adminService.deleteTemplateConfig(id);
|
||||
}
|
||||
|
||||
// ========================= 场景映射 =========================
|
||||
|
||||
@PostMapping("/sceneTemplate/page")
|
||||
public ApiResponse<PageInfo<WechatSubscribeSceneTemplateEntity>> pageSceneTemplate(
|
||||
@RequestBody WechatSubscribeSceneTemplatePageReq req) {
|
||||
return adminService.pageSceneTemplate(req);
|
||||
}
|
||||
|
||||
@GetMapping("/sceneTemplate/detail/{id}")
|
||||
public ApiResponse<WechatSubscribeSceneTemplateEntity> getSceneTemplate(@PathVariable("id") Long id) {
|
||||
return adminService.getSceneTemplate(id);
|
||||
}
|
||||
|
||||
@PostMapping("/sceneTemplate/save")
|
||||
public ApiResponse<Boolean> saveSceneTemplate(@RequestBody WechatSubscribeSceneTemplateSaveReq req) {
|
||||
return adminService.saveSceneTemplate(req);
|
||||
}
|
||||
|
||||
@DeleteMapping("/sceneTemplate/delete/{id}")
|
||||
public ApiResponse<Boolean> deleteSceneTemplate(@PathVariable("id") Long id) {
|
||||
return adminService.deleteSceneTemplate(id);
|
||||
}
|
||||
|
||||
// ========================= 事件映射 =========================
|
||||
|
||||
@PostMapping("/eventTemplate/page")
|
||||
public ApiResponse<PageInfo<WechatSubscribeEventTemplateEntity>> pageEventTemplate(
|
||||
@RequestBody WechatSubscribeEventTemplatePageReq req) {
|
||||
return adminService.pageEventTemplate(req);
|
||||
}
|
||||
|
||||
@GetMapping("/eventTemplate/detail/{id}")
|
||||
public ApiResponse<WechatSubscribeEventTemplateEntity> getEventTemplate(@PathVariable("id") Long id) {
|
||||
return adminService.getEventTemplate(id);
|
||||
}
|
||||
|
||||
@PostMapping("/eventTemplate/save")
|
||||
public ApiResponse<Boolean> saveEventTemplate(@RequestBody WechatSubscribeEventTemplateSaveReq req) {
|
||||
return adminService.saveEventTemplate(req);
|
||||
}
|
||||
|
||||
@DeleteMapping("/eventTemplate/delete/{id}")
|
||||
public ApiResponse<Boolean> deleteEventTemplate(@PathVariable("id") Long id) {
|
||||
return adminService.deleteEventTemplate(id);
|
||||
}
|
||||
|
||||
// ========================= 发送日志 =========================
|
||||
|
||||
@PostMapping("/sendLog/page")
|
||||
public ApiResponse<PageInfo<WechatSubscribeSendLogEntity>> pageSendLog(@RequestBody WechatSubscribeSendLogPageReq req) {
|
||||
return adminService.pageSendLog(req);
|
||||
}
|
||||
|
||||
@GetMapping("/sendLog/detail/{id}")
|
||||
public ApiResponse<WechatSubscribeSendLogEntity> getSendLog(@PathVariable("id") Long id) {
|
||||
return adminService.getSendLog(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,16 @@ package com.ycwl.basic.controller.printer;
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
|
||||
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
|
||||
import com.ycwl.basic.mapper.SourceMapper;
|
||||
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
||||
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
|
||||
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
|
||||
import com.ycwl.basic.repository.DeviceRepository;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.repository.SourceRepository;
|
||||
import com.ycwl.basic.service.pc.FaceService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.utils.WxMpUtil;
|
||||
@@ -32,7 +30,6 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@IgnoreToken
|
||||
@@ -46,8 +43,7 @@ public class PrinterTvController {
|
||||
private final ScenicRepository scenicRepository;
|
||||
private final FaceRepository faceRepository;
|
||||
private final FaceService pcFaceService;
|
||||
private final MemberRelationRepository memberRelationRepository;
|
||||
private final SourceRepository sourceRepository;
|
||||
private final SourceMapper sourceMapper;
|
||||
|
||||
/**
|
||||
* 获取景区列表
|
||||
@@ -124,61 +120,21 @@ public class PrinterTvController {
|
||||
*/
|
||||
@GetMapping("/face/{faceId}/qrcode")
|
||||
public void getFaceQrcode(@PathVariable("faceId") Long faceId, HttpServletResponse response) throws Exception {
|
||||
File qrcode = new File("qrcode_face_" + faceId + ".jpg");
|
||||
try {
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
response.setStatus(404);
|
||||
return;
|
||||
}
|
||||
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(face.getScenicId());
|
||||
if (scenicMpConfig == null) {
|
||||
response.setStatus(500);
|
||||
return;
|
||||
}
|
||||
WxMpUtil.generateUnlimitedWXAQRCode(
|
||||
scenicMpConfig.getAppId(),
|
||||
scenicMpConfig.getAppSecret(),
|
||||
"pages/videoSynthesis/bind_face",
|
||||
faceId.toString(),
|
||||
qrcode
|
||||
);
|
||||
|
||||
// 设置响应头
|
||||
response.setContentType("image/jpeg");
|
||||
response.setHeader("Content-Disposition", "inline; filename=\"" + qrcode.getName() + "\"");
|
||||
|
||||
// 将二维码文件写入响应输出流
|
||||
try (FileInputStream fis = new FileInputStream(qrcode);
|
||||
OutputStream os = response.getOutputStream()) {
|
||||
byte[] buffer = new byte[1024];
|
||||
int bytesRead;
|
||||
while ((bytesRead = fis.read(buffer)) != -1) {
|
||||
os.write(buffer, 0, bytesRead);
|
||||
}
|
||||
os.flush();
|
||||
}
|
||||
} finally {
|
||||
// 删除临时文件
|
||||
if (qrcode.exists()) {
|
||||
qrcode.delete();
|
||||
}
|
||||
}
|
||||
String url = pcFaceService.bindWxaCode(faceId);
|
||||
response.sendRedirect(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据人脸样本ID查询图像素材
|
||||
* 根据人脸ID查询图像素材
|
||||
*
|
||||
* @param faceId 人脸样本ID
|
||||
* @param faceId 人脸ID
|
||||
* @param type 素材类型(默认为2-图片)
|
||||
* @return 匹配的source记录
|
||||
*/
|
||||
@GetMapping("/{faceId}/source")
|
||||
public ApiResponse<List<SourceEntity>> getSourceByFaceId(@PathVariable Long faceId, @RequestParam(name = "type", required = false, defaultValue = "2") Integer type) {
|
||||
List<MemberSourceEntity> source = memberRelationRepository.listSourceByFaceRelation(faceId, type);
|
||||
if (source == null) {
|
||||
return ApiResponse.success(Collections.emptyList());
|
||||
}
|
||||
return ApiResponse.success(source.stream().map(item -> sourceRepository.getSource(item.getSourceId())).toList());
|
||||
List<SourceEntity> sources = sourceMapper.listSourceByFaceRelation(faceId, type);
|
||||
return ApiResponse.success(sources);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,5 +159,36 @@ public class PrinterTvController {
|
||||
return ApiResponse.success(resp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过人脸样本ID重定向到人脸图片URL
|
||||
*
|
||||
* @param faceSampleId 人脸样本ID
|
||||
* @param response HTTP响应
|
||||
*/
|
||||
@GetMapping("/faceSample/{faceSampleId}/url")
|
||||
public void redirectToFaceSampleUrl(@PathVariable Long faceSampleId, HttpServletResponse response) throws Exception {
|
||||
FaceSampleEntity faceSample = faceRepository.getFaceSample(faceSampleId);
|
||||
if (faceSample == null || faceSample.getFaceUrl() == null) {
|
||||
response.setStatus(404);
|
||||
return;
|
||||
}
|
||||
response.sendRedirect(faceSample.getFaceUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过人脸ID重定向到人脸图片URL
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @param response HTTP响应
|
||||
*/
|
||||
@GetMapping("/face/{faceId}/url")
|
||||
public void redirectToFaceUrl(@PathVariable Long faceId, HttpServletResponse response) throws Exception {
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null || face.getFaceUrl() == null) {
|
||||
response.setStatus(404);
|
||||
return;
|
||||
}
|
||||
response.sendRedirect(face.getFaceUrl());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ public class PuzzleGenerationOrchestrator {
|
||||
|
||||
} catch (Exception e) {
|
||||
// 异步任务失败不影响主流程,仅记录日志
|
||||
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
|
||||
log.error("异步生成拼图模板失败: scenicId={}, faceId={}, e={}", scenicId, faceId, e.getMessage());
|
||||
}
|
||||
}, "PuzzleTemplateGenerator-" + scenicId + "-" + faceId).start();
|
||||
}
|
||||
@@ -142,8 +142,8 @@ public class PuzzleGenerationOrchestrator {
|
||||
generateRequest.setFaceId(faceId);
|
||||
generateRequest.setBusinessType("face_matching");
|
||||
generateRequest.setTemplateCode(template.getCode());
|
||||
generateRequest.setOutputFormat("PNG");
|
||||
generateRequest.setQuality(90);
|
||||
generateRequest.setOutputFormat("JPEG");
|
||||
generateRequest.setQuality(80);
|
||||
generateRequest.setDynamicData(new HashMap<>(baseDynamicData));
|
||||
generateRequest.setRequireRuleMatch(true);
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ public class DeleteOldRelationsStage extends AbstractPipelineStage<FaceMatchingC
|
||||
@Autowired
|
||||
private MemberRelationRepository memberRelationRepository;
|
||||
|
||||
@Autowired
|
||||
private com.ycwl.basic.biz.FaceStatusManager faceStatusManager;
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "DeleteOldRelations";
|
||||
@@ -60,6 +63,7 @@ public class DeleteOldRelationsStage extends AbstractPipelineStage<FaceMatchingC
|
||||
|
||||
// 3. 清除缓存
|
||||
memberRelationRepository.clearSCacheByFace(faceId);
|
||||
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
|
||||
|
||||
log.debug("人脸旧关系数据删除完成:faceId={}", faceId);
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ public class PersistRelationsStage extends AbstractPipelineStage<FaceMatchingCon
|
||||
@Autowired
|
||||
private MemberRelationRepository memberRelationRepository;
|
||||
|
||||
@Autowired
|
||||
private com.ycwl.basic.biz.FaceStatusManager faceStatusManager;
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "PersistRelations";
|
||||
@@ -87,6 +90,7 @@ public class PersistRelationsStage extends AbstractPipelineStage<FaceMatchingCon
|
||||
|
||||
// 4. 清除缓存
|
||||
memberRelationRepository.clearSCacheByFace(faceId);
|
||||
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
|
||||
|
||||
return StageResult.success(String.format("持久化了%d条关联关系", validFiltered.size()));
|
||||
|
||||
|
||||
@@ -162,13 +162,12 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
|
||||
return resp;
|
||||
} else if (errorCode == 222204) {
|
||||
// error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试
|
||||
log.warn("无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
|
||||
// log.warn("无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
|
||||
String base64Image = downloadImageAsBase64(faceUrl);
|
||||
if (base64Image != null) {
|
||||
// 重试时也不需要限流,由外层调度器控制
|
||||
JSONObject retryResponse = client.addUser(base64Image, "BASE64", dbName, entityId, options);
|
||||
if (retryResponse.getInt("error_code") == 0) {
|
||||
log.info("使用base64重试添加人脸成功,entityId: {}", entityId);
|
||||
AddFaceResp resp = new AddFaceResp();
|
||||
resp.setScore(100f);
|
||||
return resp;
|
||||
@@ -338,7 +337,7 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
|
||||
return resp;
|
||||
} else if (errorCode == 222204) {
|
||||
// error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试
|
||||
log.warn("搜索人脸时无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
|
||||
// log.warn("搜索人脸时无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
|
||||
String base64Image = downloadImageAsBase64(faceUrl);
|
||||
if (base64Image != null) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.ycwl.basic.image.pipeline.stages;
|
||||
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeService;
|
||||
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
|
||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -41,4 +43,28 @@ public class WatermarkConfig {
|
||||
*/
|
||||
@Builder.Default
|
||||
private final Double scale = 1.0;
|
||||
|
||||
/**
|
||||
* 边缘端水印服务(可选)
|
||||
* 如果设置,将优先尝试使用边缘端处理
|
||||
*/
|
||||
private final WatermarkEdgeService edgeService;
|
||||
|
||||
/**
|
||||
* 存储适配器(边缘端处理时需要)
|
||||
* 用于上传原图和二维码到临时位置
|
||||
*/
|
||||
private final IStorageAdapter storageAdapter;
|
||||
|
||||
/**
|
||||
* 是否启用边缘端处理
|
||||
*/
|
||||
@Builder.Default
|
||||
private final boolean edgeEnabled = true;
|
||||
|
||||
/**
|
||||
* 边缘端处理超时时间(毫秒)
|
||||
*/
|
||||
@Builder.Default
|
||||
private final long edgeTimeoutMs = 10_000L;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ycwl.basic.image.pipeline.stages;
|
||||
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
||||
import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeService;
|
||||
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
|
||||
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
|
||||
import com.ycwl.basic.image.watermark.operator.IOperator;
|
||||
@@ -10,6 +11,7 @@ import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||
import com.ycwl.basic.pipeline.core.StageResult;
|
||||
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
@@ -21,6 +23,7 @@ import java.util.List;
|
||||
/**
|
||||
* 水印处理Stage
|
||||
* 支持三级降级: 配置的水印类型 -> PRINTER_DEFAULT -> 无水印
|
||||
* 支持边缘端渲染(可选)
|
||||
*/
|
||||
@Slf4j
|
||||
@StageConfig(
|
||||
@@ -127,6 +130,19 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
File watermarkedFile = context.getTempFileManager()
|
||||
.createTempFile("watermark_" + type.getType(), "." + fileExt);
|
||||
|
||||
// 尝试边缘端处理
|
||||
if (shouldUseEdgeProcessing(type)) {
|
||||
File edgeResult = tryEdgeProcessing(context, type, currentFile, watermarkedFile);
|
||||
if (edgeResult != null && edgeResult.exists()) {
|
||||
context.updateProcessedFile(edgeResult);
|
||||
log.info("边缘端水印应用成功: type={}, size={}KB", type.getType(), edgeResult.length() / 1024);
|
||||
return StageResult.success(String.format("水印(边缘端): %s (%dKB)",
|
||||
type.getType(), edgeResult.length() / 1024));
|
||||
}
|
||||
log.warn("边缘端水印处理失败,降级到本地处理: type={}", type.getType());
|
||||
}
|
||||
|
||||
// 本地处理(降级或直接使用)
|
||||
WatermarkInfo watermarkInfo = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
|
||||
|
||||
IOperator operator = ImageWatermarkFactory.get(type);
|
||||
@@ -143,6 +159,46 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||
type.getType(), result.length() / 1024));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应使用边缘端处理
|
||||
*/
|
||||
private boolean shouldUseEdgeProcessing(ImageWatermarkOperatorEnum type) {
|
||||
if (!config.isEdgeEnabled()) {
|
||||
return false;
|
||||
}
|
||||
WatermarkEdgeService edgeService = config.getEdgeService();
|
||||
if (edgeService == null) {
|
||||
return false;
|
||||
}
|
||||
IStorageAdapter storageAdapter = config.getStorageAdapter();
|
||||
return storageAdapter != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试使用边缘端处理
|
||||
*
|
||||
* @return 处理后的文件,失败返回 null
|
||||
*/
|
||||
private File tryEdgeProcessing(PhotoProcessContext context,
|
||||
ImageWatermarkOperatorEnum type,
|
||||
File currentFile,
|
||||
File watermarkedFile) {
|
||||
try {
|
||||
WatermarkEdgeService edgeService = config.getEdgeService();
|
||||
IStorageAdapter storageAdapter = config.getStorageAdapter();
|
||||
|
||||
// 构建水印信息用于边缘端处理
|
||||
WatermarkInfo info = buildWatermarkInfo(context, currentFile, watermarkedFile, type);
|
||||
|
||||
// 调用边缘端服务处理,传递 processId 作为 recordId
|
||||
return edgeService.processWatermarkFromFile(info, type, storageAdapter, context.getProcessId());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("边缘端水印处理异常: type={}, error={}", type.getType(), e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建水印参数
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.ycwl.basic.image.watermark;
|
||||
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
|
||||
import com.ycwl.basic.image.watermark.exception.ImageWatermarkUnsupportedException;
|
||||
import com.ycwl.basic.image.watermark.operator.IOperator;
|
||||
import com.ycwl.basic.image.watermark.operator.DefaultImageWatermarkOperator;
|
||||
import com.ycwl.basic.image.watermark.operator.LeicaWatermarkOperator;
|
||||
import com.ycwl.basic.image.watermark.operator.NormalWatermarkOperator;
|
||||
import com.ycwl.basic.image.watermark.operator.PrinterDefaultWatermarkOperator;
|
||||
@@ -18,11 +17,11 @@ public class ImageWatermarkFactory {
|
||||
}
|
||||
public static IOperator get(ImageWatermarkOperatorEnum type) {
|
||||
return switch (type) {
|
||||
case WATERMARK -> new DefaultImageWatermarkOperator();
|
||||
case NORMAL -> new NormalWatermarkOperator();
|
||||
case LEICA -> new LeicaWatermarkOperator();
|
||||
case PRINTER_DEFAULT -> new PrinterDefaultWatermarkOperator();
|
||||
default -> throw new ImageWatermarkUnsupportedException("不支持的类型" + type.name());
|
||||
case PUZZLE_PRINT -> throw new ImageWatermarkUnsupportedException(
|
||||
"PUZZLE_PRINT 仅支持边缘端处理,请使用 WatermarkEdgeService");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 水印模板构建器基类
|
||||
* 提供构建元素的工具方法
|
||||
*/
|
||||
public abstract class AbstractWatermarkTemplateBuilder implements IWatermarkTemplateBuilder {
|
||||
|
||||
// 虚拟模板ID(运行时使用,不存储)
|
||||
private static final AtomicLong VIRTUAL_TEMPLATE_ID = new AtomicLong(-1);
|
||||
private static final AtomicLong VIRTUAL_ELEMENT_ID = new AtomicLong(-1);
|
||||
|
||||
/**
|
||||
* 元素类型常量
|
||||
*/
|
||||
protected static final String ELEMENT_TYPE_IMAGE = "IMAGE";
|
||||
protected static final String ELEMENT_TYPE_TEXT = "TEXT";
|
||||
|
||||
/**
|
||||
* 图片适配模式
|
||||
*/
|
||||
protected static final String FIT_MODE_COVER = "COVER";
|
||||
protected static final String FIT_MODE_CONTAIN = "CONTAIN";
|
||||
|
||||
/**
|
||||
* 文本对齐方式
|
||||
*/
|
||||
protected static final String TEXT_ALIGN_LEFT = "LEFT";
|
||||
protected static final String TEXT_ALIGN_RIGHT = "RIGHT";
|
||||
protected static final String TEXT_ALIGN_CENTER = "CENTER";
|
||||
|
||||
/**
|
||||
* 创建虚拟模板
|
||||
*/
|
||||
protected PuzzleTemplateEntity createTemplate(String code, int width, int height, String backgroundImage) {
|
||||
PuzzleTemplateEntity template = new PuzzleTemplateEntity();
|
||||
template.setId(VIRTUAL_TEMPLATE_ID.decrementAndGet());
|
||||
template.setCode(code);
|
||||
template.setName("水印模板-" + getStyle());
|
||||
template.setCanvasWidth(width);
|
||||
template.setCanvasHeight(height);
|
||||
template.setBackgroundType(1); // 图片背景
|
||||
template.setBackgroundImage(backgroundImage);
|
||||
template.setStatus(1);
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建纯色背景模板
|
||||
*/
|
||||
protected PuzzleTemplateEntity createTemplateWithColor(String code, int width, int height, String backgroundColor) {
|
||||
PuzzleTemplateEntity template = new PuzzleTemplateEntity();
|
||||
template.setId(VIRTUAL_TEMPLATE_ID.decrementAndGet());
|
||||
template.setCode(code);
|
||||
template.setName("水印模板-" + getStyle());
|
||||
template.setCanvasWidth(width);
|
||||
template.setCanvasHeight(height);
|
||||
template.setBackgroundType(0); // 纯色背景
|
||||
template.setBackgroundColor(backgroundColor);
|
||||
template.setStatus(1);
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建图片元素
|
||||
*/
|
||||
protected PuzzleElementEntity createImageElement(String key, String name, int x, int y, int width, int height, int zIndex,
|
||||
String fitMode, Integer borderRadius, Integer opacity) {
|
||||
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||
element.setId(VIRTUAL_ELEMENT_ID.decrementAndGet());
|
||||
element.setElementType(ELEMENT_TYPE_IMAGE);
|
||||
element.setElementKey(key);
|
||||
element.setElementName(name);
|
||||
element.setXPosition(x);
|
||||
element.setYPosition(y);
|
||||
element.setWidth(width);
|
||||
element.setHeight(height);
|
||||
element.setZIndex(zIndex);
|
||||
element.setOpacity(opacity != null ? opacity : 100);
|
||||
|
||||
// 构建配置JSON
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("imageFitMode", fitMode != null ? fitMode : FIT_MODE_COVER);
|
||||
if (borderRadius != null && borderRadius > 0) {
|
||||
config.put("borderRadius", borderRadius);
|
||||
}
|
||||
element.setConfig(JacksonUtil.toJson(config));
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建圆形图片元素
|
||||
*/
|
||||
protected PuzzleElementEntity createCircleImageElement(String key, String name, int x, int y, int diameter, int zIndex) {
|
||||
// 圆形 = borderRadius 为直径的一半
|
||||
return createImageElement(key, name, x, y, diameter, diameter, zIndex, FIT_MODE_COVER, diameter / 2, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文字元素
|
||||
*/
|
||||
protected PuzzleElementEntity createTextElement(String key, String name, int x, int y, int width, int height, int zIndex,
|
||||
String fontFamily, int fontSize, String fontColor,
|
||||
String fontWeight, String textAlign) {
|
||||
PuzzleElementEntity element = new PuzzleElementEntity();
|
||||
element.setId(VIRTUAL_ELEMENT_ID.decrementAndGet());
|
||||
element.setElementType(ELEMENT_TYPE_TEXT);
|
||||
element.setElementKey(key);
|
||||
element.setElementName(name);
|
||||
element.setXPosition(x);
|
||||
element.setYPosition(y);
|
||||
element.setWidth(width);
|
||||
element.setHeight(height);
|
||||
element.setZIndex(zIndex);
|
||||
element.setOpacity(100);
|
||||
|
||||
// 构建配置JSON
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("fontFamily", fontFamily != null ? fontFamily : "PingFang SC");
|
||||
config.put("fontSize", fontSize);
|
||||
config.put("fontColor", fontColor != null ? fontColor : "#FFFFFF");
|
||||
config.put("fontWeight", fontWeight != null ? fontWeight : "NORMAL");
|
||||
config.put("textAlign", textAlign != null ? textAlign : TEXT_ALIGN_LEFT);
|
||||
element.setConfig(JacksonUtil.toJson(config));
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建构建结果
|
||||
*/
|
||||
protected WatermarkTemplateResult createResult(PuzzleTemplateEntity template,
|
||||
List<PuzzleElementEntity> elements,
|
||||
Map<String, String> dynamicData) {
|
||||
WatermarkTemplateResult result = new WatermarkTemplateResult();
|
||||
result.setTemplate(template);
|
||||
result.setElements(elements);
|
||||
result.setDynamicData(dynamicData);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空的元素列表和动态数据
|
||||
*/
|
||||
protected List<PuzzleElementEntity> newElementList() {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
protected Map<String, String> newDynamicData() {
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
/**
|
||||
* 水印模板构建器接口
|
||||
* 将水印参数转换为拼图模板+元素的形式,用于发送给边缘渲染任务
|
||||
*/
|
||||
public interface IWatermarkTemplateBuilder {
|
||||
|
||||
/**
|
||||
* 构建水印模板
|
||||
*
|
||||
* @param request 水印请求参数
|
||||
* @return 模板构建结果
|
||||
*/
|
||||
WatermarkTemplateResult build(WatermarkRequest request);
|
||||
|
||||
/**
|
||||
* 获取水印风格标识
|
||||
*/
|
||||
String getStyle();
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 徕卡风格水印模板构建器
|
||||
* 对应 LeicaWatermarkOperator
|
||||
*
|
||||
* 布局说明(百分比基于1920x1080量化,精度0.5%):
|
||||
* - 画布大小 = 原图大小(不扩展)
|
||||
* - 原图收缩放在画布上半部分,底部留出空间
|
||||
* - 底部白色区域左侧:帧途 Logo + "帧途" 文字
|
||||
* - 底部白色区域右侧:二维码(含头像)+ 景区名 + 日期时间
|
||||
*/
|
||||
@Component
|
||||
public class LeicaWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
|
||||
|
||||
public static final String STYLE = "leica";
|
||||
|
||||
// 百分比常量配置(基于1920x1080量化,精度0.5%)
|
||||
/** 底部额外区域占高度百分比 */
|
||||
private static final double EXTRA_BOTTOM_PERCENT = 0.13; // 13%
|
||||
/** Logo大小占高度百分比 */
|
||||
private static final double LOGO_SIZE_PERCENT = 0.045; // 4.5%
|
||||
/** Logo额外边距占高度百分比 */
|
||||
private static final double LOGO_EXTRA_BORDER_PERCENT = 0.02; // 2%
|
||||
/** Logo字体大小占高度百分比 */
|
||||
private static final double LOGO_FONT_SIZE_PERCENT = 0.035; // 3.5%
|
||||
/** 二维码大小占高度百分比 */
|
||||
private static final double QRCODE_SIZE_PERCENT = 0.11; // 11%
|
||||
/** 二维码X偏移占宽度百分比 */
|
||||
private static final double QRCODE_OFFSET_X_PERCENT = 0.005; // 0.5%
|
||||
/** 二维码Y偏移占高度百分比 */
|
||||
private static final double QRCODE_OFFSET_Y_PERCENT = 0.02; // 2%
|
||||
/** 左右边距占宽度百分比 */
|
||||
private static final double OFFSET_X_PERCENT = 0.04; // 4%
|
||||
/** 上下边距占高度百分比 */
|
||||
private static final double OFFSET_Y_PERCENT = 0.03; // 3%
|
||||
/** 景区名字体大小占高度百分比 */
|
||||
private static final double SCENIC_FONT_SIZE_PERCENT = 0.03; // 3%
|
||||
/** 日期时间字体大小占高度百分比 */
|
||||
private static final double DATETIME_FONT_SIZE_PERCENT = 0.025; // 2.5%
|
||||
|
||||
private static final String LOGO_TEXT_COLOR = "#333333";
|
||||
private static final String SCENIC_COLOR = "#333333";
|
||||
private static final String DATETIME_COLOR = "#999999";
|
||||
|
||||
/**
|
||||
* Logo 图片 URL(需要预先上传到 OSS)
|
||||
*/
|
||||
private static final String LOGO_URL = "https://oss.zhentuai.com/zt/zt-logo.png";
|
||||
|
||||
@Override
|
||||
public String getStyle() {
|
||||
return STYLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WatermarkTemplateResult build(WatermarkRequest request) {
|
||||
int imageWidth = request.getImageWidth();
|
||||
int imageHeight = request.getImageHeight();
|
||||
|
||||
// 根据百分比计算实际像素值
|
||||
int extraBottom = (int) (imageHeight * EXTRA_BOTTOM_PERCENT);
|
||||
int logoSize = (int) (imageHeight * LOGO_SIZE_PERCENT);
|
||||
int logoExtraBorder = (int) (imageHeight * LOGO_EXTRA_BORDER_PERCENT);
|
||||
int logoFontSize = (int) (imageHeight * LOGO_FONT_SIZE_PERCENT);
|
||||
int qrcodeSize = (int) (imageHeight * QRCODE_SIZE_PERCENT);
|
||||
int qrcodeOffsetX = (int) (imageWidth * QRCODE_OFFSET_X_PERCENT);
|
||||
int qrcodeOffsetY = (int) (imageHeight * QRCODE_OFFSET_Y_PERCENT);
|
||||
int offsetX = (int) (imageWidth * OFFSET_X_PERCENT);
|
||||
int offsetY = (int) (imageHeight * OFFSET_Y_PERCENT);
|
||||
int scenicFontSize = (int) (imageHeight * SCENIC_FONT_SIZE_PERCENT);
|
||||
int datetimeFontSize = (int) (imageHeight * DATETIME_FONT_SIZE_PERCENT);
|
||||
|
||||
// 画布大小 = 原图大小(不扩展)
|
||||
int canvasWidth = imageWidth;
|
||||
int canvasHeight = imageHeight;
|
||||
|
||||
// 原图收缩后的区域高度
|
||||
int shrunkImageHeight = imageHeight - extraBottom;
|
||||
// 底部区域起始 Y 坐标
|
||||
int bottomAreaY = shrunkImageHeight;
|
||||
|
||||
// 创建模板(白色背景)
|
||||
PuzzleTemplateEntity template = createTemplateWithColor(
|
||||
"watermark_leica_" + System.currentTimeMillis(),
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
"#FFFFFF"
|
||||
);
|
||||
|
||||
List<PuzzleElementEntity> elements = newElementList();
|
||||
Map<String, String> dynamicData = newDynamicData();
|
||||
|
||||
// 1. 原图元素(收缩放在画布上半部分)
|
||||
PuzzleElementEntity originalImageElement = createImageElement(
|
||||
"originalImage", "原图",
|
||||
0, 0,
|
||||
imageWidth, shrunkImageHeight, 1,
|
||||
FIT_MODE_COVER, null, null
|
||||
);
|
||||
elements.add(originalImageElement);
|
||||
dynamicData.put("originalImage", request.getOriginalImageUrl());
|
||||
|
||||
// 2. Logo 元素(底部左侧)
|
||||
int logoY = bottomAreaY + offsetY + logoExtraBorder;
|
||||
PuzzleElementEntity logoElement = createImageElement(
|
||||
"logo", "Logo",
|
||||
offsetX, logoY - (int)(logoSize * 0.24),
|
||||
logoSize, logoSize, 10,
|
||||
FIT_MODE_CONTAIN, null, null
|
||||
);
|
||||
elements.add(logoElement);
|
||||
dynamicData.put("logo", LOGO_URL);
|
||||
|
||||
// 3. "帧途" 文字(Logo 右边)
|
||||
int logoTextX = offsetX + logoSize + (int)(imageWidth * 0.005);
|
||||
int logoTextY = bottomAreaY + offsetY + logoExtraBorder;
|
||||
PuzzleElementEntity logoTextElement = createTextElement(
|
||||
"logoText", "帧途文字",
|
||||
logoTextX, logoTextY,
|
||||
(int)(imageWidth * 0.05), logoSize, 10,
|
||||
"PingFang SC", logoFontSize, LOGO_TEXT_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_LEFT
|
||||
);
|
||||
elements.add(logoTextElement);
|
||||
dynamicData.put("logoText", "帧途");
|
||||
|
||||
// 4. 计算右侧区域位置
|
||||
// 估算文字宽度(使用景区名和日期的较大者)
|
||||
int estimatedTextWidth = Math.max(
|
||||
(request.getScenicLine() != null ? request.getScenicLine().length() : 0) * scenicFontSize / 2,
|
||||
(request.getDatetimeLine() != null ? request.getDatetimeLine().length() : 0) * datetimeFontSize / 2
|
||||
);
|
||||
|
||||
int qrcodeX = canvasWidth - offsetX - qrcodeSize - qrcodeOffsetX - estimatedTextWidth;
|
||||
int qrcodeY = bottomAreaY + offsetY - qrcodeOffsetY;
|
||||
|
||||
// 5. 二维码元素
|
||||
PuzzleElementEntity qrcodeElement = createImageElement(
|
||||
"qrcode", "二维码",
|
||||
qrcodeX, qrcodeY,
|
||||
qrcodeSize, qrcodeSize, 10,
|
||||
FIT_MODE_CONTAIN, null, null
|
||||
);
|
||||
elements.add(qrcodeElement);
|
||||
dynamicData.put("qrcode", request.getQrcodeUrl());
|
||||
|
||||
// 6. 头像元素(二维码中央,可选)
|
||||
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
|
||||
int avatarDiameter = (int) (qrcodeSize * 0.45);
|
||||
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
|
||||
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
|
||||
|
||||
PuzzleElementEntity faceElement = createCircleImageElement(
|
||||
"face", "头像",
|
||||
avatarX, avatarY,
|
||||
avatarDiameter, 20
|
||||
);
|
||||
elements.add(faceElement);
|
||||
dynamicData.put("face", request.getFaceUrl());
|
||||
}
|
||||
|
||||
// 7. 计算文字位置(与二维码垂直居中)
|
||||
int qrcodeCenter = qrcodeY + qrcodeSize / 2;
|
||||
int totalTextHeight = scenicFontSize + datetimeFontSize + (int)(imageHeight * 0.01);
|
||||
int textY = qrcodeCenter - totalTextHeight / 2;
|
||||
int textX = canvasWidth - offsetX - estimatedTextWidth;
|
||||
|
||||
// 8. 景区名文字
|
||||
PuzzleElementEntity scenicTextElement = createTextElement(
|
||||
"scenicLine", "景区名",
|
||||
textX, textY,
|
||||
estimatedTextWidth, scenicFontSize + (int)(imageHeight * 0.01), 30,
|
||||
"PingFang SC", scenicFontSize, SCENIC_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_LEFT
|
||||
);
|
||||
elements.add(scenicTextElement);
|
||||
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
|
||||
|
||||
// 9. 日期时间文字
|
||||
int datetimeY = textY + scenicFontSize + (int)(imageHeight * 0.005);
|
||||
PuzzleElementEntity datetimeTextElement = createTextElement(
|
||||
"datetimeLine", "日期时间",
|
||||
textX, datetimeY,
|
||||
estimatedTextWidth, datetimeFontSize + (int)(imageHeight * 0.01), 30,
|
||||
"PingFang SC", datetimeFontSize, DATETIME_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_LEFT
|
||||
);
|
||||
elements.add(datetimeTextElement);
|
||||
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
|
||||
|
||||
return createResult(template, elements, dynamicData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Normal 风格水印模板构建器
|
||||
* 对应 NormalWatermarkOperator
|
||||
*
|
||||
* 布局说明(百分比基于1920x1080量化,精度0.5%):
|
||||
* - 白色背景 + 原图元素(COVER模式)
|
||||
* - 左下角:圆形二维码(右边界在宽度45%位置)
|
||||
* - 二维码中央:圆形头像(可选)
|
||||
* - 二维码右侧:景区名 + 日期时间 两行文字(白色,左对齐)
|
||||
*/
|
||||
@Component
|
||||
public class NormalWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
|
||||
|
||||
public static final String STYLE = "normal";
|
||||
|
||||
// 百分比常量配置(基于1920x1080量化,精度0.5%)
|
||||
/** 底部距离占高度百分比 */
|
||||
private static final double BOTTOM_OFFSET_PERCENT = 0.085; // 8.5%
|
||||
/** 二维码大小占宽度百分比 */
|
||||
private static final double QRCODE_SIZE_PERCENT = 0.08; // 8%
|
||||
/** 二维码右边界占宽度百分比 */
|
||||
private static final double QRCODE_RIGHT_PERCENT = 0.45; // 45%
|
||||
/** 二维码Y方向偏移(向上)占高度百分比 */
|
||||
private static final double QRCODE_OFFSET_Y_PERCENT = 0.02; // 2%
|
||||
/** 文字区域起始X位置占宽度百分比 */
|
||||
private static final double TEXT_START_X_PERCENT = 0.455; // 45.5%
|
||||
/** 字体大小占高度百分比 */
|
||||
private static final double FONT_SIZE_PERCENT = 0.04; // 4%
|
||||
/** 文字行间距占高度百分比 */
|
||||
private static final double LINE_SPACING_PERCENT = 0.005; // 0.5%
|
||||
/** 文字区域右边距占宽度百分比 */
|
||||
private static final double TEXT_RIGHT_MARGIN_PERCENT = 0.01; // 1%
|
||||
|
||||
private static final String FONT_COLOR = "#FFFFFF";
|
||||
|
||||
@Override
|
||||
public String getStyle() {
|
||||
return STYLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WatermarkTemplateResult build(WatermarkRequest request) {
|
||||
int imageWidth = request.getImageWidth();
|
||||
int imageHeight = request.getImageHeight();
|
||||
|
||||
// 根据百分比计算实际像素值
|
||||
int bottomOffset = (int) (imageHeight * BOTTOM_OFFSET_PERCENT);
|
||||
int qrcodeSize = (int) (imageWidth * QRCODE_SIZE_PERCENT);
|
||||
int qrcodeRightX = (int) (imageWidth * QRCODE_RIGHT_PERCENT);
|
||||
int qrcodeOffsetY = (int) (imageHeight * QRCODE_OFFSET_Y_PERCENT);
|
||||
int textStartX = (int) (imageWidth * TEXT_START_X_PERCENT);
|
||||
int fontSize = (int) (imageHeight * FONT_SIZE_PERCENT);
|
||||
int lineSpacing = (int) (imageHeight * LINE_SPACING_PERCENT);
|
||||
int textRightMargin = (int) (imageWidth * TEXT_RIGHT_MARGIN_PERCENT);
|
||||
|
||||
// 创建模板(白色背景,原图作为元素实现 COVER 模式)
|
||||
PuzzleTemplateEntity template = createTemplateWithColor(
|
||||
"watermark_normal_" + System.currentTimeMillis(),
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
"#FFFFFF"
|
||||
);
|
||||
|
||||
List<PuzzleElementEntity> elements = newElementList();
|
||||
Map<String, String> dynamicData = newDynamicData();
|
||||
|
||||
// 0. 原图元素(z-index=1,最底层,COVER模式)
|
||||
PuzzleElementEntity originalImageElement = createImageElement(
|
||||
"originalImage", "原图",
|
||||
0, 0,
|
||||
imageWidth, imageHeight, 1,
|
||||
FIT_MODE_COVER, null, null
|
||||
);
|
||||
elements.add(originalImageElement);
|
||||
dynamicData.put("originalImage", request.getOriginalImageUrl());
|
||||
|
||||
// 计算二维码位置(右边界在45%位置,向左推算左边界)
|
||||
int qrcodeX = qrcodeRightX - qrcodeSize;
|
||||
int qrcodeY = imageHeight - bottomOffset - qrcodeSize - qrcodeOffsetY;
|
||||
|
||||
// 1. 二维码元素(圆形裁切)
|
||||
PuzzleElementEntity qrcodeElement = createCircleImageElement(
|
||||
"qrcode", "二维码",
|
||||
qrcodeX, qrcodeY,
|
||||
qrcodeSize, 10
|
||||
);
|
||||
elements.add(qrcodeElement);
|
||||
dynamicData.put("qrcode", request.getQrcodeUrl());
|
||||
|
||||
// 2. 头像元素(圆形,二维码中央,可选)
|
||||
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
|
||||
int avatarDiameter = (int) (qrcodeSize * 0.45);
|
||||
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
|
||||
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
|
||||
|
||||
PuzzleElementEntity faceElement = createCircleImageElement(
|
||||
"face", "头像",
|
||||
avatarX, avatarY,
|
||||
avatarDiameter, 20
|
||||
);
|
||||
elements.add(faceElement);
|
||||
dynamicData.put("face", request.getFaceUrl());
|
||||
}
|
||||
|
||||
// 3. 景区名文字(在二维码右侧,从45.5%位置开始,左对齐)
|
||||
// 文字垂直居中于二维码区域
|
||||
int textAreaHeight = fontSize * 2 + lineSpacing;
|
||||
int textY = qrcodeY + (qrcodeSize - textAreaHeight) / 2;
|
||||
|
||||
PuzzleElementEntity scenicTextElement = createTextElement(
|
||||
"scenicLine", "景区名",
|
||||
textStartX, textY,
|
||||
imageWidth - textStartX - textRightMargin, fontSize + lineSpacing, 30,
|
||||
"PingFang SC", fontSize, FONT_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_LEFT
|
||||
);
|
||||
elements.add(scenicTextElement);
|
||||
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
|
||||
|
||||
// 4. 日期时间文字(在景区名下方,左对齐)
|
||||
int datetimeY = textY + fontSize + lineSpacing;
|
||||
|
||||
PuzzleElementEntity datetimeTextElement = createTextElement(
|
||||
"datetimeLine", "日期时间",
|
||||
textStartX, datetimeY,
|
||||
imageWidth - textStartX - textRightMargin, fontSize + lineSpacing, 30,
|
||||
"PingFang SC", fontSize, FONT_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_LEFT
|
||||
);
|
||||
elements.add(datetimeTextElement);
|
||||
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
|
||||
|
||||
return createResult(template, elements, dynamicData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 打印专用水印模板构建器
|
||||
* 对应 PrinterDefaultWatermarkOperator
|
||||
*
|
||||
* 布局说明:
|
||||
* - 白色背景 + 原图元素(COVER模式)
|
||||
* - 左下角:圆形二维码(带白色圆形背景)
|
||||
* - 二维码中央:圆形头像(可选)
|
||||
* - 右下角:景区名 + 日期时间 两行文字(白色,右对齐)
|
||||
* - 支持缩放和四边偏移
|
||||
*/
|
||||
@Component
|
||||
public class PrinterDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
|
||||
|
||||
public static final String STYLE = "pDefault";
|
||||
|
||||
// 常量配置(与 PrinterDefaultWatermarkOperator 保持一致)
|
||||
private static final int OFFSET_Y = 15;
|
||||
private static final int QRCODE_SIZE = 150;
|
||||
private static final double QRCODE_LEFT_MARGIN_RATIO = 0.05;
|
||||
private static final int QRCODE_OFFSET_Y = -35;
|
||||
private static final int SCENIC_FONT_SIZE = 42;
|
||||
private static final int DATETIME_FONT_SIZE = 42;
|
||||
private static final String FONT_COLOR = "#FFFFFF";
|
||||
private static final double TEXT_RIGHT_MARGIN_RATIO = 0.05;
|
||||
|
||||
@Override
|
||||
public String getStyle() {
|
||||
return STYLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WatermarkTemplateResult build(WatermarkRequest request) {
|
||||
int imageWidth = request.getImageWidth();
|
||||
int imageHeight = request.getImageHeight();
|
||||
double scale = request.getScaleValue();
|
||||
|
||||
// 应用缩放
|
||||
int scaledOffsetY = (int) (OFFSET_Y * scale);
|
||||
int scaledQrcodeSize = (int) (QRCODE_SIZE * scale);
|
||||
int scaledQrcodeOffsetY = (int) (QRCODE_OFFSET_Y * scale);
|
||||
int scaledScenicFontSize = (int) (SCENIC_FONT_SIZE * scale);
|
||||
int scaledDatetimeFontSize = (int) (DATETIME_FONT_SIZE * scale);
|
||||
|
||||
// 获取偏移值
|
||||
int offsetLeft = (int) (request.getOffsetLeftValue() * scale);
|
||||
int offsetRight = (int) (request.getOffsetRightValue() * scale);
|
||||
int offsetBottom = (int) (request.getOffsetBottomValue() * scale);
|
||||
|
||||
// 创建模板(白色背景,原图作为元素实现 COVER 模式)
|
||||
PuzzleTemplateEntity template = createTemplateWithColor(
|
||||
"watermark_printer_" + System.currentTimeMillis(),
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
"#FFFFFF"
|
||||
);
|
||||
|
||||
List<PuzzleElementEntity> elements = newElementList();
|
||||
Map<String, String> dynamicData = newDynamicData();
|
||||
|
||||
// 0. 原图元素(z-index=1,最底层,COVER模式)
|
||||
PuzzleElementEntity originalImageElement = createImageElement(
|
||||
"originalImage", "原图",
|
||||
0, 0,
|
||||
imageWidth, imageHeight, 1,
|
||||
FIT_MODE_COVER, null, null
|
||||
);
|
||||
elements.add(originalImageElement);
|
||||
dynamicData.put("originalImage", request.getOriginalImageUrl());
|
||||
|
||||
// 计算二维码位置
|
||||
int qrcodeWidth = scaledQrcodeSize;
|
||||
int qrcodeHeight = scaledQrcodeSize;
|
||||
int qrcodeX = (int) (imageWidth * QRCODE_LEFT_MARGIN_RATIO) + offsetLeft;
|
||||
int qrcodeY = imageHeight - scaledOffsetY - qrcodeHeight - offsetBottom;
|
||||
|
||||
// 1. 二维码元素(圆形裁切)
|
||||
PuzzleElementEntity qrcodeElement = createCircleImageElement(
|
||||
"qrcode", "二维码",
|
||||
qrcodeX, qrcodeY + scaledQrcodeOffsetY,
|
||||
qrcodeHeight, 10
|
||||
);
|
||||
elements.add(qrcodeElement);
|
||||
dynamicData.put("qrcode", request.getQrcodeUrl());
|
||||
|
||||
// 2. 头像元素(圆形,二维码中央,可选)
|
||||
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
|
||||
int avatarDiameter = (int) (qrcodeHeight * 0.45);
|
||||
int avatarX = qrcodeX + (qrcodeWidth - avatarDiameter) / 2;
|
||||
int avatarY = qrcodeY + scaledQrcodeOffsetY + (qrcodeHeight - avatarDiameter) / 2;
|
||||
|
||||
PuzzleElementEntity faceElement = createCircleImageElement(
|
||||
"face", "头像",
|
||||
avatarX, avatarY,
|
||||
avatarDiameter, 20
|
||||
);
|
||||
elements.add(faceElement);
|
||||
dynamicData.put("face", request.getFaceUrl());
|
||||
}
|
||||
|
||||
// 3. 计算文字位置(右对齐)
|
||||
int textRightX = imageWidth - (int) (imageWidth * TEXT_RIGHT_MARGIN_RATIO) - offsetRight;
|
||||
int textWidth = textRightX - qrcodeX - qrcodeWidth - 20;
|
||||
|
||||
// 计算垂直居中
|
||||
int qrcodeTop = qrcodeY + scaledQrcodeOffsetY;
|
||||
int qrcodeBottom = qrcodeTop + qrcodeHeight;
|
||||
int qrcodeCenter = (qrcodeTop + qrcodeBottom) / 2;
|
||||
int totalTextHeight = scaledScenicFontSize + scaledDatetimeFontSize + 10;
|
||||
int textY = qrcodeCenter - totalTextHeight / 2;
|
||||
|
||||
// 4. 景区名文字(右对齐)
|
||||
PuzzleElementEntity scenicTextElement = createTextElement(
|
||||
"scenicLine", "景区名",
|
||||
textRightX - textWidth, textY,
|
||||
textWidth, scaledScenicFontSize + 10, 30,
|
||||
"PingFang SC", scaledScenicFontSize, FONT_COLOR,
|
||||
"BOLD", TEXT_ALIGN_RIGHT
|
||||
);
|
||||
elements.add(scenicTextElement);
|
||||
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
|
||||
|
||||
// 5. 日期时间文字(右对齐)
|
||||
int datetimeY = textY + scaledScenicFontSize + 5;
|
||||
|
||||
PuzzleElementEntity datetimeTextElement = createTextElement(
|
||||
"datetimeLine", "日期时间",
|
||||
textRightX - textWidth, datetimeY,
|
||||
textWidth, scaledDatetimeFontSize + 10, 30,
|
||||
"PingFang SC", scaledDatetimeFontSize, FONT_COLOR,
|
||||
"BOLD", TEXT_ALIGN_RIGHT
|
||||
);
|
||||
elements.add(datetimeTextElement);
|
||||
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
|
||||
|
||||
return createResult(template, elements, dynamicData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 拼图默认水印模板构建器
|
||||
*
|
||||
* 布局说明:
|
||||
* - 白色背景
|
||||
* - 顶部100%为原图区域(COVER模式,保持原图完整尺寸)
|
||||
* - 底部扩展10%为信息区域:
|
||||
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
|
||||
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
|
||||
*/
|
||||
@Component
|
||||
public class PuzzleDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemplateBuilder {
|
||||
|
||||
public static final String STYLE = "puzzle_default";
|
||||
|
||||
// 布局比例配置
|
||||
private static final double BOTTOM_EXTEND_RATIO = 0.10; // 底部扩展为原图高度的10%
|
||||
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
|
||||
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
|
||||
|
||||
// 文字配置
|
||||
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 bottomAreaHeight = (int) (imageHeight * BOTTOM_EXTEND_RATIO);
|
||||
|
||||
// 画布尺寸 = 原图尺寸 + 底部扩展
|
||||
int canvasWidth = imageWidth;
|
||||
int canvasHeight = imageHeight + bottomAreaHeight;
|
||||
|
||||
// 原图区域保持完整高度
|
||||
int originalImageHeight = imageHeight;
|
||||
|
||||
// 创建模板(白色背景)
|
||||
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,157 @@
|
||||
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%白边
|
||||
* - 内部区域:顶部100%为原图区域(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 BOTTOM_EXTEND_RATIO = 0.10; // 底部扩展为原图高度的10%
|
||||
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
|
||||
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
|
||||
|
||||
// 文字配置
|
||||
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 bottomAreaHeight = (int) (imageHeight * BOTTOM_EXTEND_RATIO);
|
||||
|
||||
// 内容区高度 = 原图高度 + 扩展区域(扩展区域在白边内部)
|
||||
int contentHeight = imageHeight + bottomAreaHeight;
|
||||
|
||||
// 画布尺寸 = 内容区尺寸 + 四周白边
|
||||
int canvasWidth = imageWidth + borderX * 2;
|
||||
int canvasHeight = contentHeight + borderY * 2;
|
||||
|
||||
// 内容区起始位置(白边内)
|
||||
int contentStartX = borderX;
|
||||
int contentStartY = borderY;
|
||||
|
||||
// 内容区宽度 = 原图宽度,原图区域保持完整高度
|
||||
int contentWidth = imageWidth;
|
||||
int originalImageHeight = imageHeight;
|
||||
|
||||
// 创建模板(白色背景)
|
||||
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) (imageHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
|
||||
|
||||
// 二维码垂直居中于底部区域
|
||||
int qrcodeX = contentStartX + marginX;
|
||||
int qrcodeY = contentStartY + originalImageHeight + (bottomAreaHeight - qrcodeSize) / 2;
|
||||
|
||||
// 3. 二维码元素
|
||||
PuzzleElementEntity qrcodeElement = createImageElement(
|
||||
"qrcode", "二维码",
|
||||
qrcodeX, qrcodeY,
|
||||
qrcodeSize, qrcodeSize, 10,
|
||||
FIT_MODE_CONTAIN, null, null
|
||||
);
|
||||
elements.add(qrcodeElement);
|
||||
dynamicData.put("qrcode", request.getQrcodeUrl());
|
||||
|
||||
// 4. 头像元素(二维码中央,可选)
|
||||
if (request.getFaceUrl() != null && !request.getFaceUrl().isEmpty()) {
|
||||
int avatarDiameter = (int) (qrcodeSize * 0.45);
|
||||
int avatarX = qrcodeX + (qrcodeSize - avatarDiameter) / 2;
|
||||
int avatarY = qrcodeY + (qrcodeSize - avatarDiameter) / 2;
|
||||
|
||||
PuzzleElementEntity faceElement = createCircleImageElement(
|
||||
"face", "头像",
|
||||
avatarX, avatarY,
|
||||
avatarDiameter, 20
|
||||
);
|
||||
elements.add(faceElement);
|
||||
dynamicData.put("face", request.getFaceUrl());
|
||||
}
|
||||
|
||||
// 5. 计算右侧文字区域
|
||||
int textRightX = contentStartX + contentWidth - marginX;
|
||||
int textWidth = textRightX - qrcodeX - qrcodeSize - marginX;
|
||||
|
||||
// 文字与二维码垂直居中
|
||||
int totalTextHeight = SCENIC_FONT_SIZE + DATETIME_FONT_SIZE + 5;
|
||||
int textY = contentStartY + originalImageHeight + (bottomAreaHeight - totalTextHeight) / 2;
|
||||
|
||||
// 6. 景区名文字(右对齐)
|
||||
PuzzleElementEntity scenicTextElement = createTextElement(
|
||||
"scenicLine", "景区名",
|
||||
qrcodeX + qrcodeSize + marginX, textY,
|
||||
textWidth, SCENIC_FONT_SIZE + 10, 30,
|
||||
"PingFang SC", SCENIC_FONT_SIZE, SCENIC_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_RIGHT
|
||||
);
|
||||
elements.add(scenicTextElement);
|
||||
dynamicData.put("scenicLine", request.getScenicLine() != null ? request.getScenicLine() : "");
|
||||
|
||||
// 7. 日期时间文字(右对齐)
|
||||
int datetimeY = textY + SCENIC_FONT_SIZE + 5;
|
||||
PuzzleElementEntity datetimeTextElement = createTextElement(
|
||||
"datetimeLine", "日期时间",
|
||||
qrcodeX + qrcodeSize + marginX, datetimeY,
|
||||
textWidth, DATETIME_FONT_SIZE + 10, 30,
|
||||
"PingFang SC", DATETIME_FONT_SIZE, DATETIME_COLOR,
|
||||
"NORMAL", TEXT_ALIGN_RIGHT
|
||||
);
|
||||
elements.add(datetimeTextElement);
|
||||
dynamicData.put("datetimeLine", request.getDatetimeLine() != null ? request.getDatetimeLine() : "");
|
||||
|
||||
return createResult(template, elements, dynamicData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import com.ycwl.basic.constant.StorageConstant;
|
||||
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
|
||||
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||
import com.ycwl.basic.storage.enums.StorageAcl;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 水印边缘端处理服务
|
||||
* 将原有的 IOperator 本地处理迁移到边缘端渲染
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WatermarkEdgeService {
|
||||
|
||||
private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator;
|
||||
|
||||
/**
|
||||
* 默认等待超时时间(毫秒)
|
||||
*/
|
||||
private static final long DEFAULT_TIMEOUT_MS = 30_000L;
|
||||
|
||||
/**
|
||||
* 使用边缘端处理水印(适用于 GoodsServiceImpl 场景)
|
||||
* 直接传入 URL,不需要本地文件
|
||||
*
|
||||
* @param type 水印类型
|
||||
* @param originalUrl 原图URL
|
||||
* @param qrcodeUrl 二维码URL
|
||||
* @param faceUrl 头像URL(可选)
|
||||
* @param scenicLine 景区名称
|
||||
* @param datetime 日期时间
|
||||
* @param dtFormat 日期格式
|
||||
* @param sourceId 关联的sourceId(用于记录追踪)
|
||||
* @param faceId 人脸ID(可选)
|
||||
* @return 带水印的图片URL,处理失败返回null
|
||||
*/
|
||||
public String processWatermark(ImageWatermarkOperatorEnum type,
|
||||
String originalUrl,
|
||||
String qrcodeUrl,
|
||||
String faceUrl,
|
||||
String scenicLine,
|
||||
Date datetime,
|
||||
String dtFormat,
|
||||
Long sourceId,
|
||||
Long faceId) {
|
||||
return processWatermark(type, originalUrl, qrcodeUrl, faceUrl, scenicLine, datetime, dtFormat,
|
||||
sourceId, faceId, null, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用边缘端处理水印(完整参数版本)
|
||||
*
|
||||
* @param type 水印类型
|
||||
* @param originalUrl 原图URL
|
||||
* @param qrcodeUrl 二维码URL
|
||||
* @param faceUrl 头像URL(可选)
|
||||
* @param scenicLine 景区名称
|
||||
* @param datetime 日期时间
|
||||
* @param dtFormat 日期格式
|
||||
* @param sourceId 关联的sourceId(用于记录追踪)
|
||||
* @param faceId 人脸ID(可选)
|
||||
* @param scale 缩放倍数(可选)
|
||||
* @param offsetLeft 左偏移(可选)
|
||||
* @param offsetRight 右偏移(可选)
|
||||
* @param offsetTop 上偏移(可选)
|
||||
* @param offsetBottom 下偏移(可选)
|
||||
* @return 带水印的图片URL,处理失败返回null
|
||||
*/
|
||||
public String processWatermark(ImageWatermarkOperatorEnum type,
|
||||
String originalUrl,
|
||||
String qrcodeUrl,
|
||||
String faceUrl,
|
||||
String scenicLine,
|
||||
Date datetime,
|
||||
String dtFormat,
|
||||
Long sourceId,
|
||||
Long faceId,
|
||||
Double scale,
|
||||
Integer offsetLeft,
|
||||
Integer offsetRight,
|
||||
Integer offsetTop,
|
||||
Integer offsetBottom) {
|
||||
// 将 ImageWatermarkOperatorEnum 映射到边缘端风格
|
||||
String style = mapTypeToStyle(type);
|
||||
|
||||
// 检查边缘端是否支持该风格
|
||||
if (!watermarkEdgeTaskCreator.isStyleSupported(style)) {
|
||||
log.warn("边缘端不支持水印风格: {}", style);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取图片尺寸
|
||||
int[] dimensions = getImageDimensions(originalUrl);
|
||||
if (dimensions == null) {
|
||||
log.error("无法获取图片尺寸: {}", originalUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 构建日期时间行
|
||||
String datetimeLine = datetime != null && dtFormat != null
|
||||
? DateUtil.format(datetime, dtFormat)
|
||||
: null;
|
||||
|
||||
// 构建水印请求
|
||||
WatermarkRequest request = WatermarkRequest.builder()
|
||||
.originalImageUrl(originalUrl)
|
||||
.imageWidth(dimensions[0])
|
||||
.imageHeight(dimensions[1])
|
||||
.qrcodeUrl(qrcodeUrl)
|
||||
.faceUrl(faceUrl)
|
||||
.scenicLine(scenicLine)
|
||||
.datetimeLine(datetimeLine)
|
||||
.scale(scale)
|
||||
.offsetLeft(offsetLeft)
|
||||
.offsetRight(offsetRight)
|
||||
.offsetTop(offsetTop)
|
||||
.offsetBottom(offsetBottom)
|
||||
.outputFormat(type.getPreferFileType().equalsIgnoreCase("png") ? "PNG" : "JPEG")
|
||||
.outputQuality(90)
|
||||
.build();
|
||||
|
||||
// 创建边缘任务并等待结果
|
||||
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
|
||||
style,
|
||||
request,
|
||||
sourceId, // recordId
|
||||
faceId,
|
||||
type.getType(), // watermarkType
|
||||
DEFAULT_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (result.isSuccess()) {
|
||||
log.info("边缘端水印处理成功: sourceId={}, type={}, url={}", sourceId, type, result.getImageUrl());
|
||||
return result.getImageUrl();
|
||||
} else {
|
||||
log.error("边缘端水印处理失败: sourceId={}, type={}, error={}", sourceId, type, result.getErrorMessage());
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("边缘端水印处理异常: sourceId={}, type={}", sourceId, type, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用边缘端处理水印(适用于 WatermarkStage / Pipeline 场景)
|
||||
* 从本地文件处理,需要先上传原图和二维码
|
||||
*
|
||||
* @param info 水印信息(包含本地文件)
|
||||
* @param type 水印类型
|
||||
* @param adapter 存储适配器
|
||||
* @param recordId 记录ID(用于边缘端任务追踪,不能为空)
|
||||
* @return 处理后的本地文件,失败返回null
|
||||
*/
|
||||
public File processWatermarkFromFile(WatermarkInfo info,
|
||||
ImageWatermarkOperatorEnum type,
|
||||
IStorageAdapter adapter,
|
||||
String recordId) {
|
||||
// 将 ImageWatermarkOperatorEnum 映射到边缘端风格
|
||||
String style = mapTypeToStyle(type);
|
||||
|
||||
// 检查边缘端是否支持该风格
|
||||
if (!watermarkEdgeTaskCreator.isStyleSupported(style)) {
|
||||
log.warn("边缘端不支持水印风格: {}", style);
|
||||
return null;
|
||||
}
|
||||
|
||||
String uploadedOriginalUrl = null;
|
||||
String uploadedQrcodeUrl = null;
|
||||
String uploadedFaceUrl = null;
|
||||
|
||||
try {
|
||||
// 1. 获取图片尺寸
|
||||
BufferedImage originalImage = ImageIO.read(info.getOriginalFile());
|
||||
if (originalImage == null) {
|
||||
log.error("无法读取原图文件: {}", info.getOriginalFile());
|
||||
return null;
|
||||
}
|
||||
int imageWidth = originalImage.getWidth();
|
||||
int imageHeight = originalImage.getHeight();
|
||||
originalImage.flush();
|
||||
|
||||
// 2. 上传原图到临时位置
|
||||
String originalFileName = "temp_watermark_" + UUID.randomUUID() + ".jpg";
|
||||
uploadedOriginalUrl = adapter.uploadFile(null, info.getOriginalFile(),
|
||||
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", originalFileName);
|
||||
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", originalFileName);
|
||||
|
||||
// 3. 上传二维码(如果有)
|
||||
if (info.getQrcodeFile() != null && info.getQrcodeFile().exists()) {
|
||||
String qrcodeFileName = "temp_qrcode_" + UUID.randomUUID() + ".jpg";
|
||||
uploadedQrcodeUrl = adapter.uploadFile(null, info.getQrcodeFile(),
|
||||
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", qrcodeFileName);
|
||||
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", qrcodeFileName);
|
||||
}
|
||||
|
||||
// 4. 上传头像(如果有)
|
||||
if (info.getFaceFile() != null && info.getFaceFile().exists()) {
|
||||
String faceFileName = "temp_face_" + UUID.randomUUID() + ".jpg";
|
||||
uploadedFaceUrl = adapter.uploadFile(null, info.getFaceFile(),
|
||||
StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", faceFileName);
|
||||
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH + "/temp", faceFileName);
|
||||
}
|
||||
|
||||
// 5. 构建水印请求
|
||||
WatermarkRequest request = WatermarkRequest.builder()
|
||||
.originalImageUrl(uploadedOriginalUrl)
|
||||
.imageWidth(imageWidth)
|
||||
.imageHeight(imageHeight)
|
||||
.qrcodeUrl(uploadedQrcodeUrl)
|
||||
.faceUrl(uploadedFaceUrl)
|
||||
.scenicLine(info.getScenicLine())
|
||||
.datetimeLine(info.getDatetimeLine())
|
||||
.scale(info.getScale())
|
||||
.offsetLeft(info.getOffsetLeft())
|
||||
.offsetRight(info.getOffsetRight())
|
||||
.offsetTop(info.getOffsetTop())
|
||||
.offsetBottom(info.getOffsetBottom())
|
||||
.outputFormat(type.getPreferFileType().equalsIgnoreCase("png") ? "PNG" : "JPEG")
|
||||
.outputQuality(90)
|
||||
.build();
|
||||
|
||||
// 6. 创建边缘任务并等待结果(使用传入的 recordId)
|
||||
// recordId 转换为 Long,如果无法转换则使用哈希值
|
||||
Long recordIdLong;
|
||||
try {
|
||||
recordIdLong = Long.parseLong(recordId);
|
||||
} catch (NumberFormatException e) {
|
||||
// 如果 recordId 不是数字(如 UUID),使用其哈希值的绝对值
|
||||
recordIdLong = (long) Math.abs(recordId.hashCode());
|
||||
}
|
||||
|
||||
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
|
||||
style,
|
||||
request,
|
||||
recordIdLong, // recordId
|
||||
null, // faceId
|
||||
type.getType(), // watermarkType
|
||||
DEFAULT_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (!result.isSuccess()) {
|
||||
log.error("边缘端水印处理失败: recordId={}, error={}", recordId, result.getErrorMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
// 7. 下载结果到目标文件
|
||||
String resultUrl = result.getImageUrl();
|
||||
File outputFile = info.getWatermarkedFile();
|
||||
downloadFile(resultUrl, outputFile);
|
||||
|
||||
log.info("边缘端水印处理成功: recordId={}, type={}, outputFile={}", recordId, type, outputFile);
|
||||
return outputFile;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("边缘端水印处理异常: recordId={}, type={}", recordId, type, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ImageWatermarkOperatorEnum 映射到边缘端风格
|
||||
*/
|
||||
private String mapTypeToStyle(ImageWatermarkOperatorEnum type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (type) {
|
||||
case NORMAL -> NormalWatermarkTemplateBuilder.STYLE;
|
||||
case LEICA -> LeicaWatermarkTemplateBuilder.STYLE;
|
||||
case PRINTER_DEFAULT -> PrinterDefaultWatermarkTemplateBuilder.STYLE;
|
||||
case PUZZLE_PRINT -> PuzzlePrintWatermarkTemplateBuilder.STYLE;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片尺寸
|
||||
*
|
||||
* @param imageUrl 图片URL
|
||||
* @return [width, height],失败返回null
|
||||
*/
|
||||
private int[] getImageDimensions(String imageUrl) {
|
||||
try {
|
||||
// 替换内网域名
|
||||
String url = imageUrl.replace("oss.zhentuai.com",
|
||||
"frametour-assets.oss-cn-shanghai-internal.aliyuncs.com");
|
||||
BufferedImage image = ImageIO.read(new URL(url));
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
int[] dimensions = new int[]{image.getWidth(), image.getHeight()};
|
||||
image.flush();
|
||||
return dimensions;
|
||||
} catch (IOException e) {
|
||||
log.error("获取图片尺寸失败: {}", imageUrl, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
private void downloadFile(String url, File targetFile) throws IOException {
|
||||
// 替换内网域名
|
||||
String downloadUrl = url.replace("oss.zhentuai.com",
|
||||
"frametour-assets.oss-cn-shanghai-internal.aliyuncs.com");
|
||||
HttpUtil.downloadFile(downloadUrl, targetFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 水印边缘任务创建服务
|
||||
* 将水印请求转换为边缘渲染任务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WatermarkEdgeTaskCreator {
|
||||
|
||||
private final PuzzleEdgeRenderTaskService edgeRenderTaskService;
|
||||
private final List<IWatermarkTemplateBuilder> builders;
|
||||
|
||||
private final Map<String, IWatermarkTemplateBuilder> builderMap = new HashMap<>();
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
for (IWatermarkTemplateBuilder builder : builders) {
|
||||
builderMap.put(builder.getStyle(), builder);
|
||||
log.info("注册水印模板构建器: {}", builder.getStyle());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建水印渲染任务
|
||||
*
|
||||
* @param style 水印风格(normal/leica/printer_default)
|
||||
* @param request 水印请求参数
|
||||
* @param recordId 原始拼图记录ID(用于关联)
|
||||
* @param faceId 人脸ID(可选)
|
||||
* @param watermarkType 水印类型标识(如 print、free_download)
|
||||
* @return 任务ID
|
||||
*/
|
||||
public Long createTask(String style,
|
||||
WatermarkRequest request,
|
||||
Long recordId,
|
||||
Long faceId,
|
||||
String watermarkType) {
|
||||
IWatermarkTemplateBuilder builder = builderMap.get(style);
|
||||
if (builder == null) {
|
||||
throw new IllegalArgumentException("未知的水印风格: " + style);
|
||||
}
|
||||
|
||||
// 构建水印模板
|
||||
WatermarkTemplateResult result = builder.build(request);
|
||||
|
||||
// 创建边缘渲染任务
|
||||
Long taskId = edgeRenderTaskService.createWatermarkRenderTask(
|
||||
recordId,
|
||||
faceId,
|
||||
watermarkType,
|
||||
result.getTemplate(),
|
||||
result.getElements(),
|
||||
result.getDynamicData(),
|
||||
request.getOutputFormat(),
|
||||
request.getOutputQuality()
|
||||
);
|
||||
|
||||
log.info("创建水印边缘渲染任务: style={}, taskId={}, recordId={}, watermarkType={}",
|
||||
style, taskId, recordId, watermarkType);
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建水印渲染任务并等待结果
|
||||
*
|
||||
* @param style 水印风格
|
||||
* @param request 水印请求参数
|
||||
* @param recordId 原始拼图记录ID
|
||||
* @param faceId 人脸ID
|
||||
* @param watermarkType 水印类型
|
||||
* @param timeoutMs 超时时间(毫秒)
|
||||
* @return 任务结果
|
||||
*/
|
||||
public PuzzleEdgeRenderTaskService.TaskWaitResult createAndWait(String style,
|
||||
WatermarkRequest request,
|
||||
Long recordId,
|
||||
Long faceId,
|
||||
String watermarkType,
|
||||
long timeoutMs) {
|
||||
Long taskId = createTask(style, request, recordId, faceId, watermarkType);
|
||||
edgeRenderTaskService.registerWait(taskId);
|
||||
return edgeRenderTaskService.waitForTask(taskId, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的水印风格列表
|
||||
*/
|
||||
public List<String> getSupportedStyles() {
|
||||
return List.copyOf(builderMap.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持指定的水印风格
|
||||
*/
|
||||
public boolean isStyleSupported(String style) {
|
||||
return builderMap.containsKey(style);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 水印请求参数
|
||||
* 将原有的 WatermarkInfo(基于文件)转换为边缘渲染所需的格式(基于URL)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class WatermarkRequest {
|
||||
/**
|
||||
* 原图URL
|
||||
*/
|
||||
private String originalImageUrl;
|
||||
|
||||
/**
|
||||
* 原图宽度(像素)
|
||||
*/
|
||||
private int imageWidth;
|
||||
|
||||
/**
|
||||
* 原图高度(像素)
|
||||
*/
|
||||
private int imageHeight;
|
||||
|
||||
/**
|
||||
* 二维码URL
|
||||
*/
|
||||
private String qrcodeUrl;
|
||||
|
||||
/**
|
||||
* 头像URL(可选)
|
||||
*/
|
||||
private String faceUrl;
|
||||
|
||||
/**
|
||||
* 景区名称
|
||||
*/
|
||||
private String scenicLine;
|
||||
|
||||
/**
|
||||
* 日期时间行
|
||||
*/
|
||||
private String datetimeLine;
|
||||
|
||||
/**
|
||||
* 四边偏移(像素),正数表示向内偏移
|
||||
*/
|
||||
private Integer offsetTop;
|
||||
private Integer offsetBottom;
|
||||
private Integer offsetLeft;
|
||||
private Integer offsetRight;
|
||||
|
||||
/**
|
||||
* 缩放倍数,默认1.0
|
||||
*/
|
||||
private Double scale;
|
||||
|
||||
/**
|
||||
* 输出格式:PNG / JPEG
|
||||
*/
|
||||
@Builder.Default
|
||||
private String outputFormat = "JPEG";
|
||||
|
||||
/**
|
||||
* 输出质量(0-100)
|
||||
*/
|
||||
@Builder.Default
|
||||
private Integer outputQuality = 75;
|
||||
|
||||
public double getScaleValue() {
|
||||
return scale != null ? scale : 1.0;
|
||||
}
|
||||
|
||||
public int getOffsetTopValue() {
|
||||
return offsetTop != null ? offsetTop : 0;
|
||||
}
|
||||
|
||||
public int getOffsetBottomValue() {
|
||||
return offsetBottom != null ? offsetBottom : 0;
|
||||
}
|
||||
|
||||
public int getOffsetLeftValue() {
|
||||
return offsetLeft != null ? offsetLeft : 0;
|
||||
}
|
||||
|
||||
public int getOffsetRightValue() {
|
||||
return offsetRight != null ? offsetRight : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ycwl.basic.image.watermark.edge;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 水印模板构建结果
|
||||
* 包含虚拟模板、元素列表和动态数据,用于发送给边缘渲染任务
|
||||
*/
|
||||
@Data
|
||||
public class WatermarkTemplateResult {
|
||||
/**
|
||||
* 虚拟模板(运行时构造,不存储到数据库)
|
||||
*/
|
||||
private PuzzleTemplateEntity template;
|
||||
|
||||
/**
|
||||
* 元素列表(按z-index排序)
|
||||
*/
|
||||
private List<PuzzleElementEntity> elements;
|
||||
|
||||
/**
|
||||
* 动态数据(elementKey -> 实际值URL或文本)
|
||||
*/
|
||||
private Map<String, String> dynamicData;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.ycwl.basic.image.watermark.edge.controller;
|
||||
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeTaskCreator;
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkRequest;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 水印边缘渲染测试控制器
|
||||
* 用于测试水印边缘渲染功能
|
||||
*/
|
||||
@Slf4j
|
||||
@IgnoreToken
|
||||
@RestController
|
||||
@RequestMapping("/test/watermark/edge")
|
||||
@RequiredArgsConstructor
|
||||
public class WatermarkEdgeTestController {
|
||||
|
||||
private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator;
|
||||
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
|
||||
|
||||
/**
|
||||
* 获取支持的水印风格列表
|
||||
*/
|
||||
@GetMapping("/styles")
|
||||
public ApiResponse<List<String>> getSupportedStyles() {
|
||||
return ApiResponse.success(watermarkEdgeTaskCreator.getSupportedStyles());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建水印渲染任务(异步)
|
||||
* 任务创建后由边缘端拉取执行
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
public ApiResponse<CreateTaskResponse> createTask(@RequestBody CreateTaskRequest req) {
|
||||
// 参数校验
|
||||
if (req.getStyle() == null || req.getStyle().isEmpty()) {
|
||||
return ApiResponse.fail("水印风格(style)不能为空");
|
||||
}
|
||||
if (!watermarkEdgeTaskCreator.isStyleSupported(req.getStyle())) {
|
||||
return ApiResponse.fail("不支持的水印风格: " + req.getStyle() +
|
||||
",支持的风格: " + watermarkEdgeTaskCreator.getSupportedStyles());
|
||||
}
|
||||
if (req.getOriginalImageUrl() == null || req.getOriginalImageUrl().isEmpty()) {
|
||||
return ApiResponse.fail("原图URL(originalImageUrl)不能为空");
|
||||
}
|
||||
if (req.getImageWidth() <= 0 || req.getImageHeight() <= 0) {
|
||||
return ApiResponse.fail("图片宽高必须大于0");
|
||||
}
|
||||
|
||||
// 构建请求
|
||||
WatermarkRequest watermarkRequest = WatermarkRequest.builder()
|
||||
.originalImageUrl(req.getOriginalImageUrl())
|
||||
.imageWidth(req.getImageWidth())
|
||||
.imageHeight(req.getImageHeight())
|
||||
.qrcodeUrl(req.getQrcodeUrl())
|
||||
.faceUrl(req.getFaceUrl())
|
||||
.scenicLine(req.getScenicLine())
|
||||
.datetimeLine(req.getDatetimeLine())
|
||||
.offsetTop(req.getOffsetTop())
|
||||
.offsetBottom(req.getOffsetBottom())
|
||||
.offsetLeft(req.getOffsetLeft())
|
||||
.offsetRight(req.getOffsetRight())
|
||||
.scale(req.getScale())
|
||||
.outputFormat(req.getOutputFormat() != null ? req.getOutputFormat() : "JPEG")
|
||||
.outputQuality(req.getOutputQuality() != null ? req.getOutputQuality() : 75)
|
||||
.build();
|
||||
|
||||
// 创建任务
|
||||
Long taskId = watermarkEdgeTaskCreator.createTask(
|
||||
req.getStyle(),
|
||||
watermarkRequest,
|
||||
req.getRecordId() != null ? req.getRecordId() : 0L, // 测试用默认值
|
||||
req.getFaceId(),
|
||||
req.getWatermarkType() != null ? req.getWatermarkType() : "test"
|
||||
);
|
||||
|
||||
CreateTaskResponse response = new CreateTaskResponse();
|
||||
response.setTaskId(taskId);
|
||||
response.setMessage("任务已创建,等待边缘端拉取执行");
|
||||
|
||||
log.info("测试创建水印任务: style={}, taskId={}", req.getStyle(), taskId);
|
||||
return ApiResponse.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建水印渲染任务并等待结果(同步)
|
||||
* 注意:此接口会阻塞直到任务完成或超时
|
||||
*/
|
||||
@PostMapping("/createAndWait")
|
||||
public ApiResponse<CreateAndWaitResponse> createAndWait(@RequestBody CreateTaskRequest req) {
|
||||
// 参数校验
|
||||
if (req.getStyle() == null || req.getStyle().isEmpty()) {
|
||||
return ApiResponse.fail("水印风格(style)不能为空");
|
||||
}
|
||||
if (!watermarkEdgeTaskCreator.isStyleSupported(req.getStyle())) {
|
||||
return ApiResponse.fail("不支持的水印风格: " + req.getStyle() +
|
||||
",支持的风格: " + watermarkEdgeTaskCreator.getSupportedStyles());
|
||||
}
|
||||
if (req.getOriginalImageUrl() == null || req.getOriginalImageUrl().isEmpty()) {
|
||||
return ApiResponse.fail("原图URL(originalImageUrl)不能为空");
|
||||
}
|
||||
if (req.getImageWidth() <= 0 || req.getImageHeight() <= 0) {
|
||||
return ApiResponse.fail("图片宽高必须大于0");
|
||||
}
|
||||
|
||||
// 构建请求
|
||||
WatermarkRequest watermarkRequest = WatermarkRequest.builder()
|
||||
.originalImageUrl(req.getOriginalImageUrl())
|
||||
.imageWidth(req.getImageWidth())
|
||||
.imageHeight(req.getImageHeight())
|
||||
.qrcodeUrl(req.getQrcodeUrl())
|
||||
.faceUrl(req.getFaceUrl())
|
||||
.scenicLine(req.getScenicLine())
|
||||
.datetimeLine(req.getDatetimeLine())
|
||||
.offsetTop(req.getOffsetTop())
|
||||
.offsetBottom(req.getOffsetBottom())
|
||||
.offsetLeft(req.getOffsetLeft())
|
||||
.offsetRight(req.getOffsetRight())
|
||||
.scale(req.getScale())
|
||||
.outputFormat(req.getOutputFormat() != null ? req.getOutputFormat() : "JPEG")
|
||||
.outputQuality(req.getOutputQuality() != null ? req.getOutputQuality() : 75)
|
||||
.build();
|
||||
|
||||
// 超时时间,默认30秒
|
||||
long timeoutMs = req.getTimeoutMs() != null ? req.getTimeoutMs() : 30000L;
|
||||
|
||||
// 先创建任务获取 taskId
|
||||
Long taskId = watermarkEdgeTaskCreator.createTask(
|
||||
req.getStyle(),
|
||||
watermarkRequest,
|
||||
req.getRecordId() != null ? req.getRecordId() : 0L,
|
||||
req.getFaceId(),
|
||||
req.getWatermarkType() != null ? req.getWatermarkType() : "test"
|
||||
);
|
||||
|
||||
// 注册等待并等待结果
|
||||
puzzleEdgeRenderTaskService.registerWait(taskId);
|
||||
PuzzleEdgeRenderTaskService.TaskWaitResult result = puzzleEdgeRenderTaskService.waitForTask(taskId, timeoutMs);
|
||||
|
||||
CreateAndWaitResponse response = new CreateAndWaitResponse();
|
||||
response.setTaskId(taskId);
|
||||
response.setSuccess(result.isSuccess());
|
||||
response.setImageUrl(result.getImageUrl());
|
||||
response.setErrorMessage(result.getErrorMessage());
|
||||
|
||||
log.info("测试水印任务完成: style={}, taskId={}, success={}, imageUrl={}",
|
||||
req.getStyle(), taskId, result.isSuccess(), result.getImageUrl());
|
||||
|
||||
return ApiResponse.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务请求
|
||||
*/
|
||||
@Data
|
||||
public static class CreateTaskRequest {
|
||||
/**
|
||||
* 水印风格:normal / leica / printer_default
|
||||
*/
|
||||
private String style;
|
||||
|
||||
/**
|
||||
* 原图URL
|
||||
*/
|
||||
private String originalImageUrl;
|
||||
|
||||
/**
|
||||
* 原图宽度
|
||||
*/
|
||||
private int imageWidth;
|
||||
|
||||
/**
|
||||
* 原图高度
|
||||
*/
|
||||
private int imageHeight;
|
||||
|
||||
/**
|
||||
* 二维码URL
|
||||
*/
|
||||
private String qrcodeUrl;
|
||||
|
||||
/**
|
||||
* 头像URL(可选)
|
||||
*/
|
||||
private String faceUrl;
|
||||
|
||||
/**
|
||||
* 景区名称
|
||||
*/
|
||||
private String scenicLine;
|
||||
|
||||
/**
|
||||
* 日期时间行
|
||||
*/
|
||||
private String datetimeLine;
|
||||
|
||||
/**
|
||||
* 四边偏移(像素)
|
||||
*/
|
||||
private Integer offsetTop;
|
||||
private Integer offsetBottom;
|
||||
private Integer offsetLeft;
|
||||
private Integer offsetRight;
|
||||
|
||||
/**
|
||||
* 缩放倍数
|
||||
*/
|
||||
private Double scale;
|
||||
|
||||
/**
|
||||
* 输出格式:PNG / JPEG
|
||||
*/
|
||||
private String outputFormat;
|
||||
|
||||
/**
|
||||
* 输出质量(0-100)
|
||||
*/
|
||||
private Integer outputQuality;
|
||||
|
||||
/**
|
||||
* 关联的拼图记录ID(测试用)
|
||||
*/
|
||||
private Long recordId;
|
||||
|
||||
/**
|
||||
* 人脸ID(可选)
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 水印类型标识
|
||||
*/
|
||||
private String watermarkType;
|
||||
|
||||
/**
|
||||
* 等待超时时间(毫秒),仅用于 createAndWait
|
||||
*/
|
||||
private Long timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务响应
|
||||
*/
|
||||
@Data
|
||||
public static class CreateTaskResponse {
|
||||
private Long taskId;
|
||||
private String message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并等待响应
|
||||
*/
|
||||
@Data
|
||||
public static class CreateAndWaitResponse {
|
||||
private Long taskId;
|
||||
private boolean success;
|
||||
private String imageUrl;
|
||||
private String errorMessage;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public enum ImageWatermarkOperatorEnum {
|
||||
WATERMARK("defW", "jpg"),
|
||||
LEICA("leica", "png"),
|
||||
NORMAL("normal", "png"),
|
||||
PRINTER_DEFAULT("pDefault", "png");
|
||||
LEICA("leica", "jpg"),
|
||||
NORMAL("normal", "jpg"),
|
||||
PRINTER_DEFAULT("pDefault", "jpg"),
|
||||
PUZZLE_PRINT("puzzle_print", "jpg");
|
||||
|
||||
private final String type;
|
||||
private final String preferFileType;
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package com.ycwl.basic.image.watermark.operator;
|
||||
|
||||
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
|
||||
import com.ycwl.basic.image.watermark.exception.ImageWatermarkException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
@Slf4j
|
||||
public class DefaultImageWatermarkOperator implements IOperator {
|
||||
@Override
|
||||
public File process(WatermarkInfo info) throws ImageWatermarkException {
|
||||
BufferedImage baseImage;
|
||||
BufferedImage watermarkImage;
|
||||
InputStream logoInputStream = getClass().getResourceAsStream("/watermark.png");
|
||||
if (logoInputStream == null) {
|
||||
throw new ImageWatermarkException("无法找到 watermark.png 资源文件");
|
||||
}
|
||||
try {
|
||||
baseImage = ImageIO.read(info.getOriginalFile());
|
||||
watermarkImage = ImageIO.read(logoInputStream);
|
||||
} catch (IOException e) {
|
||||
throw new ImageWatermarkException("图片打开失败");
|
||||
}
|
||||
// 新图像画布
|
||||
BufferedImage newImage = new BufferedImage(baseImage.getWidth(), baseImage.getHeight(), BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = newImage.createGraphics();
|
||||
g2d.drawImage(baseImage, 0, 0, null);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_ATOP, 0.5f));
|
||||
g2d.drawImage(watermarkImage, 0, 0, baseImage.getWidth(), baseImage.getHeight(), null);
|
||||
String fileName = info.getWatermarkedFile().getName();
|
||||
String formatName = "jpg"; // 默认格式为 jpg
|
||||
if (fileName.endsWith(".png")) {
|
||||
formatName = "png";
|
||||
} else if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) {
|
||||
formatName = "jpg";
|
||||
}
|
||||
ImageWriter writer = ImageIO.getImageWritersByFormatName(formatName).next();
|
||||
ImageOutputStream ios;
|
||||
try {
|
||||
ios = ImageIO.createImageOutputStream(info.getWatermarkedFile());
|
||||
} catch (IOException e) {
|
||||
throw new ImageWatermarkException("图片保存失败,目标文件无法写入");
|
||||
}
|
||||
writer.setOutput(ios);
|
||||
try {
|
||||
// 使用 ImageWriter 设置写入质量
|
||||
ImageWriteParam writeParam = writer.getDefaultWriteParam();
|
||||
if (writeParam.canWriteCompressed()) {
|
||||
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||
writeParam.setCompressionQuality(0.8f); // 设置写入质量为 80%
|
||||
}
|
||||
writer.write(null, new javax.imageio.IIOImage(newImage, null, null), writeParam);
|
||||
} catch (IOException e) {
|
||||
throw new ImageWatermarkException("图片保存失败");
|
||||
}
|
||||
finally {
|
||||
g2d.dispose();
|
||||
try {
|
||||
ios.close();
|
||||
} catch (IOException ignore) {
|
||||
}
|
||||
writer.dispose();
|
||||
}
|
||||
return info.getWatermarkedFile();
|
||||
}
|
||||
}
|
||||
@@ -1161,6 +1161,228 @@ fallbackService.clearAllFallbackCache("zt-render-worker");
|
||||
- **Active (isActive=1)**: Worker is available for tasks
|
||||
- **Inactive (isActive=0)**: Worker is disabled
|
||||
|
||||
## Render Template Integration (ZT-Render-Worker Microservice)
|
||||
|
||||
### Key Components
|
||||
|
||||
#### Feign Clients
|
||||
- **RenderTemplateV2Client**: Template CRUD operations, segment management
|
||||
|
||||
#### Services
|
||||
- **RenderTemplateIntegrationService**: High-level template operations (with automatic fallback for queries)
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Basic Template Operations
|
||||
```java
|
||||
@Autowired
|
||||
private RenderTemplateIntegrationService templateService;
|
||||
|
||||
// Create template (direct operation, fails immediately on error)
|
||||
CreateTemplateRequest createRequest = new CreateTemplateRequest();
|
||||
createRequest.setScenicId(1001L);
|
||||
createRequest.setName("新年贺卡模板");
|
||||
createRequest.setDescription("用于新年祝福的模板");
|
||||
createRequest.setDefaultDurationMs(10000L);
|
||||
|
||||
OutputSpecDTO outputSpec = new OutputSpecDTO();
|
||||
outputSpec.setWidth(1080);
|
||||
outputSpec.setHeight(1920);
|
||||
outputSpec.setFps(30);
|
||||
createRequest.setOutputSpec(outputSpec);
|
||||
|
||||
TemplateV2DTO template = templateService.createTemplate(createRequest);
|
||||
|
||||
// Get template details (automatically falls back to cache on failure)
|
||||
TemplateV2DTO templateInfo = templateService.getTemplate(templateId);
|
||||
|
||||
// Get template with segments (automatically falls back to cache on failure)
|
||||
TemplateV2WithSegmentsDTO templateWithSegments = templateService.getTemplateWithSegments(templateId);
|
||||
|
||||
// List templates (no fallback for list operations)
|
||||
PageResponse<TemplateV2DTO> templates = templateService.listTemplates(1, 10, scenicId, 1, null);
|
||||
|
||||
// Update template (direct operation, fails immediately on error)
|
||||
UpdateTemplateRequest updateRequest = new UpdateTemplateRequest();
|
||||
updateRequest.setName("更新后的模板名称");
|
||||
templateService.updateTemplate(templateId, updateRequest);
|
||||
|
||||
// Publish template (direct operation, fails immediately on error)
|
||||
templateService.publishTemplate(templateId);
|
||||
|
||||
// Create new version (direct operation, fails immediately on error)
|
||||
TemplateV2DTO newVersion = templateService.createTemplateVersion(templateId);
|
||||
|
||||
// Delete template (direct operation, fails immediately on error)
|
||||
templateService.deleteTemplate(templateId);
|
||||
```
|
||||
|
||||
#### Segment Management
|
||||
```java
|
||||
// Get template segments (automatically falls back to cache on failure)
|
||||
List<TemplateV2SegmentDTO> segments = templateService.getTemplateSegments(templateId);
|
||||
|
||||
// Create segment (direct operation, fails immediately on error)
|
||||
CreateSegmentRequest segmentRequest = new CreateSegmentRequest();
|
||||
segmentRequest.setSegmentIndex(0);
|
||||
segmentRequest.setSegmentType("RENDER");
|
||||
segmentRequest.setSourceType("SLOT");
|
||||
segmentRequest.setSourceRef("slot1");
|
||||
segmentRequest.setDurationMs(2000L);
|
||||
segmentRequest.setTransitionType("fade");
|
||||
segmentRequest.setTransitionMs(500);
|
||||
|
||||
RenderSpecDTO renderSpec = new RenderSpecDTO();
|
||||
renderSpec.setCropEnable(true);
|
||||
renderSpec.setSpeed("1.0");
|
||||
segmentRequest.setRenderSpec(renderSpec);
|
||||
|
||||
TemplateV2SegmentDTO segment = templateService.createSegment(templateId, segmentRequest);
|
||||
|
||||
// Update segment (direct operation, fails immediately on error)
|
||||
UpdateSegmentRequest updateSegmentRequest = new UpdateSegmentRequest();
|
||||
updateSegmentRequest.setDurationMs(3000L);
|
||||
templateService.updateSegment(templateId, segmentId, updateSegmentRequest);
|
||||
|
||||
// Delete segment (direct operation, fails immediately on error)
|
||||
templateService.deleteSegment(templateId, segmentId);
|
||||
|
||||
// Replace all segments (direct operation, fails immediately on error)
|
||||
ReplaceSegmentsRequest replaceRequest = new ReplaceSegmentsRequest();
|
||||
replaceRequest.setSegments(Arrays.asList(segmentRequest1, segmentRequest2));
|
||||
templateService.replaceSegments(templateId, replaceRequest);
|
||||
```
|
||||
|
||||
### Template Status
|
||||
- **0**: Draft - Template is being edited
|
||||
- **1**: Published - Template is live and available for rendering
|
||||
|
||||
### Segment Types
|
||||
- **FIXED**: Fixed asset segment
|
||||
- **RENDER**: Segment that needs to be rendered with user materials
|
||||
|
||||
### Source Types
|
||||
- **ASSET**: Fixed asset resource
|
||||
- **PLACEHOLDER_VIDEO**: Video placeholder slot
|
||||
- **PLACEHOLDER_IMAGE**: Image placeholder slot
|
||||
- **SLOT**: Material slot
|
||||
|
||||
## Render Job Integration (ZT-Render-Worker Microservice)
|
||||
|
||||
### Key Components
|
||||
|
||||
#### Feign Clients
|
||||
- **RenderJobV2Client**: Job creation, status queries, admin operations
|
||||
|
||||
#### Services
|
||||
- **RenderJobIntegrationService**: High-level job operations (with automatic fallback for queries)
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Creating and Managing Render Jobs
|
||||
```java
|
||||
@Autowired
|
||||
private RenderJobIntegrationService jobService;
|
||||
|
||||
// Create preview job (direct operation, fails immediately on error)
|
||||
CreatePreviewRequest previewRequest = new CreatePreviewRequest();
|
||||
previewRequest.setTemplateId(123L);
|
||||
previewRequest.setScenicId(456L);
|
||||
previewRequest.setFaceId(789L);
|
||||
previewRequest.setMemberId(101L);
|
||||
|
||||
// Set materials by slot
|
||||
Map<String, List<MaterialDTO>> materialsBySlot = new HashMap<>();
|
||||
MaterialDTO material = new MaterialDTO();
|
||||
material.setUrl("https://example.com/video.mp4");
|
||||
material.setType("video");
|
||||
material.setDuration(5000L);
|
||||
materialsBySlot.put("slot1", Arrays.asList(material));
|
||||
previewRequest.setMaterialsBySlot(materialsBySlot);
|
||||
|
||||
CreatePreviewResponse previewResponse = jobService.createPreview(previewRequest);
|
||||
log.info("作业创建成功, jobId: {}, playUrl: {}", previewResponse.getJobId(), previewResponse.getPlayUrl());
|
||||
|
||||
// Get job status (automatically falls back to cache on failure)
|
||||
JobStatusResponse status = jobService.getJobStatus(jobId);
|
||||
log.info("作业状态: {}, 进度: {}%", status.getStatus(), status.getProgress());
|
||||
|
||||
// Get playlist info (automatically falls back to cache on failure)
|
||||
PlaylistInfoDTO playlistInfo = jobService.getPlaylistInfo(jobId);
|
||||
log.info("总片段: {}, 已发布: {}", playlistInfo.getSegmentCount(), playlistInfo.getPublishedCount());
|
||||
|
||||
// Get HLS playlist (direct call, returns M3U8 content)
|
||||
String hlsPlaylist = jobService.getHlsPlaylist(jobId);
|
||||
|
||||
// Cancel job (direct operation, fails immediately on error)
|
||||
jobService.cancelJob(jobId);
|
||||
```
|
||||
|
||||
#### Admin Operations
|
||||
```java
|
||||
// List jobs (no fallback for list operations)
|
||||
PageResponse<RenderJobV2DTO> jobs = jobService.listJobs(scenicId, templateId, "RUNNING", "PREVIEW", 1, 20);
|
||||
log.info("查询到 {} 条作业", jobs.getTotal());
|
||||
|
||||
// Get job detail (automatically falls back to cache on failure)
|
||||
RenderJobV2DTO jobDetail = jobService.getJobDetail(jobId);
|
||||
log.info("作业详情: templateId={}, status={}", jobDetail.getTemplateId(), jobDetail.getStatus());
|
||||
|
||||
// Get job segments (automatically falls back to cache on failure)
|
||||
List<RenderJobSegmentV2DTO> jobSegments = jobService.getJobSegments(jobId);
|
||||
for (RenderJobSegmentV2DTO segment : jobSegments) {
|
||||
log.info("片段 {}: status={}, tsUrl={}", segment.getPlanSegmentIndex(), segment.getStatus(), segment.getTsUrl());
|
||||
}
|
||||
```
|
||||
|
||||
### Job Status
|
||||
- **PENDING**: Job is waiting to be processed
|
||||
- **RUNNING**: Job is currently being processed
|
||||
- **SUCCESS**: Job completed successfully
|
||||
- **FAILED**: Job failed
|
||||
- **CANCELED**: Job was canceled
|
||||
|
||||
### Job Types
|
||||
- **PREVIEW**: Preview job (HLS streaming)
|
||||
- **PREVIEW_HLS**: HLS preview job
|
||||
- **FINAL_MP4**: Final MP4 export job
|
||||
|
||||
### Segment Status
|
||||
- **PENDING**: Segment is waiting to be processed
|
||||
- **RENDERING**: Segment is being rendered
|
||||
- **VIDEO_READY**: Video is ready
|
||||
- **PACKAGING**: Segment is being packaged
|
||||
- **TS_READY**: TS file is ready
|
||||
- **PUBLISHED**: Segment is published and available
|
||||
- **FAILED**: Segment processing failed
|
||||
|
||||
### Fallback Cache Management for Templates and Jobs
|
||||
```java
|
||||
@Autowired
|
||||
private IntegrationFallbackService fallbackService;
|
||||
|
||||
// Check fallback cache status for templates
|
||||
boolean hasTemplateCache = fallbackService.hasFallbackCache("zt-render-worker", "template:1001");
|
||||
boolean hasTemplateSegmentsCache = fallbackService.hasFallbackCache("zt-render-worker", "template:segments:1001");
|
||||
|
||||
// Check fallback cache status for jobs
|
||||
boolean hasJobStatusCache = fallbackService.hasFallbackCache("zt-render-worker", "job:status:2001");
|
||||
boolean hasJobDetailCache = fallbackService.hasFallbackCache("zt-render-worker", "job:detail:2001");
|
||||
|
||||
// Get cache statistics
|
||||
IntegrationFallbackService.FallbackCacheStats stats =
|
||||
fallbackService.getFallbackCacheStats("zt-render-worker");
|
||||
log.info("Render fallback cache: {} items, TTL: {} days",
|
||||
stats.getTotalCacheCount(), stats.getFallbackTtlDays());
|
||||
|
||||
// Clear specific cache
|
||||
fallbackService.clearFallbackCache("zt-render-worker", "template:1001");
|
||||
fallbackService.clearFallbackCache("zt-render-worker", "job:status:2001");
|
||||
|
||||
// Clear all render worker caches
|
||||
fallbackService.clearAllFallbackCache("zt-render-worker");
|
||||
```
|
||||
|
||||
## ZT-Message Integration (Kafka Producer)
|
||||
|
||||
### Overview
|
||||
|
||||
@@ -12,6 +12,7 @@ import io.reactivex.rxjava3.core.Flowable;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -23,6 +24,7 @@ import java.util.function.Consumer;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Lazy
|
||||
public class GlmClientImpl implements GlmClient {
|
||||
|
||||
private static final String DEFAULT_MODEL = "glm-4.5-airx";
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ycwl.basic.integration.render.client;
|
||||
|
||||
import com.ycwl.basic.integration.common.response.CommonResponse;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.integration.render.dto.job.*;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 渲染作业V2客户端
|
||||
*/
|
||||
@FeignClient(name = "zt-render-worker", contextId = "render-job-v2", path = "/api/render/v2")
|
||||
public interface RenderJobV2Client {
|
||||
|
||||
// ==================== 小程序侧接口 ====================
|
||||
|
||||
/**
|
||||
* 创建预览作业
|
||||
*/
|
||||
@PostMapping("/preview")
|
||||
CommonResponse<CreatePreviewResponse> createPreview(@RequestBody CreatePreviewRequest request);
|
||||
|
||||
/**
|
||||
* 获取作业状态
|
||||
*/
|
||||
@GetMapping("/jobs/{jobId}")
|
||||
CommonResponse<JobStatusResponse> getJobStatus(@PathVariable("jobId") Long jobId);
|
||||
|
||||
/**
|
||||
* 获取HLS播放列表
|
||||
* 返回M3U8格式的文本内容
|
||||
*/
|
||||
@GetMapping("/jobs/{jobId}/index.m3u8")
|
||||
String getHlsPlaylist(@PathVariable("jobId") Long jobId);
|
||||
|
||||
/**
|
||||
* 获取播放列表信息
|
||||
*/
|
||||
@GetMapping("/jobs/{jobId}/playlist-info")
|
||||
CommonResponse<PlaylistInfoDTO> getPlaylistInfo(@PathVariable("jobId") Long jobId);
|
||||
|
||||
/**
|
||||
* 取消作业
|
||||
*/
|
||||
@PostMapping("/jobs/{jobId}/cancel")
|
||||
CommonResponse<Void> cancelJob(@PathVariable("jobId") Long jobId);
|
||||
|
||||
// ==================== 管理端接口 ====================
|
||||
|
||||
/**
|
||||
* 获取作业列表
|
||||
*/
|
||||
@GetMapping("/admin/jobs")
|
||||
CommonResponse<PageResponse<RenderJobV2DTO>> listJobs(
|
||||
@RequestParam(required = false) Long scenicId,
|
||||
@RequestParam(required = false) Long templateId,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String jobType,
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
@RequestParam(defaultValue = "20") Integer pageSize);
|
||||
|
||||
/**
|
||||
* 获取作业详情
|
||||
*/
|
||||
@GetMapping("/admin/jobs/{jobId}")
|
||||
CommonResponse<RenderJobV2DTO> getJobDetail(@PathVariable("jobId") Long jobId);
|
||||
|
||||
/**
|
||||
* 获取作业片段列表
|
||||
*/
|
||||
@GetMapping("/admin/jobs/{jobId}/segments")
|
||||
CommonResponse<List<RenderJobSegmentV2DTO>> getJobSegments(@PathVariable("jobId") Long jobId);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.ycwl.basic.integration.render.client;
|
||||
|
||||
import com.ycwl.basic.integration.common.response.CommonResponse;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.integration.render.dto.template.*;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 渲染模板V2客户端
|
||||
*/
|
||||
@FeignClient(name = "zt-render-worker", contextId = "render-template-v2", path = "/api/render/template/v2")
|
||||
public interface RenderTemplateV2Client {
|
||||
|
||||
// ==================== Template CRUD Operations ====================
|
||||
|
||||
/**
|
||||
* 创建模板
|
||||
*/
|
||||
@PostMapping
|
||||
CommonResponse<TemplateV2DTO> createTemplate(@RequestBody CreateTemplateRequest request);
|
||||
|
||||
/**
|
||||
* 获取模板列表
|
||||
*/
|
||||
@GetMapping
|
||||
CommonResponse<PageResponse<TemplateV2DTO>> listTemplates(
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
@RequestParam(defaultValue = "10") Integer pageSize,
|
||||
@RequestParam(required = false) Long scenicId,
|
||||
@RequestParam(required = false) Integer status,
|
||||
@RequestParam(required = false) String name);
|
||||
|
||||
/**
|
||||
* 获取模板详情
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
CommonResponse<TemplateV2DTO> getTemplate(@PathVariable("id") Long id);
|
||||
|
||||
/**
|
||||
* 获取模板及其片段
|
||||
*/
|
||||
@GetMapping("/{id}/with-segments")
|
||||
CommonResponse<TemplateV2WithSegmentsDTO> getTemplateWithSegments(@PathVariable("id") Long id);
|
||||
|
||||
/**
|
||||
* 更新模板
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
CommonResponse<Void> updateTemplate(@PathVariable("id") Long id,
|
||||
@RequestBody UpdateTemplateRequest request);
|
||||
|
||||
/**
|
||||
* 删除模板
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
CommonResponse<Void> deleteTemplate(@PathVariable("id") Long id);
|
||||
|
||||
// ==================== Template Operations ====================
|
||||
|
||||
/**
|
||||
* 发布模板
|
||||
*/
|
||||
@PostMapping("/{id}/publish")
|
||||
CommonResponse<Void> publishTemplate(@PathVariable("id") Long id);
|
||||
|
||||
/**
|
||||
* 创建新版本
|
||||
*/
|
||||
@PostMapping("/{id}/version")
|
||||
CommonResponse<TemplateV2DTO> createTemplateVersion(@PathVariable("id") Long id);
|
||||
|
||||
// ==================== Segment Management ====================
|
||||
|
||||
/**
|
||||
* 获取模板片段列表
|
||||
*/
|
||||
@GetMapping("/{id}/segments")
|
||||
CommonResponse<List<TemplateV2SegmentDTO>> getTemplateSegments(@PathVariable("id") Long id);
|
||||
|
||||
/**
|
||||
* 创建片段
|
||||
*/
|
||||
@PostMapping("/{id}/segments")
|
||||
CommonResponse<TemplateV2SegmentDTO> createSegment(@PathVariable("id") Long id,
|
||||
@RequestBody CreateSegmentRequest request);
|
||||
|
||||
/**
|
||||
* 更新片段
|
||||
*/
|
||||
@PutMapping("/{id}/segments/{segmentId}")
|
||||
CommonResponse<Void> updateSegment(@PathVariable("id") Long id,
|
||||
@PathVariable("segmentId") Long segmentId,
|
||||
@RequestBody UpdateSegmentRequest request);
|
||||
|
||||
/**
|
||||
* 删除片段
|
||||
*/
|
||||
@DeleteMapping("/{id}/segments/{segmentId}")
|
||||
CommonResponse<Void> deleteSegment(@PathVariable("id") Long id,
|
||||
@PathVariable("segmentId") Long segmentId);
|
||||
|
||||
/**
|
||||
* 替换所有片段
|
||||
*/
|
||||
@PostMapping("/{id}/segments/replace")
|
||||
CommonResponse<Void> replaceSegments(@PathVariable("id") Long id,
|
||||
@RequestBody ReplaceSegmentsRequest request);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ycwl.basic.integration.render.dto.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 音频参数DTO
|
||||
*/
|
||||
@Data
|
||||
public class AudioSpecDTO {
|
||||
|
||||
/**
|
||||
* 音频素材URL
|
||||
*/
|
||||
private String audioUrl;
|
||||
|
||||
/**
|
||||
* 音量 (0.0-1.0)
|
||||
*/
|
||||
private Double volume;
|
||||
|
||||
/**
|
||||
* 淡入时长(毫秒)
|
||||
*/
|
||||
private Integer fadeInMs;
|
||||
|
||||
/**
|
||||
* 淡出时长(毫秒)
|
||||
*/
|
||||
private Integer fadeOutMs;
|
||||
|
||||
/**
|
||||
* 音频开始位置(毫秒)
|
||||
*/
|
||||
private Integer startMs;
|
||||
|
||||
/**
|
||||
* 延迟播放(毫秒)
|
||||
*/
|
||||
private Integer delayMs;
|
||||
|
||||
/**
|
||||
* 是否循环
|
||||
*/
|
||||
private Boolean loopEnable;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.ycwl.basic.integration.render.dto.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 输出规格DTO
|
||||
*/
|
||||
@Data
|
||||
public class OutputSpecDTO {
|
||||
|
||||
/**
|
||||
* 宽度
|
||||
*/
|
||||
private Integer width;
|
||||
|
||||
/**
|
||||
* 高度
|
||||
*/
|
||||
private Integer height;
|
||||
|
||||
/**
|
||||
* 帧率
|
||||
*/
|
||||
private Integer fps;
|
||||
|
||||
/**
|
||||
* 比特率
|
||||
*/
|
||||
private Integer bitrate;
|
||||
|
||||
/**
|
||||
* 视频编解码器 (默认h264)
|
||||
*/
|
||||
private String codec;
|
||||
|
||||
/**
|
||||
* 音频编解码器 (默认aac)
|
||||
*/
|
||||
private String audioCodec;
|
||||
|
||||
/**
|
||||
* 采样率 (默认48000)
|
||||
*/
|
||||
private Integer sampleRate;
|
||||
|
||||
/**
|
||||
* 声道数 (默认2)
|
||||
*/
|
||||
private Integer channels;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ycwl.basic.integration.render.dto.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 渲染参数DTO
|
||||
*/
|
||||
@Data
|
||||
public class RenderSpecDTO {
|
||||
|
||||
/**
|
||||
* 是否启用人脸裁切
|
||||
*/
|
||||
private Boolean cropEnable;
|
||||
|
||||
/**
|
||||
* 裁切后大小
|
||||
*/
|
||||
private String cropSize;
|
||||
|
||||
/**
|
||||
* 倍速
|
||||
*/
|
||||
private String speed;
|
||||
|
||||
/**
|
||||
* 调色LUT文件URL
|
||||
*/
|
||||
private String lutUrl;
|
||||
|
||||
/**
|
||||
* 叠加蒙版URL
|
||||
*/
|
||||
private String overlayUrl;
|
||||
|
||||
/**
|
||||
* 特效配置
|
||||
*/
|
||||
private String effects;
|
||||
|
||||
/**
|
||||
* 是否缩放裁切
|
||||
*/
|
||||
private Boolean zoomCut;
|
||||
|
||||
/**
|
||||
* 竖屏切割位置
|
||||
*/
|
||||
private String videoCrop;
|
||||
|
||||
/**
|
||||
* 人脸位置参数
|
||||
*/
|
||||
private String facePos;
|
||||
|
||||
/**
|
||||
* 转场效果
|
||||
*/
|
||||
private String transitions;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 创建预览请求
|
||||
*/
|
||||
@Data
|
||||
public class CreatePreviewRequest {
|
||||
|
||||
/**
|
||||
* 模板ID (必填)
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 景区ID (必填)
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 人脸ID (可选)
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 会员ID (可选)
|
||||
*/
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 素材槽映射 (可选)
|
||||
* key: 槽位键
|
||||
* value: 素材列表
|
||||
*/
|
||||
private Map<String, List<MaterialDTO>> materialsBySlot;
|
||||
|
||||
/**
|
||||
* 自定义输出规格 (可选)
|
||||
*/
|
||||
private OutputSpecDTO outputSpec;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 创建预览响应
|
||||
*/
|
||||
@Data
|
||||
public class CreatePreviewResponse {
|
||||
|
||||
/**
|
||||
* 作业ID
|
||||
*/
|
||||
private Long jobId;
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 播放URL
|
||||
*/
|
||||
private String playUrl;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 作业状态响应
|
||||
*/
|
||||
@Data
|
||||
public class JobStatusResponse {
|
||||
|
||||
/**
|
||||
* 作业ID
|
||||
*/
|
||||
private Long jobId;
|
||||
|
||||
/**
|
||||
* 状态 (PENDING, RUNNING, SUCCESS, FAILED, CANCELED)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 进度 (0.0 - 100.0)
|
||||
*/
|
||||
private Double progress;
|
||||
|
||||
/**
|
||||
* 总片段数
|
||||
*/
|
||||
private Integer segmentCount;
|
||||
|
||||
/**
|
||||
* 已发布片段数
|
||||
*/
|
||||
private Integer publishedCount;
|
||||
|
||||
/**
|
||||
* 播放URL
|
||||
*/
|
||||
private String playUrl;
|
||||
|
||||
/**
|
||||
* MP4下载URL
|
||||
*/
|
||||
private String mp4Url;
|
||||
|
||||
/**
|
||||
* 错误码
|
||||
*/
|
||||
private String errorCode;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private String errorMessage;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 素材信息DTO
|
||||
*/
|
||||
@Data
|
||||
public class MaterialDTO {
|
||||
|
||||
/**
|
||||
* 素材URL
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 类型 (video, image)
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 时长(毫秒)
|
||||
*/
|
||||
private Long duration;
|
||||
|
||||
/**
|
||||
* 人脸分数
|
||||
*/
|
||||
private String score;
|
||||
|
||||
/**
|
||||
* 人脸位置
|
||||
*/
|
||||
private String facePos;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 播放列表信息DTO
|
||||
*/
|
||||
@Data
|
||||
public class PlaylistInfoDTO {
|
||||
|
||||
/**
|
||||
* 总片段数
|
||||
*/
|
||||
private Integer segmentCount;
|
||||
|
||||
/**
|
||||
* 进度 (0.0 - 100.0)
|
||||
*/
|
||||
private Double progress;
|
||||
|
||||
/**
|
||||
* 已发布片段数
|
||||
*/
|
||||
private Integer publishedCount;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
|
||||
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 渲染作业片段V2 DTO
|
||||
*/
|
||||
@Data
|
||||
public class RenderJobSegmentV2DTO {
|
||||
|
||||
/**
|
||||
* 片段ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 作业ID
|
||||
*/
|
||||
private Long jobId;
|
||||
|
||||
/**
|
||||
* 模板片段索引
|
||||
*/
|
||||
private Integer templateSegmentIndex;
|
||||
|
||||
/**
|
||||
* 计划片段索引
|
||||
*/
|
||||
private Integer planSegmentIndex;
|
||||
|
||||
/**
|
||||
* 开始时间(毫秒)
|
||||
*/
|
||||
private Long startTimeMs;
|
||||
|
||||
/**
|
||||
* 时长(毫秒)
|
||||
*/
|
||||
private Long durationMs;
|
||||
|
||||
/**
|
||||
* 片段类型 (RENDER, FIXED)
|
||||
*/
|
||||
private String segmentType;
|
||||
|
||||
/**
|
||||
* 状态 (PENDING, RENDERING, VIDEO_READY, PACKAGING, TS_READY, PUBLISHED, FAILED)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 素材来源类型
|
||||
*/
|
||||
private String sourceType;
|
||||
|
||||
/**
|
||||
* 素材来源引用
|
||||
*/
|
||||
private String sourceRef;
|
||||
|
||||
/**
|
||||
* 绑定素材URL
|
||||
*/
|
||||
private String boundMaterialUrl;
|
||||
|
||||
/**
|
||||
* 渲染参数JSON
|
||||
*/
|
||||
@JsonProperty("renderSpecJson")
|
||||
private RenderSpecDTO renderSpecJson;
|
||||
|
||||
/**
|
||||
* 音频参数JSON
|
||||
*/
|
||||
@JsonProperty("audioSpecJson")
|
||||
private AudioSpecDTO audioSpecJson;
|
||||
|
||||
/**
|
||||
* 视频URL
|
||||
*/
|
||||
private String videoUrl;
|
||||
|
||||
/**
|
||||
* TS文件URL
|
||||
*/
|
||||
private String tsUrl;
|
||||
|
||||
/**
|
||||
* TS时长(秒)
|
||||
*/
|
||||
private Double tsDuration;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonProperty("createTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonProperty("updateTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 渲染作业V2 DTO
|
||||
*/
|
||||
@Data
|
||||
public class RenderJobV2DTO {
|
||||
|
||||
/**
|
||||
* 作业ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 作业类型 (PREVIEW, PREVIEW_HLS, FINAL_MP4)
|
||||
*/
|
||||
private String jobType;
|
||||
|
||||
/**
|
||||
* 状态 (PENDING, RUNNING, SUCCESS, FAILED, CANCELED)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 模板ID
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 模板版本
|
||||
*/
|
||||
private Integer templateVersion;
|
||||
|
||||
/**
|
||||
* 人脸ID
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 会员ID
|
||||
*/
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 总时长(毫秒)
|
||||
*/
|
||||
private Long totalDurationMs;
|
||||
|
||||
/**
|
||||
* 输出规格JSON
|
||||
*/
|
||||
@JsonProperty("outputSpecJson")
|
||||
private Map<String, Object> outputSpecJson;
|
||||
|
||||
/**
|
||||
* 渲染计划JSON
|
||||
*/
|
||||
private String planJson;
|
||||
|
||||
/**
|
||||
* 总片段数
|
||||
*/
|
||||
private Integer segmentCount;
|
||||
|
||||
/**
|
||||
* 已发布片段数
|
||||
*/
|
||||
private Integer publishedCount;
|
||||
|
||||
/**
|
||||
* 已完成片段数
|
||||
*/
|
||||
private Integer completedCount;
|
||||
|
||||
/**
|
||||
* M3U8播放地址
|
||||
*/
|
||||
private String m3u8Url;
|
||||
|
||||
/**
|
||||
* 音频URL
|
||||
*/
|
||||
private String audioUrl;
|
||||
|
||||
/**
|
||||
* MP4下载地址
|
||||
*/
|
||||
private String mp4Url;
|
||||
|
||||
/**
|
||||
* 错误码
|
||||
*/
|
||||
private String errorCode;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private String errorMessage;
|
||||
|
||||
/**
|
||||
* 幂等键
|
||||
*/
|
||||
private String idempotencyKey;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
@JsonProperty("startTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date startTime;
|
||||
|
||||
/**
|
||||
* 完成时间
|
||||
*/
|
||||
@JsonProperty("finishTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date finishTime;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonProperty("createTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonProperty("updateTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
|
||||
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 创建片段请求
|
||||
*/
|
||||
@Data
|
||||
public class CreateSegmentRequest {
|
||||
|
||||
/**
|
||||
* 片段索引
|
||||
*/
|
||||
private Integer segmentIndex;
|
||||
|
||||
/**
|
||||
* 片段类型 (RENDER, FIXED)
|
||||
*/
|
||||
private String segmentType;
|
||||
|
||||
/**
|
||||
* 是否可缓存
|
||||
*/
|
||||
private Boolean cacheable;
|
||||
|
||||
/**
|
||||
* 位置是否固定
|
||||
*/
|
||||
private Boolean positionFixed;
|
||||
|
||||
/**
|
||||
* 素材来源类型 (ASSET, PLACEHOLDER_VIDEO, PLACEHOLDER_IMAGE, SLOT)
|
||||
*/
|
||||
private String sourceType;
|
||||
|
||||
/**
|
||||
* 素材来源引用
|
||||
*/
|
||||
private String sourceRef;
|
||||
|
||||
/**
|
||||
* 时长(毫秒)
|
||||
*/
|
||||
private Long durationMs;
|
||||
|
||||
/**
|
||||
* 转场类型
|
||||
*/
|
||||
private String transitionType;
|
||||
|
||||
/**
|
||||
* 转场时长(毫秒)
|
||||
*/
|
||||
private Integer transitionMs;
|
||||
|
||||
/**
|
||||
* 素材槽位键
|
||||
*/
|
||||
private String slotKey;
|
||||
|
||||
/**
|
||||
* 条件表达式
|
||||
*/
|
||||
private Map<String, Object> onlyIfExpr;
|
||||
|
||||
/**
|
||||
* 渲染参数
|
||||
*/
|
||||
private RenderSpecDTO renderSpec;
|
||||
|
||||
/**
|
||||
* 音频参数
|
||||
*/
|
||||
private AudioSpecDTO audioSpec;
|
||||
|
||||
/**
|
||||
* 扩展属性
|
||||
*/
|
||||
private Map<String, Object> extendedProps;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 创建模板请求
|
||||
*/
|
||||
@Data
|
||||
public class CreateTemplateRequest {
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 模板名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 缩略图URL
|
||||
*/
|
||||
private String thumbnailUrl;
|
||||
|
||||
/**
|
||||
* 默认时长(毫秒)
|
||||
*/
|
||||
private Long defaultDurationMs;
|
||||
|
||||
/**
|
||||
* 输出规格
|
||||
*/
|
||||
private OutputSpecDTO outputSpec;
|
||||
|
||||
/**
|
||||
* 背景音乐URL
|
||||
*/
|
||||
private String bgmUrl;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 替换所有片段请求
|
||||
*/
|
||||
@Data
|
||||
public class ReplaceSegmentsRequest {
|
||||
|
||||
/**
|
||||
* 片段列表
|
||||
*/
|
||||
private List<CreateSegmentRequest> segments;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 渲染模板V2 DTO
|
||||
*/
|
||||
@Data
|
||||
public class TemplateV2DTO {
|
||||
|
||||
/**
|
||||
* 模板ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 模板名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
private Integer version;
|
||||
|
||||
/**
|
||||
* 状态 (0-草稿, 1-已发布)
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 缩略图URL
|
||||
*/
|
||||
private String thumbnailUrl;
|
||||
|
||||
/**
|
||||
* 默认时长(毫秒)
|
||||
*/
|
||||
private Long defaultDurationMs;
|
||||
|
||||
/**
|
||||
* 输出规格
|
||||
*/
|
||||
private OutputSpecDTO outputSpec;
|
||||
|
||||
/**
|
||||
* 输出宽度
|
||||
*/
|
||||
private Integer outputWidth;
|
||||
|
||||
/**
|
||||
* 输出高度
|
||||
*/
|
||||
private Integer outputHeight;
|
||||
|
||||
/**
|
||||
* 输出帧率
|
||||
*/
|
||||
private Integer outputFps;
|
||||
|
||||
/**
|
||||
* 背景音乐URL
|
||||
*/
|
||||
private String bgmUrl;
|
||||
|
||||
/**
|
||||
* 背景音乐是否固定
|
||||
*/
|
||||
private Boolean bgmFixed;
|
||||
|
||||
/**
|
||||
* 封面URL
|
||||
*/
|
||||
private String coverUrl;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonProperty("createTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonProperty("updateTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date updateTime;
|
||||
|
||||
/**
|
||||
* 删除标记
|
||||
*/
|
||||
private Integer deleted;
|
||||
|
||||
/**
|
||||
* 删除时间
|
||||
*/
|
||||
@JsonProperty("deletedAt")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date deletedAt;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
|
||||
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 模板片段V2 DTO
|
||||
*/
|
||||
@Data
|
||||
public class TemplateV2SegmentDTO {
|
||||
|
||||
/**
|
||||
* 片段ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 模板ID
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 片段索引
|
||||
*/
|
||||
private Integer segmentIndex;
|
||||
|
||||
/**
|
||||
* 片段类型 (RENDER, FIXED)
|
||||
*/
|
||||
private String segmentType;
|
||||
|
||||
/**
|
||||
* 是否可缓存
|
||||
*/
|
||||
private Boolean cacheable;
|
||||
|
||||
/**
|
||||
* 位置是否固定
|
||||
*/
|
||||
private Boolean positionFixed;
|
||||
|
||||
/**
|
||||
* 时长(毫秒)
|
||||
*/
|
||||
private Long durationMs;
|
||||
|
||||
/**
|
||||
* 转场类型
|
||||
*/
|
||||
private String transitionType;
|
||||
|
||||
/**
|
||||
* 转场时长(毫秒)
|
||||
*/
|
||||
private Integer transitionMs;
|
||||
|
||||
/**
|
||||
* 素材槽位键
|
||||
*/
|
||||
private String slotKey;
|
||||
|
||||
/**
|
||||
* 条件表达式JSON
|
||||
*/
|
||||
@JsonProperty("onlyIfExprJson")
|
||||
private Map<String, Object> onlyIfExprJson;
|
||||
|
||||
/**
|
||||
* 素材来源类型 (ASSET, PLACEHOLDER_VIDEO, PLACEHOLDER_IMAGE, SLOT)
|
||||
*/
|
||||
private String sourceType;
|
||||
|
||||
/**
|
||||
* 素材来源引用
|
||||
*/
|
||||
private String sourceRef;
|
||||
|
||||
/**
|
||||
* 渲染参数JSON
|
||||
*/
|
||||
@JsonProperty("renderSpecJson")
|
||||
private RenderSpecDTO renderSpecJson;
|
||||
|
||||
/**
|
||||
* 音频参数JSON
|
||||
*/
|
||||
@JsonProperty("audioSpecJson")
|
||||
private AudioSpecDTO audioSpecJson;
|
||||
|
||||
/**
|
||||
* 扩展属性JSON
|
||||
*/
|
||||
@JsonProperty("extendedPropsJson")
|
||||
private Map<String, Object> extendedPropsJson;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonProperty("createTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonProperty("updateTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 模板及其片段响应DTO
|
||||
*/
|
||||
@Data
|
||||
public class TemplateV2WithSegmentsDTO {
|
||||
|
||||
/**
|
||||
* 模板信息
|
||||
*/
|
||||
private TemplateV2DTO template;
|
||||
|
||||
/**
|
||||
* 片段列表
|
||||
*/
|
||||
private List<TemplateV2SegmentDTO> segments;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
|
||||
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 更新片段请求
|
||||
*/
|
||||
@Data
|
||||
public class UpdateSegmentRequest {
|
||||
|
||||
/**
|
||||
* 片段索引
|
||||
*/
|
||||
private Integer segmentIndex;
|
||||
|
||||
/**
|
||||
* 片段类型 (RENDER, FIXED)
|
||||
*/
|
||||
private String segmentType;
|
||||
|
||||
/**
|
||||
* 是否可缓存
|
||||
*/
|
||||
private Boolean cacheable;
|
||||
|
||||
/**
|
||||
* 素材来源类型 (ASSET, PLACEHOLDER_VIDEO, PLACEHOLDER_IMAGE, SLOT)
|
||||
*/
|
||||
private String sourceType;
|
||||
|
||||
/**
|
||||
* 素材来源引用
|
||||
*/
|
||||
private String sourceRef;
|
||||
|
||||
/**
|
||||
* 时长(毫秒)
|
||||
*/
|
||||
private Long durationMs;
|
||||
|
||||
/**
|
||||
* 转场类型
|
||||
*/
|
||||
private String transitionType;
|
||||
|
||||
/**
|
||||
* 转场时长(毫秒)
|
||||
*/
|
||||
private Integer transitionMs;
|
||||
|
||||
/**
|
||||
* 素材槽位键
|
||||
*/
|
||||
private String slotKey;
|
||||
|
||||
/**
|
||||
* 条件表达式
|
||||
*/
|
||||
private Map<String, Object> onlyIfExpr;
|
||||
|
||||
/**
|
||||
* 渲染参数
|
||||
*/
|
||||
private RenderSpecDTO renderSpec;
|
||||
|
||||
/**
|
||||
* 音频参数
|
||||
*/
|
||||
private AudioSpecDTO audioSpec;
|
||||
|
||||
/**
|
||||
* 扩展属性
|
||||
*/
|
||||
private Map<String, Object> extendedProps;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 更新模板请求
|
||||
*/
|
||||
@Data
|
||||
public class UpdateTemplateRequest {
|
||||
|
||||
/**
|
||||
* 模板名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 缩略图URL
|
||||
*/
|
||||
private String thumbnailUrl;
|
||||
|
||||
/**
|
||||
* 默认时长(毫秒)
|
||||
*/
|
||||
private Long defaultDurationMs;
|
||||
|
||||
/**
|
||||
* 输出规格
|
||||
*/
|
||||
private OutputSpecDTO outputSpec;
|
||||
|
||||
/**
|
||||
* 背景音乐URL
|
||||
*/
|
||||
private String bgmUrl;
|
||||
|
||||
/**
|
||||
* 背景音乐是否固定
|
||||
*/
|
||||
private Boolean bgmFixed;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.ycwl.basic.integration.render.service;
|
||||
|
||||
import com.ycwl.basic.integration.common.exception.IntegrationException;
|
||||
import com.ycwl.basic.integration.common.response.CommonResponse;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
|
||||
import com.ycwl.basic.integration.render.client.RenderJobV2Client;
|
||||
import com.ycwl.basic.integration.render.dto.job.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 渲染作业集成服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RenderJobIntegrationService {
|
||||
|
||||
private final RenderJobV2Client renderJobV2Client;
|
||||
private final IntegrationFallbackService fallbackService;
|
||||
|
||||
private static final String SERVICE_NAME = "zt-render-worker";
|
||||
|
||||
// ==================== 小程序侧接口 ====================
|
||||
|
||||
/**
|
||||
* 创建预览作业(直接调用,不降级)
|
||||
*/
|
||||
public CreatePreviewResponse createPreview(CreatePreviewRequest request) {
|
||||
log.debug("创建预览作业, templateId: {}, scenicId: {}", request.getTemplateId(), request.getScenicId());
|
||||
CommonResponse<CreatePreviewResponse> response = renderJobV2Client.createPreview(request);
|
||||
return handleResponse(response, "创建预览作业失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作业状态(带降级)
|
||||
*/
|
||||
public JobStatusResponse getJobStatus(Long jobId) {
|
||||
log.debug("获取作业状态, jobId: {}", jobId);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"job:status:" + jobId,
|
||||
() -> {
|
||||
CommonResponse<JobStatusResponse> response = renderJobV2Client.getJobStatus(jobId);
|
||||
return handleResponse(response, "获取作业状态失败");
|
||||
},
|
||||
JobStatusResponse.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取HLS播放列表
|
||||
* 返回M3U8格式的文本内容(不降级)
|
||||
*/
|
||||
public String getHlsPlaylist(Long jobId) {
|
||||
log.debug("获取HLS播放列表, jobId: {}", jobId);
|
||||
try {
|
||||
return renderJobV2Client.getHlsPlaylist(jobId);
|
||||
} catch (Exception e) {
|
||||
log.error("获取HLS播放列表失败, jobId: {}", jobId, e);
|
||||
throw new IntegrationException(5000, "获取HLS播放列表失败: " + e.getMessage(), SERVICE_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取播放列表信息(带降级)
|
||||
*/
|
||||
public PlaylistInfoDTO getPlaylistInfo(Long jobId) {
|
||||
log.debug("获取播放列表信息, jobId: {}", jobId);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"job:playlist-info:" + jobId,
|
||||
() -> {
|
||||
CommonResponse<PlaylistInfoDTO> response = renderJobV2Client.getPlaylistInfo(jobId);
|
||||
return handleResponse(response, "获取播放列表信息失败");
|
||||
},
|
||||
PlaylistInfoDTO.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消作业(直接调用,不降级)
|
||||
*/
|
||||
public void cancelJob(Long jobId) {
|
||||
log.debug("取消作业, jobId: {}", jobId);
|
||||
CommonResponse<Void> response = renderJobV2Client.cancelJob(jobId);
|
||||
handleVoidResponse(response, "取消作业失败");
|
||||
}
|
||||
|
||||
// ==================== 管理端接口 ====================
|
||||
|
||||
/**
|
||||
* 获取作业列表(不降级)
|
||||
*/
|
||||
public PageResponse<RenderJobV2DTO> listJobs(Long scenicId, Long templateId, String status,
|
||||
String jobType, Integer page, Integer pageSize) {
|
||||
log.debug("查询作业列表, scenicId: {}, templateId: {}, status: {}, jobType: {}, page: {}, pageSize: {}",
|
||||
scenicId, templateId, status, jobType, page, pageSize);
|
||||
CommonResponse<PageResponse<RenderJobV2DTO>> response =
|
||||
renderJobV2Client.listJobs(scenicId, templateId, status, jobType, page, pageSize);
|
||||
return handleResponse(response, "查询作业列表失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作业详情(带降级)
|
||||
*/
|
||||
public RenderJobV2DTO getJobDetail(Long jobId) {
|
||||
log.debug("获取作业详情, jobId: {}", jobId);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"job:detail:" + jobId,
|
||||
() -> {
|
||||
CommonResponse<RenderJobV2DTO> response = renderJobV2Client.getJobDetail(jobId);
|
||||
return handleResponse(response, "获取作业详情失败");
|
||||
},
|
||||
RenderJobV2DTO.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作业片段列表(带降级)
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<RenderJobSegmentV2DTO> getJobSegments(Long jobId) {
|
||||
log.debug("获取作业片段列表, jobId: {}", jobId);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"job:segments:" + jobId,
|
||||
() -> {
|
||||
CommonResponse<List<RenderJobSegmentV2DTO>> response =
|
||||
renderJobV2Client.getJobSegments(jobId);
|
||||
return handleResponse(response, "获取作业片段列表失败");
|
||||
},
|
||||
(Class<List<RenderJobSegmentV2DTO>>) (Class<?>) List.class
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* 处理通用响应
|
||||
*/
|
||||
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
|
||||
if (response == null || !response.getSuccess()) {
|
||||
String msg = response != null && response.getMessage() != null ?
|
||||
response.getMessage() : errorMessage;
|
||||
Integer code = response != null ? response.getCode() : 5000;
|
||||
throw new IntegrationException(code, msg, SERVICE_NAME);
|
||||
}
|
||||
return response.getData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理空响应
|
||||
*/
|
||||
private void handleVoidResponse(CommonResponse<Void> response, String errorMessage) {
|
||||
if (response == null || !response.getSuccess()) {
|
||||
String msg = response != null && response.getMessage() != null ?
|
||||
response.getMessage() : errorMessage;
|
||||
Integer code = response != null ? response.getCode() : 5000;
|
||||
throw new IntegrationException(code, msg, SERVICE_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.ycwl.basic.integration.render.service;
|
||||
|
||||
import com.ycwl.basic.integration.common.exception.IntegrationException;
|
||||
import com.ycwl.basic.integration.common.response.CommonResponse;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
|
||||
import com.ycwl.basic.integration.render.client.RenderTemplateV2Client;
|
||||
import com.ycwl.basic.integration.render.dto.template.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 渲染模板集成服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RenderTemplateIntegrationService {
|
||||
|
||||
private final RenderTemplateV2Client renderTemplateV2Client;
|
||||
private final IntegrationFallbackService fallbackService;
|
||||
|
||||
private static final String SERVICE_NAME = "zt-render-worker";
|
||||
|
||||
// ==================== Template CRUD Operations ====================
|
||||
|
||||
/**
|
||||
* 创建模板(直接调用,不降级)
|
||||
*/
|
||||
public TemplateV2DTO createTemplate(CreateTemplateRequest request) {
|
||||
log.debug("创建渲染模板, scenicId: {}, name: {}", request.getScenicId(), request.getName());
|
||||
CommonResponse<TemplateV2DTO> response = renderTemplateV2Client.createTemplate(request);
|
||||
return handleResponse(response, "创建渲染模板失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板列表(不降级)
|
||||
*/
|
||||
public PageResponse<TemplateV2DTO> listTemplates(Integer page, Integer pageSize, Long scenicId,
|
||||
Integer status, String name) {
|
||||
log.debug("查询渲染模板列表, page: {}, pageSize: {}, scenicId: {}, status: {}, name: {}",
|
||||
page, pageSize, scenicId, status, name);
|
||||
CommonResponse<PageResponse<TemplateV2DTO>> response =
|
||||
renderTemplateV2Client.listTemplates(page, pageSize, scenicId, status, name);
|
||||
return handleResponse(response, "查询渲染模板列表失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板详情(带降级)
|
||||
*/
|
||||
public TemplateV2DTO getTemplate(Long id) {
|
||||
log.debug("获取渲染模板信息, id: {}", id);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"template:" + id,
|
||||
() -> {
|
||||
CommonResponse<TemplateV2DTO> response = renderTemplateV2Client.getTemplate(id);
|
||||
return handleResponse(response, "获取渲染模板信息失败");
|
||||
},
|
||||
TemplateV2DTO.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板及其片段(带降级)
|
||||
*/
|
||||
public TemplateV2WithSegmentsDTO getTemplateWithSegments(Long id) {
|
||||
log.debug("获取渲染模板及片段, id: {}", id);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"template:with-segments:" + id,
|
||||
() -> {
|
||||
CommonResponse<TemplateV2WithSegmentsDTO> response =
|
||||
renderTemplateV2Client.getTemplateWithSegments(id);
|
||||
return handleResponse(response, "获取渲染模板及片段失败");
|
||||
},
|
||||
TemplateV2WithSegmentsDTO.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板(直接调用,不降级)
|
||||
*/
|
||||
public void updateTemplate(Long id, UpdateTemplateRequest request) {
|
||||
log.debug("更新渲染模板, id: {}, name: {}", id, request.getName());
|
||||
CommonResponse<Void> response = renderTemplateV2Client.updateTemplate(id, request);
|
||||
handleVoidResponse(response, "更新渲染模板失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模板(直接调用,不降级)
|
||||
*/
|
||||
public void deleteTemplate(Long id) {
|
||||
log.debug("删除渲染模板, id: {}", id);
|
||||
CommonResponse<Void> response = renderTemplateV2Client.deleteTemplate(id);
|
||||
handleVoidResponse(response, "删除渲染模板失败");
|
||||
}
|
||||
|
||||
// ==================== Template Operations ====================
|
||||
|
||||
/**
|
||||
* 发布模板(直接调用,不降级)
|
||||
*/
|
||||
public void publishTemplate(Long id) {
|
||||
log.debug("发布渲染模板, id: {}", id);
|
||||
CommonResponse<Void> response = renderTemplateV2Client.publishTemplate(id);
|
||||
handleVoidResponse(response, "发布渲染模板失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新版本(直接调用,不降级)
|
||||
*/
|
||||
public TemplateV2DTO createTemplateVersion(Long id) {
|
||||
log.debug("创建渲染模板新版本, id: {}", id);
|
||||
CommonResponse<TemplateV2DTO> response = renderTemplateV2Client.createTemplateVersion(id);
|
||||
return handleResponse(response, "创建渲染模板新版本失败");
|
||||
}
|
||||
|
||||
// ==================== Segment Management ====================
|
||||
|
||||
/**
|
||||
* 获取模板片段列表(带降级)
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<TemplateV2SegmentDTO> getTemplateSegments(Long id) {
|
||||
log.debug("获取渲染模板片段列表, templateId: {}", id);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"template:segments:" + id,
|
||||
() -> {
|
||||
CommonResponse<List<TemplateV2SegmentDTO>> response =
|
||||
renderTemplateV2Client.getTemplateSegments(id);
|
||||
return handleResponse(response, "获取渲染模板片段列表失败");
|
||||
},
|
||||
(Class<List<TemplateV2SegmentDTO>>) (Class<?>) List.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建片段(直接调用,不降级)
|
||||
*/
|
||||
public TemplateV2SegmentDTO createSegment(Long templateId, CreateSegmentRequest request) {
|
||||
log.debug("创建模板片段, templateId: {}, segmentIndex: {}", templateId, request.getSegmentIndex());
|
||||
CommonResponse<TemplateV2SegmentDTO> response =
|
||||
renderTemplateV2Client.createSegment(templateId, request);
|
||||
return handleResponse(response, "创建模板片段失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新片段(直接调用,不降级)
|
||||
*/
|
||||
public void updateSegment(Long templateId, Long segmentId, UpdateSegmentRequest request) {
|
||||
log.debug("更新模板片段, templateId: {}, segmentId: {}", templateId, segmentId);
|
||||
CommonResponse<Void> response =
|
||||
renderTemplateV2Client.updateSegment(templateId, segmentId, request);
|
||||
handleVoidResponse(response, "更新模板片段失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除片段(直接调用,不降级)
|
||||
*/
|
||||
public void deleteSegment(Long templateId, Long segmentId) {
|
||||
log.debug("删除模板片段, templateId: {}, segmentId: {}", templateId, segmentId);
|
||||
CommonResponse<Void> response = renderTemplateV2Client.deleteSegment(templateId, segmentId);
|
||||
handleVoidResponse(response, "删除模板片段失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换所有片段(直接调用,不降级)
|
||||
*/
|
||||
public void replaceSegments(Long templateId, ReplaceSegmentsRequest request) {
|
||||
log.debug("替换所有模板片段, templateId: {}, segmentCount: {}",
|
||||
templateId, request.getSegments() != null ? request.getSegments().size() : 0);
|
||||
CommonResponse<Void> response = renderTemplateV2Client.replaceSegments(templateId, request);
|
||||
handleVoidResponse(response, "替换所有模板片段失败");
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* 处理通用响应
|
||||
*/
|
||||
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
|
||||
if (response == null || !response.getSuccess()) {
|
||||
String msg = response != null && response.getMessage() != null ?
|
||||
response.getMessage() : errorMessage;
|
||||
Integer code = response != null ? response.getCode() : 5000;
|
||||
throw new IntegrationException(code, msg, SERVICE_NAME);
|
||||
}
|
||||
return response.getData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理空响应
|
||||
*/
|
||||
private void handleVoidResponse(CommonResponse<Void> response, String errorMessage) {
|
||||
if (response == null || !response.getSuccess()) {
|
||||
String msg = response != null && response.getMessage() != null ?
|
||||
response.getMessage() : errorMessage;
|
||||
Integer code = response != null ? response.getCode() : 5000;
|
||||
throw new IntegrationException(code, msg, SERVICE_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import com.ycwl.basic.model.pc.broker.resp.BrokerRecordRespVO;
|
||||
import com.ycwl.basic.model.pc.broker.resp.DailySummaryRespVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -28,4 +31,11 @@ public interface BrokerRecordMapper {
|
||||
int update(BrokerRecord brokerRecord);
|
||||
|
||||
List<DailySummaryRespVO> getDailySummaryByBrokerId(Long brokerId, Date startTime, Date endTime);
|
||||
|
||||
/**
|
||||
* 按日期统计分销员订单数据(不含扫码统计,已迁移到 ClickHouse)
|
||||
*/
|
||||
List<HashMap<String, Object>> getDailyOrderStats(@Param("brokerId") Long brokerId,
|
||||
@Param("startTime") Date startTime,
|
||||
@Param("endTime") Date endTime);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.coupon.req.CouponQueryReq;
|
||||
import com.ycwl.basic.model.pc.coupon.resp.CouponRespVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CouponMapper {
|
||||
List<CouponRespVO> selectByQuery(CouponQueryReq query);
|
||||
|
||||
int updateStatus(Integer id);
|
||||
|
||||
CouponEntity getById(Integer couponId);
|
||||
|
||||
int insert(CouponEntity coupon);
|
||||
|
||||
int updateById(CouponEntity coupon);
|
||||
|
||||
int deleteById(Integer id);
|
||||
|
||||
List<CouponEntity> selectList();
|
||||
|
||||
CouponEntity selectById(Integer id);
|
||||
|
||||
CouponEntity selectByScenicIdAndTypeAndStatus(Long scenicId, Integer type, Integer status);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
|
||||
import com.ycwl.basic.model.pc.couponRecord.req.CouponRecordPageQueryReq;
|
||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordPageResp;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CouponRecordMapper extends BaseMapper<CouponRecordEntity> {
|
||||
List<CouponRecordEntity> queryByUserWithGoodsId(Long scenicId, Long memberId, String goodsId);
|
||||
|
||||
List<CouponRecordEntity> queryByMemberIdAndFaceId(Long memberId, Long faceId);
|
||||
|
||||
CouponRecordEntity queryByMemberIdAndFaceIdAndType(Long memberId, Long faceId, Integer type);
|
||||
|
||||
List<CouponRecordPageResp> selectByPageQuery(CouponRecordPageQueryReq query);
|
||||
}
|
||||
@@ -173,4 +173,12 @@ public interface SourceMapper {
|
||||
* @return 免费记录数
|
||||
*/
|
||||
int countFreeRelationsByFaceIdAndType(Long faceId, Integer type);
|
||||
|
||||
/**
|
||||
* 根据faceId和type直接查询关联的source列表(避免N+1查询)
|
||||
* @param faceId 人脸ID
|
||||
* @param type 素材类型
|
||||
* @return source实体列表
|
||||
*/
|
||||
List<SourceEntity> listSourceByFaceRelation(Long faceId, Integer type);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.ycwl.basic.model.mobile.statistic.req.CommonQueryReq;
|
||||
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
|
||||
import com.ycwl.basic.model.mobile.statistic.resp.AppStatisticsFunnelVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
@@ -104,10 +105,48 @@ public interface StatisticsMapper {
|
||||
|
||||
List<HashMap<String, String>> orderChartByHour(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按小时统计扫码人数(仅统计数据,不含订单)
|
||||
*/
|
||||
List<HashMap<String, String>> scanCodeMemberChartByHour(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按日期统计扫码人数(仅统计数据,不含订单)
|
||||
*/
|
||||
List<HashMap<String, String>> scanCodeMemberChartByDate(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按小时统计访问打印样片页面人数
|
||||
*/
|
||||
List<HashMap<String, String>> printerFromSampleChartByHour(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按日期统计访问打印样片页面人数
|
||||
*/
|
||||
List<HashMap<String, String>> printerFromSampleChartByDate(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按小时统计订单数据
|
||||
*/
|
||||
List<HashMap<String, String>> orderChartByHourForMerge(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 按日期统计订单数据
|
||||
*/
|
||||
List<HashMap<String, String>> orderChartByDateForMerge(CommonQueryReq query);
|
||||
|
||||
/**
|
||||
* 统计分销员扫码次数
|
||||
*/
|
||||
Integer countBrokerScanCount(Long brokerId);
|
||||
|
||||
/**
|
||||
* 按日期统计分销员扫码数据
|
||||
*/
|
||||
List<HashMap<String, Object>> getDailyScanStats(@Param("brokerId") Long brokerId,
|
||||
@Param("startTime") Date startTime,
|
||||
@Param("endTime") Date endTime);
|
||||
|
||||
/**
|
||||
* 统计订单数量和金额(包含推送订单和现场订单)
|
||||
* @param query
|
||||
|
||||
@@ -59,4 +59,16 @@ public interface TaskMapper {
|
||||
List<TaskEntity> selectAllFailed();
|
||||
|
||||
TaskEntity listLastFaceTemplateTask(Long faceId, Long templateId);
|
||||
|
||||
/**
|
||||
* 根据 face_id 列表统计已完成任务的用户数
|
||||
* 用于 ClickHouse 迁移后的跨库统计
|
||||
*/
|
||||
Integer countCompletedTaskMembersByFaceIds(@Param("faceIds") List<String> faceIds);
|
||||
|
||||
/**
|
||||
* 根据 face_id 列表统计已完成任务数
|
||||
* 用于 ClickHouse 迁移后的跨库统计
|
||||
*/
|
||||
Integer countCompletedTasksByFaceIds(@Param("faceIds") List<String> faceIds);
|
||||
}
|
||||
|
||||
@@ -89,4 +89,18 @@ public interface UserNotificationAuthorizationMapper extends BaseMapper<UserNoti
|
||||
@Param("templateId") String templateId,
|
||||
@Param("scenicId") Long scenicId
|
||||
);
|
||||
|
||||
/**
|
||||
* 批量查询用户对多个模板的授权记录
|
||||
*
|
||||
* @param memberId 用户ID
|
||||
* @param templateIds 模板ID列表
|
||||
* @param scenicId 景区ID
|
||||
* @return 授权记录列表
|
||||
*/
|
||||
List<UserNotificationAuthorizationEntity> selectBatchByTemplateIds(
|
||||
@Param("memberId") Long memberId,
|
||||
@Param("templateIds") List<String> templateIds,
|
||||
@Param("scenicId") Long scenicId
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationRecordEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 用户订阅消息授权明细Mapper(幂等)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Mapper
|
||||
public interface UserNotificationAuthorizationRecordMapper extends BaseMapper<UserNotificationAuthorizationRecordEntity> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 微信订阅消息事件模板映射Mapper
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Mapper
|
||||
public interface WechatSubscribeEventTemplateMapper extends BaseMapper<WechatSubscribeEventTemplateEntity> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 微信订阅消息场景模板映射Mapper
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Mapper
|
||||
public interface WechatSubscribeSceneTemplateMapper extends BaseMapper<WechatSubscribeSceneTemplateEntity> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 微信订阅消息发送日志Mapper
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Mapper
|
||||
public interface WechatSubscribeSendLogMapper extends BaseMapper<WechatSubscribeSendLogEntity> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板配置Mapper
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Mapper
|
||||
public interface WechatSubscribeTemplateConfigMapper extends BaseMapper<WechatSubscribeTemplateConfigEntity> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ycwl.basic.model.mobile.notify.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 批量查询用户授权余额请求
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2026/01/10
|
||||
*/
|
||||
@Data
|
||||
public class BatchRemainingCountReq {
|
||||
|
||||
/**
|
||||
* 通知模板ID列表(微信 wechatTemplateId)
|
||||
*/
|
||||
@NotEmpty(message = "模板ID列表不能为空")
|
||||
private List<String> templateIds;
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
@NotNull(message = "景区ID不能为空")
|
||||
private Long scenicId;
|
||||
}
|
||||
@@ -26,4 +26,13 @@ public class NotificationAuthRecordReq {
|
||||
*/
|
||||
@NotNull(message = "景区ID不能为空")
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 前端幂等ID(可选)
|
||||
* <p>
|
||||
* 目的:避免前端重试导致授权次数虚增。
|
||||
* 同一次用户授权动作(一次 requestSubscribeMessage)建议复用同一个 requestId。
|
||||
* </p>
|
||||
*/
|
||||
private String requestId;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ycwl.basic.model.mobile.notify.resp;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 景区所有场景及其订阅消息模板列表(静态配置,不含用户授权信息)
|
||||
* 用户授权信息通过 /api/mobile/notify/auth/batch-remaining 接口获取
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2026/01/10
|
||||
*/
|
||||
@Data
|
||||
public class WechatSubscribeAllScenesResp {
|
||||
|
||||
private Long scenicId;
|
||||
|
||||
private List<SceneWithTemplates> scenes;
|
||||
|
||||
@Data
|
||||
public static class SceneWithTemplates {
|
||||
/**
|
||||
* 场景标识
|
||||
*/
|
||||
private String sceneKey;
|
||||
|
||||
/**
|
||||
* 该场景下的模板列表
|
||||
*/
|
||||
private List<StaticTemplateInfo> templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态模板信息(不含用户授权信息,可缓存)
|
||||
*/
|
||||
@Data
|
||||
public static class StaticTemplateInfo {
|
||||
/**
|
||||
* 逻辑模板键(业务固定)
|
||||
*/
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板ID(tmplId)
|
||||
*/
|
||||
private String wechatTemplateId;
|
||||
|
||||
/**
|
||||
* 前端展示标题
|
||||
*/
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 前端展示描述
|
||||
*/
|
||||
private String description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ycwl.basic.model.mobile.notify.resp;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 场景可申请的订阅消息模板列表(含用户授权余额)
|
||||
*
|
||||
* @Author: System
|
||||
* @Date: 2025/12/31
|
||||
*/
|
||||
@Data
|
||||
public class WechatSubscribeSceneTemplatesResp {
|
||||
|
||||
private Long scenicId;
|
||||
|
||||
private String sceneKey;
|
||||
|
||||
private List<TemplateInfo> templates;
|
||||
|
||||
@Data
|
||||
public static class TemplateInfo {
|
||||
/**
|
||||
* 逻辑模板键(业务固定)
|
||||
*/
|
||||
private String templateKey;
|
||||
|
||||
/**
|
||||
* 微信订阅消息模板ID(tmplId)
|
||||
*/
|
||||
private String wechatTemplateId;
|
||||
|
||||
/**
|
||||
* 前端展示标题
|
||||
*/
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 前端展示描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 用户剩余授权次数
|
||||
*/
|
||||
private Integer remainingCount;
|
||||
|
||||
/**
|
||||
* 是否有授权(remainingCount > 0)
|
||||
*/
|
||||
private Boolean hasAuthorization;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ycwl.basic.model.mobile.scenic.content;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 景区模板内容响应对象
|
||||
* 用于返回景区内的模板基础信息(与 faceId 无关)
|
||||
*/
|
||||
@Data
|
||||
public class ScenicTemplateContentVO {
|
||||
/**
|
||||
* 商品类型
|
||||
*/
|
||||
private Integer goodsType;
|
||||
|
||||
/**
|
||||
* 模板名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 模板分组
|
||||
*/
|
||||
private String group;
|
||||
|
||||
/**
|
||||
* 模板ID
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 模板封面URL
|
||||
*/
|
||||
private String templateCoverUrl;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.coupon.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("coupon")
|
||||
public class CouponEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Integer id;
|
||||
private Long scenicId;
|
||||
|
||||
// 新增优惠券名称字段
|
||||
private String name;
|
||||
|
||||
// 优惠券描述
|
||||
private String description;
|
||||
|
||||
// 倒计时字段(仅用于展示)
|
||||
private String countdown;
|
||||
|
||||
// 广播字段,仅用于展示
|
||||
private String broadcast;
|
||||
|
||||
/**
|
||||
* 优惠券类别,0:普通优惠券;1:第一次推送;2:第二次;3:第三次
|
||||
*/
|
||||
private Integer type;
|
||||
/**
|
||||
* 价格配置ID,逗号分隔字符串
|
||||
*/
|
||||
private String configIds;
|
||||
/**
|
||||
* 0降价,1打折
|
||||
*/
|
||||
private Integer discountType;
|
||||
private BigDecimal discountPrice;
|
||||
/**
|
||||
* 状态:0不开启;1开启
|
||||
*/
|
||||
private Integer status;
|
||||
private Date createAt;
|
||||
|
||||
private Integer deleted;
|
||||
private Date deletedAt;
|
||||
|
||||
public BigDecimal calculateDiscountPrice(BigDecimal originalPrice) {
|
||||
if (originalPrice == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
if (discountType == 0) {
|
||||
return discountPrice;
|
||||
} else {
|
||||
return originalPrice.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_DOWN).multiply(discountPrice);
|
||||
}
|
||||
}
|
||||
public BigDecimal calculateDiscountPrice(String originalPrice) {
|
||||
if (originalPrice == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
BigDecimal priceObj = new BigDecimal(originalPrice);
|
||||
if (discountType == 0) {
|
||||
return discountPrice;
|
||||
} else {
|
||||
return priceObj.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_DOWN).multiply(discountPrice);
|
||||
}
|
||||
}
|
||||
public String calculateDiscountedPrice(String originalPrice) {
|
||||
if (originalPrice == null) {
|
||||
return "0.00";
|
||||
}
|
||||
BigDecimal priceObj = new BigDecimal(originalPrice);
|
||||
if (discountType == 0) {
|
||||
return priceObj.subtract(discountPrice).setScale(2, RoundingMode.HALF_DOWN).toString();
|
||||
} else {
|
||||
return priceObj.subtract(priceObj.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_DOWN).multiply(discountPrice)).setScale(2, RoundingMode.HALF_DOWN).toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.coupon.req;
|
||||
|
||||
import lombok.Data;
|
||||
import com.ycwl.basic.model.common.BaseQueryParameterReq;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
// 优惠券查询请求参数
|
||||
public class CouponQueryReq extends BaseQueryParameterReq {
|
||||
// 景区ID
|
||||
private Long scenicId;
|
||||
private String name;
|
||||
|
||||
// 优惠券类型:0普通/1首次推送/2二次/3三次
|
||||
private Integer type;
|
||||
|
||||
// 折扣类型:0降价/1打折
|
||||
private Integer discountType;
|
||||
|
||||
// 状态:0关闭/1开启
|
||||
private Integer status;
|
||||
|
||||
// 创建时间起始
|
||||
private Date createAtStart;
|
||||
|
||||
// 创建时间结束
|
||||
private Date createAtEnd;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.coupon.resp;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class CouponRespVO {
|
||||
private Integer id;
|
||||
private Long scenicId;
|
||||
private String scenicName;
|
||||
|
||||
// 新增优惠券名称字段
|
||||
private String name;
|
||||
|
||||
// 优惠券描述
|
||||
private String description;
|
||||
|
||||
// 倒计时字段(仅用于展示)
|
||||
private String countdown;
|
||||
|
||||
// 通知展示字段,仅用于展示
|
||||
private String broadcast;
|
||||
|
||||
/**
|
||||
* 优惠券类别,0:普通优惠券;1:第一次推送;2:第二次;3:第三次
|
||||
*/
|
||||
private Integer type;
|
||||
/**
|
||||
* 价格配置ID,逗号分隔字符串
|
||||
*/
|
||||
private String configIds;
|
||||
/**
|
||||
* 0降价,1打折
|
||||
*/
|
||||
private Integer discountType;
|
||||
private BigDecimal discountPrice;
|
||||
/**
|
||||
* 状态:0不开启;1开启
|
||||
*/
|
||||
private Integer status;
|
||||
private Date createAt;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.couponRecord.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("coupon_record")
|
||||
public class CouponRecordEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Integer id;
|
||||
private Integer couponId;
|
||||
private Long memberId;
|
||||
private Long faceId;
|
||||
private Integer status;
|
||||
private Date createTime;
|
||||
private Date usedTime;
|
||||
private Long usedOrderId;
|
||||
|
||||
private Integer deleted;
|
||||
private Date deletedAt;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.couponRecord.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CouponRecordPageQueryReq {
|
||||
private Integer pageNum = 1;
|
||||
private Integer pageSize = 10;
|
||||
private Long scenicId;
|
||||
private String couponName;
|
||||
private Integer couponType;
|
||||
private Integer status;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.couponRecord.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CouponRecordUserQueryReq {
|
||||
private Long scenicId;
|
||||
private Long memberId;
|
||||
private Long faceId;
|
||||
private Integer couponType;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.couponRecord.resp;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class CouponRecordPageResp {
|
||||
private Integer id;
|
||||
private Integer couponId;
|
||||
private String couponName;
|
||||
private Integer couponType;
|
||||
private String couponTypeName;
|
||||
private Long scenicId;
|
||||
private String scenicName;
|
||||
private Long memberId;
|
||||
private Long faceId;
|
||||
private Integer status;
|
||||
private String statusName;
|
||||
private Date createTime;
|
||||
private Date usedTime;
|
||||
private Long usedOrderId;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.couponRecord.resp;
|
||||
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class CouponRecordQueryResp {
|
||||
private boolean exist = false;
|
||||
private Integer id;
|
||||
private Long scenicId;
|
||||
private Integer couponId;
|
||||
private Long memberId;
|
||||
private Long faceId;
|
||||
private Integer status;
|
||||
private Date createTime;
|
||||
private Date usedTime;
|
||||
private Long usedOrderId;
|
||||
private CouponEntity coupon;
|
||||
|
||||
public boolean isUsable() {
|
||||
return Integer.valueOf(0).equals(status);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user