You've already forked FrameTour-BE
Compare commits
62 Commits
bb2367c5a6
...
4360ef1313
| Author | SHA1 | Date | |
|---|---|---|---|
| 4360ef1313 | |||
| 9278d4479f | |||
| 18bf51487d | |||
| 447e8799e8 | |||
| fd130c471f | |||
| c47c24a39a | |||
| 97e3ab19a0 | |||
| 5b27cac6b0 | |||
| 91f3632e2b | |||
| cd8ae491e2 | |||
| d0d238d31d | |||
| 2be30c6eb4 | |||
| fb82329a88 | |||
| 4f0d6dc44f | |||
| 302b6811c4 | |||
| c0daa4d3b2 | |||
| 83cfbc67e1 | |||
| 8f918570d9 | |||
| f4a3dc9cae | |||
| cd5ba23d59 | |||
| 038b2e6f08 | |||
| caad0c2cf0 | |||
| 259d99bde7 | |||
| 0e2122910f | |||
| e1a77a1614 | |||
| 8791cf5910 | |||
| a860319ea1 | |||
| d5fc5c2565 | |||
| 0db713b4a8 | |||
| 6ef710201c | |||
| 9123a1f6db | |||
| d458f918ed | |||
| 27e58d36d0 | |||
| 8c76c85ae2 | |||
| 8991d68673 | |||
| 3b93e07a66 | |||
| c8054c60ab | |||
| 2fd852c5c6 | |||
| aaa8d8310a | |||
| 8d2d0901fd | |||
| d1381c93b0 | |||
| 536f2866f6 | |||
| 4cbd0dc255 | |||
| 90cf0d44c9 | |||
| d387f11173 | |||
| f6d6a63977 | |||
| 67aebd5770 | |||
| 6d18a770b8 | |||
| b6cbb18a7f | |||
| cfb3625ac0 | |||
| cb17ea527b | |||
| 625ad910c9 | |||
| 778afaaa83 | |||
| de421cf0d5 | |||
| 3ddf7bd0e9 | |||
| 208202ba41 | |||
| 6e84a5fd43 | |||
| 8e48bd92cc | |||
| 23181e9f08 | |||
| 42e806df76 | |||
| a49e581915 | |||
| af60e95529 |
13
pom.xml
13
pom.xml
@@ -311,19 +311,6 @@
|
||||
<skip>${skipTests}</skip>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- 跳过测试编译 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<testExcludes>
|
||||
<testExclude>**/*Test.java</testExclude>
|
||||
</testExcludes>
|
||||
<source>21</source>
|
||||
<target>21</target>
|
||||
<compilerArgs>--enable-preview</compilerArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.ycwl.basic.biz;
|
||||
|
||||
import com.ycwl.basic.enums.StatisticEnum;
|
||||
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||
import com.ycwl.basic.mapper.OrderMapper;
|
||||
import com.ycwl.basic.mapper.SourceMapper;
|
||||
import com.ycwl.basic.mapper.StatisticsMapper;
|
||||
import com.ycwl.basic.mapper.VideoMapper;
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||
import com.ycwl.basic.model.mobile.order.PriceObj;
|
||||
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
|
||||
@@ -160,90 +162,46 @@ public class OrderBiz {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public IsBuyRespVO isBuy(Long userId, Long scenicId, int goodsType, Long goodsId) {
|
||||
public IsBuyRespVO isBuy(Long scenicId, Long memberId, Long faceId, int goodsType, Long goodsId) {
|
||||
IsBuyRespVO respVO = new IsBuyRespVO();
|
||||
boolean isBuy = orderRepository.checkUserBuyItem(userId, goodsType, goodsId);
|
||||
// 模板购买逻辑
|
||||
if (!isBuy) {
|
||||
if (goodsType == 0) {
|
||||
VideoEntity video = videoRepository.getVideo(goodsId);
|
||||
if (video == null) {
|
||||
respVO.setGoodsType(goodsType);
|
||||
respVO.setGoodsId(goodsId);
|
||||
OrderEntity orderEntity = orderMapper.getUserBuyFaceItem(memberId, faceId, goodsType, goodsId);
|
||||
if (orderEntity != null) {
|
||||
respVO.setOrderId(orderEntity.getId());
|
||||
respVO.setBuy(true);
|
||||
respVO.setFree(false);
|
||||
return respVO;
|
||||
}
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
|
||||
if (Boolean.TRUE.equals(scenicConfig.getBoolean("all_free"))) {
|
||||
// 景区全免
|
||||
respVO.setFree(true);
|
||||
respVO.setOrigPrice(BigDecimal.ZERO);
|
||||
respVO.setSlashPrice(BigDecimal.ZERO);
|
||||
return respVO;
|
||||
}
|
||||
// 未来模板一口价
|
||||
if (goodsType == 0) {
|
||||
// 视频,可以买断模板
|
||||
VideoEntity video = videoRepository.getVideo(goodsId);
|
||||
if (video != null && video.getTemplateId() != null) {
|
||||
OrderEntity templateBuy = orderMapper.getUserBuyFaceItem(memberId, faceId, -1, video.getTemplateId());
|
||||
if (templateBuy != null) {
|
||||
respVO.setOrderId(templateBuy.getId());
|
||||
respVO.setBuy(true);
|
||||
respVO.setFree(false);
|
||||
return respVO;
|
||||
}
|
||||
TaskEntity task = videoTaskRepository.getTaskById(video.getTaskId());
|
||||
Long templateId = video.getTemplateId();
|
||||
// -1为整个模板购买
|
||||
OrderEntity orderEntity = orderRepository.getUserBuyItem(userId, -1, templateId);
|
||||
if (orderEntity != null && task != null) {
|
||||
respVO.setOrderId(orderEntity.getId());
|
||||
if (orderEntity.getFaceId() != null && task.getFaceId() != null) {
|
||||
isBuy = orderEntity.getFaceId().equals(task.getFaceId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 免费送逻辑,之前已经赠送了的
|
||||
if (!isBuy) {
|
||||
isBuy = switch (goodsType) {
|
||||
case 0 -> videoRepository.getUserIsBuy(userId, goodsId);
|
||||
case 1, 2 -> sourceRepository.getUserIsBuy(userId, goodsType, goodsId);
|
||||
default -> false;
|
||||
};
|
||||
} else {
|
||||
OrderEntity orderEntity = orderRepository.getUserBuyItem(userId, goodsType, goodsId);
|
||||
if (orderEntity != null) {
|
||||
respVO.setOrderId(orderEntity.getId());
|
||||
}
|
||||
}
|
||||
respVO.setBuy(isBuy);
|
||||
// 还是没买
|
||||
if (!isBuy) {
|
||||
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId);
|
||||
if (priceObj == null) {
|
||||
return respVO;
|
||||
}
|
||||
FaceEntity face = faceRepository.getFace(priceObj.getFaceId());
|
||||
respVO.setShare(true);
|
||||
if (face != null && face.getMemberId().equals(userId)) {
|
||||
respVO.setShare(false);
|
||||
}
|
||||
respVO.setFree(priceObj.isFree());
|
||||
respVO.setGoodsType(goodsType);
|
||||
respVO.setGoodsId(goodsId);
|
||||
respVO.setOrigPrice(priceObj.getPrice());
|
||||
respVO.setSlashPrice(priceObj.getSlashPrice());
|
||||
switch (goodsType) {
|
||||
case 0: // vlog
|
||||
VideoEntity video = videoRepository.getVideo(goodsId);
|
||||
TaskEntity taskById = videoTaskRepository.getTaskById(video.getTaskId());
|
||||
if (taskById != null) {
|
||||
CouponRecordQueryResp recordQueryResp = couponBiz.queryUserCouponRecord(scenicId, userId, taskById.getFaceId(), taskById.getTemplateId().toString());
|
||||
if (recordQueryResp.isUsable()) {
|
||||
respVO.setCouponId(recordQueryResp.getCouponId());
|
||||
respVO.setCouponRecordId(recordQueryResp.getId());
|
||||
CouponEntity coupon = recordQueryResp.getCoupon();
|
||||
if (coupon != null) {
|
||||
respVO.setCouponPrice(coupon.calculateDiscountPrice(priceObj.getPrice()));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
case 2:
|
||||
CouponRecordQueryResp recordQueryResp = couponBiz.queryUserCouponRecord(scenicId, userId, goodsId, String.valueOf(goodsType));
|
||||
if (recordQueryResp.isUsable()) {
|
||||
respVO.setCouponId(recordQueryResp.getCouponId());
|
||||
respVO.setCouponRecordId(recordQueryResp.getId());
|
||||
CouponEntity coupon = recordQueryResp.getCoupon();
|
||||
if (coupon != null) {
|
||||
respVO.setCouponPrice(coupon.calculateDiscountPrice(priceObj.getPrice()));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId);
|
||||
if (priceObj == null) {
|
||||
return respVO;
|
||||
}
|
||||
respVO.setBuy(false);
|
||||
respVO.setOrigPrice(priceObj.getPrice());
|
||||
respVO.setSlashPrice(priceObj.getSlashPrice());
|
||||
return respVO;
|
||||
}
|
||||
|
||||
@@ -259,11 +217,14 @@ public class OrderBiz {
|
||||
switch (item.getGoodsType()) {
|
||||
case 0: // vlog视频
|
||||
videoRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
|
||||
break;
|
||||
case 1: // 视频原素材
|
||||
case 2: // 照片原素材
|
||||
sourceRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId(), order.getId());
|
||||
break;
|
||||
case 3:
|
||||
printerService.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
|
||||
break;
|
||||
}
|
||||
});
|
||||
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
||||
@@ -271,38 +232,6 @@ public class OrderBiz {
|
||||
if (couponRecordId != null) {
|
||||
couponBiz.userUseCoupon(order.getMemberId(), order.getFaceId(), couponRecordId, orderId);
|
||||
}
|
||||
|
||||
//支付时间
|
||||
OrderAppRespVO orderDetail = orderMapper.appDetail(orderId);
|
||||
Date payAt = orderDetail.getPayAt();
|
||||
//商品创建时间
|
||||
Date goodsCreateTime = new Date();
|
||||
if (!orderDetail.getOrderItemList().isEmpty()) {
|
||||
OrderItemVO orderItemVO = orderDetail.getOrderItemList().getFirst();
|
||||
switch (orderItemVO.getGoodsType()) {
|
||||
case 0:
|
||||
VideoEntity video = videoRepository.getVideo(orderItemVO.getGoodsId());
|
||||
if (video != null) {
|
||||
goodsCreateTime = video.getCreateTime();
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
List<SourceEntity> imageSource = sourceMapper.listImageByFaceRelation(orderItemVO.getGoodsId());
|
||||
Optional<SourceEntity> min = imageSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
|
||||
if (min.isPresent()) {
|
||||
goodsCreateTime = min.get().getCreateTime();
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
List<SourceEntity> videoSource = sourceMapper.listImageByFaceRelation(orderItemVO.getGoodsId());
|
||||
Optional<SourceEntity> minTime = videoSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
|
||||
if (minTime.isPresent()) {
|
||||
goodsCreateTime = minTime.get().getCreateTime();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
|
||||
statisticsRecordAddReq.setMemberId(order.getMemberId());
|
||||
Long enterType = statisticsMapper.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.ycwl.basic.biz;
|
||||
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||
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;
|
||||
@@ -12,8 +10,6 @@ import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
|
||||
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
||||
import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity;
|
||||
import com.ycwl.basic.pricing.entity.PriceOnePriceConfig;
|
||||
import com.ycwl.basic.pricing.service.IOnePricePurchaseService;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||
import com.ycwl.basic.repository.PriceRepository;
|
||||
@@ -29,7 +25,6 @@ import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
@@ -95,7 +90,7 @@ public class PriceBiz {
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public IsBuyBatchRespVO isBuy(Long userId, Long faceId, Long scenicId, Integer type, String goodsIds) {
|
||||
public IsBuyBatchRespVO isOnePriceBuy(Long userId, Long faceId, Long scenicId, Integer type, String goodsIds) {
|
||||
IsBuyBatchRespVO respVO = new IsBuyBatchRespVO();
|
||||
PriceConfigEntity priceConfig = priceRepository.getPriceConfigByScenicTypeGoods(scenicId, type, goodsIds);
|
||||
if (priceConfig == null) {
|
||||
|
||||
@@ -59,7 +59,7 @@ public class AppFaceController {
|
||||
public ApiResponse<List<FaceRespVO>> list(@PathVariable("scenicId") String scenicId) {
|
||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||
Long userId = worker.getUserId();
|
||||
List<FaceRespVO> list = faceService.listByUser(userId, scenicId);
|
||||
List<FaceRespVO> list = faceService.listByUser(userId, Long.parseLong(scenicId));
|
||||
return ApiResponse.success(list);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.exception.CheckTokenException;
|
||||
import com.ycwl.basic.model.jwt.JwtInfo;
|
||||
import com.ycwl.basic.model.mobile.goods.*;
|
||||
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
||||
import com.ycwl.basic.service.mobile.GoodsService;
|
||||
import com.ycwl.basic.service.pc.FaceService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -24,11 +26,17 @@ public class AppGoodsController {
|
||||
|
||||
@Autowired
|
||||
private GoodsService goodsService;
|
||||
@Autowired
|
||||
private FaceService faceService;
|
||||
|
||||
// 商品列表
|
||||
@PostMapping("/goodsList")
|
||||
public ApiResponse<List<GoodsPageVO>> goodsList(@RequestBody GoodsReqQuery query) {
|
||||
return goodsService.goodsList(query);
|
||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||
Long userId = worker.getUserId();
|
||||
List<FaceRespVO> faceRespVOS = faceService.listByUser(userId, query.getScenicId());
|
||||
List<Long> faceIds = faceRespVOS.stream().map(FaceRespVO::getId).toList();
|
||||
return goodsService.listGoodsByFaceIdList(faceIds, query.getIsBuy(), query.getScenicId());
|
||||
}
|
||||
|
||||
// 源素材(原片/照片)商品列表
|
||||
|
||||
@@ -93,9 +93,9 @@ public class AppOrderController {
|
||||
}
|
||||
|
||||
@GetMapping("/scenic/{scenicId}/query")
|
||||
public ApiResponse<IsBuyRespVO> isBuy(@PathVariable("scenicId") Long scenicId, @RequestParam("type") Integer type, @RequestParam("goodsId") Long goodsId) {
|
||||
public ApiResponse<IsBuyRespVO> isBuy(@PathVariable("scenicId") Long scenicId, @RequestParam("type") Integer type, @RequestParam("goodsId") Long goodsId, @RequestParam(value = "faceId", required = false) Long faceId) {
|
||||
Long userId = Long.parseLong(BaseContextHandler.getUserId());
|
||||
return ApiResponse.success(orderBiz.isBuy(userId, scenicId, type, goodsId));
|
||||
return ApiResponse.success(orderBiz.isBuy(scenicId, userId, faceId, type, goodsId));
|
||||
}
|
||||
|
||||
@GetMapping("/scenic/{scenicId}/queryBatchPrice")
|
||||
@@ -108,7 +108,7 @@ public class AppOrderController {
|
||||
}
|
||||
faceId = lastFaceByUserId.getId();
|
||||
}
|
||||
IsBuyBatchRespVO buy = priceBiz.isBuy(userId, faceId, scenicId, type, goodsIds);
|
||||
IsBuyBatchRespVO buy = priceBiz.isOnePriceBuy(userId, faceId, scenicId, type, goodsIds);
|
||||
if (buy == null) {
|
||||
return ApiResponse.fail("该套餐暂未开放购买");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import com.ycwl.basic.biz.OrderBiz;
|
||||
import com.ycwl.basic.constant.SourceType;
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
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.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/puzzle/v1")
|
||||
@RequiredArgsConstructor
|
||||
public class AppPuzzleController {
|
||||
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
private final FaceRepository faceRepository;
|
||||
private final IPriceCalculationService iPriceCalculationService;
|
||||
private final PrinterService printerService;
|
||||
private final OrderBiz orderBiz;
|
||||
|
||||
/**
|
||||
* 根据faceId查询三拼图数量
|
||||
*/
|
||||
@GetMapping("/count/{faceId}")
|
||||
public ApiResponse<Integer> countByFaceId(@PathVariable("faceId") Long faceId) {
|
||||
if (faceId == null) {
|
||||
return ApiResponse.fail("faceId不能为空");
|
||||
}
|
||||
int count = recordMapper.countByFaceId(faceId);
|
||||
return ApiResponse.success(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据faceId查询所有三拼图记录
|
||||
*/
|
||||
@GetMapping("/list/{faceId}")
|
||||
public ApiResponse<List<ContentPageVO>> listByFaceId(@PathVariable("faceId") Long faceId) {
|
||||
if (faceId == null) {
|
||||
return ApiResponse.fail("faceId不能为空");
|
||||
}
|
||||
List<PuzzleGenerationRecordEntity> records = recordMapper.listByFaceId(faceId);
|
||||
List<ContentPageVO> result = records.stream()
|
||||
.map(this::convertToContentPageVO)
|
||||
.collect(Collectors.toList());
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据recordId查询单个三拼图记录
|
||||
*/
|
||||
@GetMapping("/detail/{recordId}")
|
||||
public ApiResponse<ContentPageVO> getByRecordId(@PathVariable("recordId") Long recordId) {
|
||||
if (recordId == null) {
|
||||
return ApiResponse.fail("recordId不能为空");
|
||||
}
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
ContentPageVO result = convertToContentPageVO(record);
|
||||
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);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
String resultImageUrl = record.getResultImageUrl();
|
||||
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
|
||||
return ApiResponse.fail("该拼图记录没有可用的图片URL");
|
||||
}
|
||||
return ApiResponse.success(Collections.singletonList(resultImageUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据recordId查询拼图价格
|
||||
*/
|
||||
@GetMapping("/price/{recordId}")
|
||||
public ApiResponse<PriceCalculationResult> getPriceByRecordId(@PathVariable("recordId") Long recordId) {
|
||||
if (recordId == null) {
|
||||
return ApiResponse.fail("recordId不能为空");
|
||||
}
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
FaceEntity face = faceRepository.getFace(record.getFaceId());
|
||||
if (face == null) {
|
||||
return ApiResponse.fail("未找到对应的人脸信息");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return ApiResponse.success(calculationResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将拼图导入到打印列表
|
||||
*/
|
||||
@PostMapping("/import-to-print/{recordId}")
|
||||
public ApiResponse<Integer> importToPrint(@PathVariable("recordId") Long recordId) {
|
||||
if (recordId == null) {
|
||||
return ApiResponse.fail("recordId不能为空");
|
||||
}
|
||||
|
||||
// 查询拼图记录
|
||||
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
|
||||
// 检查是否有图片URL
|
||||
String resultImageUrl = record.getResultImageUrl();
|
||||
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
|
||||
return ApiResponse.fail("该拼图记录没有可用的图片URL");
|
||||
}
|
||||
|
||||
// 获取人脸信息
|
||||
FaceEntity face = faceRepository.getFace(record.getFaceId());
|
||||
if (face == null) {
|
||||
return ApiResponse.fail("未找到对应的人脸信息");
|
||||
}
|
||||
|
||||
// 调用服务添加到打印列表
|
||||
Integer memberPrintId = printerService.addUserPhotoFromPuzzle(
|
||||
face.getMemberId(),
|
||||
face.getScenicId(),
|
||||
record.getFaceId(),
|
||||
resultImageUrl,
|
||||
0L // 打印特有
|
||||
);
|
||||
|
||||
if (memberPrintId == null) {
|
||||
return ApiResponse.fail("添加到打印列表失败");
|
||||
}
|
||||
|
||||
return ApiResponse.success(memberPrintId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将PuzzleGenerationRecordEntity转换为ContentPageVO
|
||||
*/
|
||||
private ContentPageVO convertToContentPageVO(PuzzleGenerationRecordEntity record) {
|
||||
ContentPageVO vo = new ContentPageVO();
|
||||
|
||||
// 内容类型为3(拼图)
|
||||
vo.setContentType(3);
|
||||
|
||||
// 源素材类型为3(拼图)
|
||||
vo.setSourceType(3);
|
||||
vo.setGroup("拼图");
|
||||
|
||||
// 只要存在记录,lockType不为0(设置为-1表示已生成)
|
||||
vo.setLockType(-1);
|
||||
|
||||
// 通过faceId填充scenicId的信息
|
||||
FaceEntity face = faceRepository.getFace(record.getFaceId());
|
||||
if (record.getFaceId() != null) {
|
||||
vo.setScenicId(face.getScenicId());
|
||||
}
|
||||
|
||||
// contentId为生成记录id
|
||||
vo.setContentId(record.getId());
|
||||
|
||||
// templateCoverUrl和生成的图是一致的
|
||||
vo.setTemplateCoverUrl(record.getResultImageUrl());
|
||||
|
||||
// 设置模板ID
|
||||
vo.setTemplateId(record.getTemplateId());
|
||||
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), 5, record.getTemplateId());
|
||||
if (isBuyRespVO.isBuy()) {
|
||||
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 {
|
||||
vo.setFreeCount(1);
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ public class AppScenicController {
|
||||
add("3932535453961555968");
|
||||
add("3936121342868459520");
|
||||
add("3936940597855784960");
|
||||
add("4049850382325780480");
|
||||
}};
|
||||
|
||||
// 分页查询景区列表
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.ycwl.basic.controller.pc;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
|
||||
import com.ycwl.basic.task.DeviceVideoContinuityCheckTask;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 设备视频连续性检查控制器
|
||||
* 提供查询设备视频连续性检查结果的接口
|
||||
*
|
||||
* @author Claude Code
|
||||
* @date 2025-09-01
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/device/video-continuity")
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceVideoContinuityController {
|
||||
|
||||
private static final String REDIS_KEY_PREFIX = "device:video:continuity:";
|
||||
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final DeviceVideoContinuityCheckTask checkTask;
|
||||
|
||||
/**
|
||||
* 查询设备最近的视频连续性检查结果
|
||||
*
|
||||
* @param deviceId 设备ID
|
||||
* @return 检查结果
|
||||
*/
|
||||
@GetMapping("/{deviceId}")
|
||||
public ApiResponse<DeviceVideoContinuityCache> getDeviceContinuityResult(@PathVariable Long deviceId) {
|
||||
log.info("查询设备 {} 的视频连续性检查结果", deviceId);
|
||||
|
||||
try {
|
||||
String redisKey = REDIS_KEY_PREFIX + deviceId;
|
||||
String cacheJson = redisTemplate.opsForValue().get(redisKey);
|
||||
|
||||
if (cacheJson == null) {
|
||||
log.warn("未找到设备 {} 的视频连续性检查结果", deviceId);
|
||||
return ApiResponse.buildResponse(404, null, "未找到该设备的检查结果,可能设备未配置存储或尚未执行检查");
|
||||
}
|
||||
|
||||
DeviceVideoContinuityCache cache = objectMapper.readValue(cacheJson, DeviceVideoContinuityCache.class);
|
||||
return ApiResponse.success(cache);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("查询设备 {} 视频连续性检查结果失败", deviceId, e);
|
||||
return ApiResponse.buildResponse(500, null, "查询失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发设备视频连续性检查
|
||||
* 注意:仅用于测试和紧急情况,正常情况下由定时任务自动执行
|
||||
*
|
||||
* @param deviceId 设备ID
|
||||
* @return 检查结果
|
||||
*/
|
||||
@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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除设备的视频连续性检查缓存
|
||||
* 用于清理过期或错误的缓存数据
|
||||
*
|
||||
* @param deviceId 设备ID
|
||||
* @return 删除结果
|
||||
*/
|
||||
@DeleteMapping("/{deviceId}")
|
||||
public ApiResponse<String> deleteContinuityCache(@PathVariable Long deviceId) {
|
||||
log.info("删除设备 {} 的视频连续性检查缓存", deviceId);
|
||||
|
||||
try {
|
||||
String redisKey = REDIS_KEY_PREFIX + deviceId;
|
||||
Boolean deleted = redisTemplate.delete(redisKey);
|
||||
|
||||
if (deleted != null && deleted) {
|
||||
return ApiResponse.success("缓存删除成功");
|
||||
} else {
|
||||
return ApiResponse.buildResponse(404, null, "未找到该设备的缓存数据");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("删除设备 {} 视频连续性检查缓存失败", deviceId, e);
|
||||
return ApiResponse.buildResponse(500, null, "删除失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.ycwl.basic.device.entity.common;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备视频连续性检查缓存实体
|
||||
* 用于存储在Redis中的检查结果
|
||||
*
|
||||
* @author Claude Code
|
||||
* @date 2025-09-01
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DeviceVideoContinuityCache implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备ID
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 检查时间
|
||||
*/
|
||||
private Date checkTime;
|
||||
|
||||
/**
|
||||
* 检查的开始时间
|
||||
*/
|
||||
private Date startTime;
|
||||
|
||||
/**
|
||||
* 检查的结束时间
|
||||
*/
|
||||
private Date endTime;
|
||||
|
||||
/**
|
||||
* 是否支持连续性检查
|
||||
*/
|
||||
private Boolean support;
|
||||
|
||||
/**
|
||||
* 视频是否连续
|
||||
*/
|
||||
private Boolean continuous;
|
||||
|
||||
/**
|
||||
* 视频总数
|
||||
*/
|
||||
private Integer totalVideos;
|
||||
|
||||
/**
|
||||
* 总时长(毫秒)
|
||||
*/
|
||||
private Long totalDurationMs;
|
||||
|
||||
/**
|
||||
* 允许的最大间隙(毫秒)
|
||||
*/
|
||||
private Long maxAllowedGapMs;
|
||||
|
||||
/**
|
||||
* 间隙数量
|
||||
*/
|
||||
private Integer gapCount;
|
||||
|
||||
/**
|
||||
* 间隙列表(简化版,只包含关键信息)
|
||||
*/
|
||||
private List<GapInfo> gaps;
|
||||
|
||||
/**
|
||||
* 间隙信息简化类
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class GapInfo implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 前一个文件名
|
||||
*/
|
||||
private String beforeFileName;
|
||||
|
||||
/**
|
||||
* 后一个文件名
|
||||
*/
|
||||
private String afterFileName;
|
||||
|
||||
/**
|
||||
* 间隙时长(毫秒)
|
||||
*/
|
||||
private Long gapMs;
|
||||
|
||||
/**
|
||||
* 间隙开始时间
|
||||
*/
|
||||
private Date gapStartTime;
|
||||
|
||||
/**
|
||||
* 间隙结束时间
|
||||
*/
|
||||
private Date gapEndTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从VideoContinuityResult创建缓存对象
|
||||
*
|
||||
* @param deviceId 设备ID
|
||||
* @param result 检查结果
|
||||
* @param startTime 检查开始时间
|
||||
* @param endTime 检查结束时间
|
||||
* @return 缓存对象
|
||||
*/
|
||||
public static DeviceVideoContinuityCache fromResult(Long deviceId, VideoContinuityResult result,
|
||||
Date startTime, Date endTime) {
|
||||
DeviceVideoContinuityCache cache = new DeviceVideoContinuityCache();
|
||||
cache.setDeviceId(deviceId);
|
||||
cache.setCheckTime(new Date());
|
||||
cache.setStartTime(startTime);
|
||||
cache.setEndTime(endTime);
|
||||
cache.setSupport(result.isSupport());
|
||||
cache.setContinuous(result.isContinuous());
|
||||
cache.setTotalVideos(result.getTotalVideos());
|
||||
cache.setTotalDurationMs(result.getTotalDurationMs());
|
||||
cache.setMaxAllowedGapMs(result.getMaxAllowedGapMs());
|
||||
cache.setGapCount(result.getGapCount());
|
||||
|
||||
// 转换间隙列表
|
||||
if (result.getGaps() != null && !result.getGaps().isEmpty()) {
|
||||
List<GapInfo> gapInfos = result.getGaps().stream()
|
||||
.map(gap -> new GapInfo(
|
||||
gap.getBeforeFile() != null ? gap.getBeforeFile().getName() : null,
|
||||
gap.getAfterFile() != null ? gap.getAfterFile().getName() : null,
|
||||
gap.getGapMs(),
|
||||
gap.getGapStartTime(),
|
||||
gap.getGapEndTime()
|
||||
))
|
||||
.toList();
|
||||
cache.setGaps(gapInfos);
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ycwl.basic.device.entity.common;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 视频连续性检查中的间隙信息
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VideoContinuityGap {
|
||||
/**
|
||||
* 间隙前的视频文件
|
||||
*/
|
||||
private FileObject beforeFile;
|
||||
|
||||
/**
|
||||
* 间隙后的视频文件
|
||||
*/
|
||||
private FileObject afterFile;
|
||||
|
||||
/**
|
||||
* 间隙时长(毫秒)
|
||||
*/
|
||||
private long gapMs;
|
||||
|
||||
/**
|
||||
* 间隙开始时间(前一个视频的endTime)
|
||||
*/
|
||||
private Date gapStartTime;
|
||||
|
||||
/**
|
||||
* 间隙结束时间(后一个视频的createTime)
|
||||
*/
|
||||
private Date gapEndTime;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ycwl.basic.device.entity.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 视频连续性检查结果
|
||||
*/
|
||||
@Data
|
||||
public class VideoContinuityResult {
|
||||
/**
|
||||
* 是否支持连续性检查功能
|
||||
*/
|
||||
private boolean support;
|
||||
|
||||
/**
|
||||
* 视频是否连续(所有间隙都在允许范围内)
|
||||
*/
|
||||
private boolean continuous;
|
||||
|
||||
/**
|
||||
* 检测到的间隙列表
|
||||
*/
|
||||
private List<VideoContinuityGap> gaps = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 视频文件总数
|
||||
*/
|
||||
private int totalVideos;
|
||||
|
||||
/**
|
||||
* 总时长(毫秒)
|
||||
*/
|
||||
private long totalDurationMs;
|
||||
|
||||
/**
|
||||
* 允许的最大间隙(毫秒)
|
||||
*/
|
||||
private long maxAllowedGapMs;
|
||||
|
||||
/**
|
||||
* 添加一个间隙
|
||||
*/
|
||||
public void addGap(VideoContinuityGap gap) {
|
||||
this.gaps.add(gap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取间隙数量
|
||||
*/
|
||||
public int getGapCount() {
|
||||
return gaps.size();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,50 @@
|
||||
package com.ycwl.basic.device.operator;
|
||||
|
||||
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
|
||||
public abstract class ADeviceStorageOperator implements IDeviceStorageOperator {
|
||||
@Setter
|
||||
protected DeviceEntity device;
|
||||
@Setter
|
||||
protected DeviceConfigEntity deviceConfig;
|
||||
|
||||
/**
|
||||
* 默认实现:不支持视频连续性检查
|
||||
*
|
||||
* @param startDate 开始时间
|
||||
* @param endDate 结束时间
|
||||
* @param maxGapMs 允许的最大间隔时间(毫秒)
|
||||
* @return support=false的结果
|
||||
*/
|
||||
@Override
|
||||
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
|
||||
VideoContinuityResult result = new VideoContinuityResult();
|
||||
result.setSupport(false);
|
||||
result.setContinuous(false);
|
||||
result.setTotalVideos(0);
|
||||
result.setTotalDurationMs(0);
|
||||
result.setMaxAllowedGapMs(maxGapMs);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认实现:不支持视频连续性检查
|
||||
*
|
||||
* @return support=false的结果
|
||||
*/
|
||||
@Override
|
||||
public VideoContinuityResult checkRecentVideoContinuity() {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.add(Calendar.MINUTE, -2);
|
||||
Date endDate = calendar.getTime();
|
||||
calendar.add(Calendar.MINUTE, -5);
|
||||
Date startDate = calendar.getTime();
|
||||
return checkVideoContinuity(startDate, endDate, 2000L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.ycwl.basic.device.operator;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
import com.ycwl.basic.device.entity.common.FileObject;
|
||||
import com.ycwl.basic.device.entity.common.VideoContinuityGap;
|
||||
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||
import com.ycwl.basic.storage.entity.AliOssStorageConfig;
|
||||
@@ -98,4 +100,104 @@ public class AliOssStorageOperator extends ADeviceStorageOperator {
|
||||
String prefix = dateFormat.format(calendar.getTime());
|
||||
return removeFilesByPrefix(prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查视频片段的连续性
|
||||
*
|
||||
* @param startDate 开始时间
|
||||
* @param endDate 结束时间
|
||||
* @param maxGapMs 允许的最大间隔时间(毫秒)
|
||||
* @return 包含缺口信息的验证结果
|
||||
*/
|
||||
@Override
|
||||
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
|
||||
VideoContinuityResult result = new VideoContinuityResult();
|
||||
result.setSupport(true);
|
||||
result.setMaxAllowedGapMs(maxGapMs);
|
||||
|
||||
// 获取时间范围内的视频列表
|
||||
List<FileObject> fileList = getFileListByDtRange(startDate, endDate);
|
||||
|
||||
if (fileList == null || fileList.isEmpty()) {
|
||||
result.setContinuous(false);
|
||||
result.setTotalVideos(0);
|
||||
result.setTotalDurationMs(0);
|
||||
log.warn("未找到指定时间范围内的视频文件: {} - {}", startDate, endDate);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.setTotalVideos(fileList.size());
|
||||
|
||||
// 只有一个视频文件时,认为是连续的
|
||||
if (fileList.size() == 1) {
|
||||
FileObject file = fileList.get(0);
|
||||
long duration = file.getEndTime().getTime() - file.getCreateTime().getTime();
|
||||
result.setContinuous(true);
|
||||
result.setTotalDurationMs(duration);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查相邻视频之间的间隙
|
||||
long totalDuration = 0;
|
||||
for (int i = 0; i < fileList.size() - 1; i++) {
|
||||
FileObject currentFile = fileList.get(i);
|
||||
FileObject nextFile = fileList.get(i + 1);
|
||||
|
||||
// 计算当前视频的时长
|
||||
totalDuration += currentFile.getEndTime().getTime() - currentFile.getCreateTime().getTime();
|
||||
|
||||
// 计算间隙: 后一个视频的开始时间 - 前一个视频的结束时间
|
||||
long gapMs = nextFile.getCreateTime().getTime() - currentFile.getEndTime().getTime();
|
||||
|
||||
// 如果间隙超过允许值,记录该间隙
|
||||
if (gapMs > maxGapMs) {
|
||||
VideoContinuityGap gap = new VideoContinuityGap();
|
||||
gap.setBeforeFile(currentFile);
|
||||
gap.setAfterFile(nextFile);
|
||||
gap.setGapMs(gapMs);
|
||||
gap.setGapStartTime(currentFile.getEndTime());
|
||||
gap.setGapEndTime(nextFile.getCreateTime());
|
||||
result.addGap(gap);
|
||||
log.debug("检测到视频间隙: {} -> {}, 间隙时长: {}ms",
|
||||
currentFile.getName(), nextFile.getName(), gapMs);
|
||||
}
|
||||
}
|
||||
|
||||
// 加上最后一个视频的时长
|
||||
FileObject lastFile = fileList.get(fileList.size() - 1);
|
||||
totalDuration += lastFile.getEndTime().getTime() - lastFile.getCreateTime().getTime();
|
||||
|
||||
result.setTotalDurationMs(totalDuration);
|
||||
result.setContinuous(result.getGapCount() == 0);
|
||||
|
||||
log.info("视频连续性检查完成: 总视频数={}, 总时长={}ms, 间隙数={}, 连续={}",
|
||||
result.getTotalVideos(), result.getTotalDurationMs(), result.getGapCount(), result.isContinuous());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查近期视频的连续性(测试用)
|
||||
* 时间范围: 当前时间向前2分钟后,再向前10分钟(即前12分钟到前2分钟)
|
||||
* 允许的最大间隙: 2秒
|
||||
*
|
||||
* @return 包含缺口信息的验证结果
|
||||
*/
|
||||
@Override
|
||||
public VideoContinuityResult checkRecentVideoContinuity() {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
|
||||
// 结束时间: 当前时间 - 2分钟
|
||||
calendar.add(Calendar.MINUTE, -2);
|
||||
Date endDate = calendar.getTime();
|
||||
|
||||
// 开始时间: 当前时间 - 12分钟 (再向前10分钟)
|
||||
calendar.add(Calendar.MINUTE, -10);
|
||||
Date startDate = calendar.getTime();
|
||||
|
||||
log.info("检查近期视频连续性: {} - {}", startDate, endDate);
|
||||
|
||||
// 允许的最大间隙为2秒(2000毫秒)
|
||||
return checkVideoContinuity(startDate, endDate, 2000L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ycwl.basic.device.operator;
|
||||
|
||||
import com.ycwl.basic.device.IDeviceCommon;
|
||||
import com.ycwl.basic.device.entity.common.FileObject;
|
||||
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
@@ -19,10 +20,29 @@ public interface IDeviceStorageOperator extends IDeviceCommon {
|
||||
List<FileObject> getFileListByDtRange(Date startDate, Date endDate);
|
||||
|
||||
/**
|
||||
* 删除指定日期之前的文件,不包含指定的日期当天
|
||||
* 删除指定日期之前的文件,不包含指定的日期当天
|
||||
*
|
||||
* @param date 指定日期,不包含指定日期当天
|
||||
* @param date 指定日期,不包含指定日期当天
|
||||
* @return
|
||||
*/
|
||||
boolean removeFilesBeforeDate(Date date);
|
||||
|
||||
/**
|
||||
* 检查视频片段的连续性
|
||||
*
|
||||
* @param startDate 开始时间
|
||||
* @param endDate 结束时间
|
||||
* @param maxGapMs 允许的最大间隔时间(毫秒)
|
||||
* @return 包含缺口信息的验证结果
|
||||
*/
|
||||
VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs);
|
||||
|
||||
/**
|
||||
* 检查近期视频的连续性(便捷方法)
|
||||
* 时间范围: 当前时间向前2分钟后,再向前5分钟(即前7分钟到前2分钟)
|
||||
* 允许的最大间隙: 2秒
|
||||
*
|
||||
* @return 包含缺口信息的验证结果
|
||||
*/
|
||||
VideoContinuityResult checkRecentVideoContinuity();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.ycwl.basic.device.operator;
|
||||
|
||||
import com.ycwl.basic.device.entity.common.FileObject;
|
||||
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
@@ -34,4 +36,24 @@ public class LocalStorageOperator implements IDeviceStorageOperator {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
|
||||
VideoContinuityResult result = new VideoContinuityResult();
|
||||
result.setSupport(false);
|
||||
result.setContinuous(false);
|
||||
result.setTotalVideos(0);
|
||||
result.setTotalDurationMs(0);
|
||||
result.setMaxAllowedGapMs(maxGapMs);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public VideoContinuityResult checkRecentVideoContinuity() {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.add(Calendar.MINUTE, -2);
|
||||
Date endDate = calendar.getTime();
|
||||
calendar.add(Calendar.MINUTE, -5);
|
||||
Date startDate = calendar.getTime();
|
||||
return checkVideoContinuity(startDate, endDate, 2000L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,16 @@ public class WatermarkInfo {
|
||||
private String dtFormat;
|
||||
private String datetimeLine;
|
||||
|
||||
/**
|
||||
* 四边偏移(像素值),正数表示向内偏移
|
||||
* 例如: offsetLeft=40 表示左边界向右收缩40像素,所有左对齐元素随之向右移动
|
||||
* null 表示使用默认值(通常为0)
|
||||
*/
|
||||
private Integer offsetTop;
|
||||
private Integer offsetBottom;
|
||||
private Integer offsetLeft;
|
||||
private Integer offsetRight;
|
||||
|
||||
public String getDatetimeLine() {
|
||||
if (datetimeLine == null) {
|
||||
datetimeLine = DateUtil.format(datetime, dtFormat);
|
||||
|
||||
@@ -47,7 +47,7 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
|
||||
public static int OFFSET_Y = 15;
|
||||
public static Color BG_COLOR = Color.WHITE;
|
||||
public static int QRCODE_SIZE = 150;
|
||||
public static double QRCODE_LEFT_MARGIN_RATIO = 0.075; // 二维码距离左边缘的图片宽度比例
|
||||
public static double QRCODE_LEFT_MARGIN_RATIO = 0.05; // 二维码距离左边缘的图片宽度比例
|
||||
public static int QRCODE_OFFSET_Y = -35;
|
||||
|
||||
public static int SCENIC_FONT_SIZE = 42;
|
||||
@@ -56,8 +56,20 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
|
||||
public static Color datetimeColor = Color.white;
|
||||
public static double TEXT_RIGHT_MARGIN_RATIO = 0.05; // 文字距离右边缘的图片宽度比例
|
||||
|
||||
// 默认四边偏移值(像素),当 WatermarkInfo 中未提供时使用
|
||||
public static int DEFAULT_OFFSET_TOP = 0;
|
||||
public static int DEFAULT_OFFSET_BOTTOM = 0;
|
||||
public static int DEFAULT_OFFSET_LEFT = 0;
|
||||
public static int DEFAULT_OFFSET_RIGHT = 0;
|
||||
|
||||
@Override
|
||||
public File process(WatermarkInfo info) throws ImageWatermarkException {
|
||||
// 获取四边偏移值,优先使用传入的值,否则使用默认值
|
||||
int offsetTop = info.getOffsetTop() != null ? info.getOffsetTop() : DEFAULT_OFFSET_TOP;
|
||||
int offsetBottom = info.getOffsetBottom() != null ? info.getOffsetBottom() : DEFAULT_OFFSET_BOTTOM;
|
||||
int offsetLeft = info.getOffsetLeft() != null ? info.getOffsetLeft() : DEFAULT_OFFSET_LEFT;
|
||||
int offsetRight = info.getOffsetRight() != null ? info.getOffsetRight() : DEFAULT_OFFSET_RIGHT;
|
||||
|
||||
BufferedImage baseImage;
|
||||
BufferedImage qrcodeImage;
|
||||
BufferedImage faceImage = null;
|
||||
@@ -92,9 +104,9 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
|
||||
int scenicLineWidth = scenicFontMetrics.stringWidth(info.getScenicLine());
|
||||
int datetimeLineWidth = datetimeFontMetrics.stringWidth(info.getDatetimeLine());
|
||||
|
||||
// 二维码放置在左下角,距离左边缘图片宽度的5%
|
||||
int qrcodeOffsetX = (int) (newImage.getWidth() * QRCODE_LEFT_MARGIN_RATIO);
|
||||
int qrcodeOffsetY = EXTRA_BORDER_PX + baseImage.getHeight() - OFFSET_Y - newQrcodeHeight;
|
||||
// 二维码放置在左下角,距离左边缘图片宽度的5%,再加上左侧偏移
|
||||
int qrcodeOffsetX = (int) (newImage.getWidth() * QRCODE_LEFT_MARGIN_RATIO) + offsetLeft;
|
||||
int qrcodeOffsetY = EXTRA_BORDER_PX + baseImage.getHeight() - OFFSET_Y - newQrcodeHeight - offsetBottom;
|
||||
Shape originalClip = g2d.getClip();
|
||||
|
||||
// 创建比二维码大10像素的白色圆形背景
|
||||
@@ -165,8 +177,8 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
|
||||
// 计算第一行文字的Y坐标(基线位置),使两行文字整体垂直居中于二维码
|
||||
int textStartY = qrcodeCenter - totalTextHeight / 2 + scenicFontMetrics.getAscent();
|
||||
|
||||
// 文字右对齐,放置在右下角,距离右边缘图片宽度的5%
|
||||
int textRightX = newImage.getWidth() - (int) (newImage.getWidth() * TEXT_RIGHT_MARGIN_RATIO);
|
||||
// 文字右对齐,放置在右下角,距离右边缘图片宽度的5%,再减去右侧偏移
|
||||
int textRightX = newImage.getWidth() - (int) (newImage.getWidth() * TEXT_RIGHT_MARGIN_RATIO) - offsetRight;
|
||||
|
||||
g2d.setFont(scenicFont);
|
||||
g2d.setColor(scenicColor);
|
||||
|
||||
@@ -35,7 +35,7 @@ public interface FaceMapper {
|
||||
FaceRespVO findLastFaceByUserId(String userId);
|
||||
FaceRespVO findLastFaceByScenicAndUserId(Long scenicId, Long userId);
|
||||
|
||||
List<FaceRespVO> listByScenicAndUserId(String scenicId, Long userId);
|
||||
List<FaceRespVO> listByScenicAndUserId(Long scenicId, Long userId);
|
||||
|
||||
List<FaceEntity> listUnpaidEntityBeforeDate(Long scenicId, Date endDate);
|
||||
}
|
||||
|
||||
@@ -59,4 +59,6 @@ public interface OrderMapper {
|
||||
int updateMemberIdByFaceId(OrderEntity orderEntity);
|
||||
|
||||
List<OrderItemEntity> getOrderItems(Long orderId);
|
||||
|
||||
OrderEntity getUserBuyFaceItem(Long memberId, Long faceId, int goodsType, Long goodsId);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public interface PrinterMapper {
|
||||
PrintTaskEntity getTaskById(Integer id);
|
||||
|
||||
List<PrinterResp> listByScenicId(@Param("scenicId") Long scenicId);
|
||||
|
||||
int countFacePhoto(Long scenicId, Long faceId, Long sourceId);
|
||||
List<MemberPrintResp> listRelation(@Param("memberId") Long memberId, @Param("scenicId") Long scenicId);
|
||||
List<MemberPrintResp> listRelationByFaceId(Long memberId, Long scenicId, Long faceId);
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ public interface SourceMapper {
|
||||
|
||||
List<SourceRespVO> queryByRelation(SourceReqQuery sourceReqQuery);
|
||||
|
||||
/**
|
||||
* 按 faceId 和 type 分组查询源素材,每组返回最新的一条记录
|
||||
* @param sourceReqQuery 查询参数
|
||||
* @return 分组后的素材列表
|
||||
*/
|
||||
List<SourceRespVO> queryGroupedByFaceAndType(SourceReqQuery sourceReqQuery);
|
||||
|
||||
SourceEntity querySameVideo(Long faceSampleId, Long deviceId);
|
||||
|
||||
int hasRelationTo(Long memberId, Long sourceId, int type);
|
||||
@@ -107,4 +114,38 @@ public interface SourceMapper {
|
||||
int addFromZTSource(SourceEntity source);
|
||||
|
||||
SourceEntity getBySampleIdAndType(Long faceSampleId, Integer type);
|
||||
|
||||
/**
|
||||
* 统计faceId关联的不同设备数量
|
||||
* @param faceId 人脸ID
|
||||
* @return 设备数量
|
||||
*/
|
||||
Integer countDistinctDevicesByFaceId(Long faceId);
|
||||
|
||||
/**
|
||||
* 根据faceId和设备索引获取source
|
||||
* @param faceId 人脸ID
|
||||
* @param deviceIndex 设备索引(从0开始)
|
||||
* @param type 素材类型(1-视频,2-图片)
|
||||
* @param sortStrategy 排序策略
|
||||
* @return source实体
|
||||
*/
|
||||
SourceEntity getSourceByFaceAndDeviceIndex(Long faceId, Integer deviceIndex, Integer type, String sortStrategy);
|
||||
|
||||
/**
|
||||
* 获取faceId关联的所有设备ID列表
|
||||
* @param faceId 人脸ID
|
||||
* @return 设备ID列表
|
||||
*/
|
||||
List<Long> getDeviceIdsByFaceId(Long faceId);
|
||||
|
||||
/**
|
||||
* 根据faceId和设备ID获取source
|
||||
* @param faceId 人脸ID
|
||||
* @param deviceId 设备ID
|
||||
* @param type 素材类型(1-视频,2-图片)
|
||||
* @param sortStrategy 排序策略
|
||||
* @return source实体
|
||||
*/
|
||||
SourceEntity getSourceByFaceAndDeviceId(Long faceId, Long deviceId, Integer type, String sortStrategy);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ycwl.basic.model.mobile.goods;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class GoodsDetailPrintSceneVO extends GoodsDetailVO{
|
||||
private Boolean inList;
|
||||
}
|
||||
@@ -17,4 +17,5 @@ public class GoodsReqQuery {
|
||||
private Long scenicId;
|
||||
// 源素材商品类型 1视频 2图像
|
||||
private Integer sourceType;
|
||||
private String scene;
|
||||
}
|
||||
|
||||
@@ -17,71 +17,6 @@ import java.util.Date;
|
||||
public class OrderAppPageReq extends BaseQueryParameterReq {
|
||||
// 用户id
|
||||
private Long memberId;
|
||||
// /**
|
||||
// * 微信openId
|
||||
// */
|
||||
// @ApiModelProperty("微信openId")
|
||||
// private Long openId;
|
||||
// /**
|
||||
// * 价格
|
||||
// */
|
||||
// @ApiModelProperty("价格")
|
||||
// private BigDecimal price;
|
||||
// /**
|
||||
// * 实际支付价格
|
||||
// */
|
||||
// @ApiModelProperty("实际支付价格")
|
||||
// private BigDecimal payPrice;
|
||||
// /**
|
||||
// * 推客id
|
||||
// */
|
||||
// @ApiModelProperty("推客id")
|
||||
// private Long brokerId;
|
||||
// /**
|
||||
// * 推客优惠码
|
||||
// */
|
||||
// @ApiModelProperty("推客优惠码")
|
||||
// private String promoCode;
|
||||
// /**
|
||||
// * 退款原因
|
||||
// */
|
||||
// @ApiModelProperty("退款原因")
|
||||
// private String refundReason;
|
||||
// /**
|
||||
// * 退款状态,0未提出,1已通过,2待审核
|
||||
// */
|
||||
// @ApiModelProperty("退款状态,0未提出,1已通过,2待审核")
|
||||
// private Integer refundStatus;
|
||||
// /**
|
||||
// * 状态,0未支付,1已支付,2已退款,9已取消
|
||||
// */
|
||||
// @ApiModelProperty("状态,0未支付,1已支付,2已退款,9已取消")
|
||||
// private Integer status;
|
||||
// /**
|
||||
// * 订单创建时间
|
||||
// */
|
||||
// @ApiModelProperty("订单创建时间")
|
||||
// private Date startCreateTime;
|
||||
// private Date endCreateTime;
|
||||
// /**
|
||||
// * 订单支付时间
|
||||
// */
|
||||
// @ApiModelProperty("订单支付时间")
|
||||
// private Date startPayTime;
|
||||
// private Date endPayTime;
|
||||
// /**
|
||||
// * 订单取消时间
|
||||
// */
|
||||
// @ApiModelProperty("订单取消时间")
|
||||
// private Date startCancelTime;
|
||||
// private Date endCancelTime;
|
||||
// /**
|
||||
// * 订单退款时间
|
||||
// */
|
||||
// @ApiModelProperty("订单退款时间")
|
||||
// private Date startRefundTime;
|
||||
// private Date endRefundTime;
|
||||
|
||||
// 订单类型 0成片(vlog) 1原片 2照片
|
||||
private Integer type;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Author:longbinbin
|
||||
@@ -45,6 +46,8 @@ public class SourceReqQuery extends BaseQueryParameterReq {
|
||||
// 是否被购买:0未购买,1已购买
|
||||
private Integer isBuy;
|
||||
private Long faceId;
|
||||
// 人脸ID列表(批量查询用)
|
||||
private List<Long> faceIds;
|
||||
private Date startTime;
|
||||
private Date endTime;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ public class VideoReviewRespDTO {
|
||||
*/
|
||||
private String videoUrl;
|
||||
|
||||
/**
|
||||
* 模板ID(关联查询video表)
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 模板名称(关联查询)
|
||||
*/
|
||||
private String templateName;
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,8 @@ public enum ProductType {
|
||||
VLOG_VIDEO("VLOG_VIDEO", "Vlog视频"),
|
||||
RECORDING_SET("RECORDING_SET", "录像集"),
|
||||
PHOTO_SET("PHOTO_SET", "照相集"),
|
||||
PHOTO_LOG("PHOTO_LOG", "pLog图"),
|
||||
PHOTO_VLOG("PHOTO_VLOG", "pLog视频"),
|
||||
PHOTO_PRINT("PHOTO_PRINT", "照片打印"),
|
||||
PHOTO_PRINT_MU("PHOTO_PRINT_MU", "手机照片打印"),
|
||||
PHOTO_PRINT_FX("PHOTO_PRINT_FX", "特效照片打印"),
|
||||
|
||||
@@ -9,6 +9,7 @@ Puzzle拼图模块是一个基于模板和元素的动态图片生成系统,
|
||||
- 多层次元素渲染:支持图片和文字元素的分层叠加
|
||||
- 灵活的样式配置:支持位置、大小、透明度、旋转、圆角等属性
|
||||
- 动态数据注入:通过elementKey进行动态数据替换
|
||||
- 智能自动填充:基于规则引擎自动选择和填充素材数据
|
||||
- 生成记录追踪:完整记录每次生成的参数和结果
|
||||
|
||||
**典型应用场景:**
|
||||
@@ -27,28 +28,58 @@ Puzzle拼图模块是一个基于模板和元素的动态图片生成系统,
|
||||
puzzle/
|
||||
├── controller/ # API接口层
|
||||
│ ├── PuzzleGenerateController.java # 拼图生成接口
|
||||
│ └── PuzzleTemplateController.java # 模板管理接口
|
||||
│ ├── PuzzleTemplateController.java # 模板管理接口
|
||||
│ └── PuzzleFillRuleController.java # 填充规则管理接口
|
||||
├── service/ # 业务逻辑层
|
||||
│ ├── IPuzzleGenerateService.java
|
||||
│ ├── IPuzzleTemplateService.java
|
||||
│ ├── IPuzzleFillRuleService.java
|
||||
│ └── impl/
|
||||
│ ├── PuzzleGenerateServiceImpl.java
|
||||
│ └── PuzzleTemplateServiceImpl.java
|
||||
│ ├── PuzzleTemplateServiceImpl.java
|
||||
│ └── PuzzleFillRuleServiceImpl.java
|
||||
├── mapper/ # 数据访问层
|
||||
│ ├── PuzzleTemplateMapper.java
|
||||
│ ├── PuzzleElementMapper.java
|
||||
│ └── PuzzleGenerationRecordMapper.java
|
||||
│ ├── PuzzleGenerationRecordMapper.java
|
||||
│ ├── PuzzleFillRuleMapper.java
|
||||
│ └── PuzzleFillRuleItemMapper.java
|
||||
│ # 拼图引擎会复用基础域的 com.ycwl.basic.mapper.SourceMapper(不在 puzzle 包内)
|
||||
├── entity/ # 实体类
|
||||
│ ├── PuzzleTemplateEntity.java # 模板实体
|
||||
│ ├── PuzzleElementEntity.java # 元素实体
|
||||
│ └── PuzzleGenerationRecordEntity.java # 生成记录实体
|
||||
│ ├── PuzzleGenerationRecordEntity.java # 生成记录实体
|
||||
│ ├── PuzzleFillRuleEntity.java # 填充规则实体
|
||||
│ └── PuzzleFillRuleItemEntity.java # 填充规则明细实体
|
||||
├── dto/ # 数据传输对象
|
||||
│ ├── PuzzleGenerateRequest.java # 生成请求
|
||||
│ ├── PuzzleGenerateResponse.java # 生成响应
|
||||
│ ├── PuzzleTemplateDTO.java # 模板DTO
|
||||
│ ├── PuzzleElementDTO.java # 元素DTO
|
||||
│ ├── TemplateCreateRequest.java # 模板创建请求
|
||||
│ └── ElementCreateRequest.java # 元素创建请求
|
||||
│ ├── ElementCreateRequest.java # 元素创建请求
|
||||
│ ├── PuzzleFillRuleSaveRequest.java # 填充规则保存请求
|
||||
│ ├── PuzzleFillRuleDTO.java # 填充规则DTO
|
||||
│ └── PuzzleFillRuleItemDTO.java # 填充规则明细DTO
|
||||
├── fill/ # 自动填充引擎
|
||||
│ ├── PuzzleElementFillEngine.java # 填充引擎(核心)
|
||||
│ ├── condition/ # 条件策略
|
||||
│ │ ├── ConditionStrategy.java
|
||||
│ │ ├── ConditionEvaluator.java
|
||||
│ │ ├── ConditionContext.java
|
||||
│ │ ├── AlwaysConditionStrategy.java
|
||||
│ │ ├── DeviceCountConditionStrategy.java
|
||||
│ │ ├── DeviceCountRangeConditionStrategy.java
|
||||
│ │ └── DeviceIdMatchConditionStrategy.java
|
||||
│ ├── datasource/ # 数据源解析
|
||||
│ │ ├── DataSourceResolver.java
|
||||
│ │ ├── DeviceImageDataSourceStrategy.java
|
||||
│ │ ├── FaceUrlDataSourceStrategy.java
|
||||
│ │ └── StaticValueDataSourceStrategy.java
|
||||
│ └── enums/ # 枚举定义
|
||||
│ ├── ConditionType.java
|
||||
│ ├── DataSourceType.java
|
||||
│ └── SortStrategy.java
|
||||
└── util/ # 工具类
|
||||
└── PuzzleImageRenderer.java # 图片渲染引擎(核心)
|
||||
```
|
||||
@@ -57,8 +88,16 @@ puzzle/
|
||||
|
||||
1. **服务层模式(Service Layer)**:业务逻辑封装在service层,controller只负责接口适配
|
||||
2. **DTO模式**:使用独立的DTO对象处理API输入输出,与Entity分离
|
||||
3. **策略模式**:图片适配模式(CONTAIN、COVER、FILL等)
|
||||
3. **策略模式**:图片适配模式(CONTAIN、COVER、FILL等)、条件匹配策略、数据源解析策略、排序策略
|
||||
4. **建造者模式**:通过模板+元素配置构建最终图片
|
||||
5. **责任链模式**:自动填充规则按优先级顺序匹配执行
|
||||
|
||||
## 🔐 运行时安全与一致性保证
|
||||
|
||||
- **景区隔离强校验**:PuzzleGenerateServiceImpl 会根据模板的 `scenicId` 判断是否允许当前请求生成图片;模板绑定了景区时,请求必须传入相同的 `scenicId`,否则直接拒绝,避免跨租户串用模板。
|
||||
- **自动填充参数兜底**:自动填充引擎 `PuzzleElementFillEngine` 仅在 `faceId + scenicId` 同时存在时触发,且规则没有明细项时会继续匹配下一条,防止空规则截断后续逻辑。
|
||||
- **元素类型白名单**:`ElementConfigHelper` 仅允许 `ElementType` 枚举中已经落地的 TEXT、IMAGE 类型入库,杜绝未实现类型绕过验证。
|
||||
- **图片下载防护**:`ImageElement` 只接受公网 http/https 地址,自动阻断内网、环回以及 file:// 资源,并设置请求超时,缓解 SSRF 与资源阻塞风险。
|
||||
|
||||
---
|
||||
|
||||
@@ -179,7 +218,93 @@ PuzzleGenerateResponse generate(PuzzleGenerateRequest request)
|
||||
|
||||
---
|
||||
|
||||
### 4. Controller接口层
|
||||
### 4. PuzzleElementFillEngine - 自动填充引擎(新功能)
|
||||
|
||||
**职责**:基于规则引擎自动选择和填充拼图元素的数据源
|
||||
|
||||
**核心概念**:
|
||||
- 通过配置化的规则(而非硬编码)决定每个元素使用哪些素材
|
||||
- 支持基于机位数量、机位ID、人脸特征等多维度条件匹配
|
||||
- 支持多种数据源(机位图片、用户头像、二维码等)
|
||||
- 支持灵活的排序策略(最新、评分、随机等)
|
||||
- 支持优先级和降级策略
|
||||
|
||||
**核心方法**:
|
||||
```java
|
||||
Map<String, String> execute(Long templateId, Long faceId, Long scenicId)
|
||||
```
|
||||
|
||||
**执行流程**:
|
||||
1. **加载规则列表**:
|
||||
- 查询指定模板的所有启用规则(`PuzzleFillRuleMapper.listByTemplateId`)
|
||||
- 按`priority`降序排序(优先级高的先执行)
|
||||
|
||||
2. **构建上下文**:
|
||||
- 查询faceId关联的机位数量(`SourceMapper.countDistinctDevicesByFaceId`)
|
||||
- 查询faceId关联的机位ID列表(`SourceMapper.getDeviceIdsByFaceId`)
|
||||
- 构建`ConditionContext`对象
|
||||
|
||||
3. **规则匹配**:
|
||||
- 遍历规则列表,调用`ConditionEvaluator.evaluate()`评估每条规则
|
||||
- 匹配到第一条符合条件的规则后停止(责任链模式)
|
||||
|
||||
4. **执行填充**:
|
||||
- 查询匹配规则的所有明细项(`PuzzleFillRuleItemMapper.listByRuleId`)
|
||||
- 按`itemOrder`排序
|
||||
- 对每条明细调用`DataSourceResolver.resolve()`解析数据源
|
||||
- 返回`Map<elementKey, dataValue>`
|
||||
|
||||
**条件策略(Strategy Pattern)**:
|
||||
|
||||
| 策略类型 | 类名 | 匹配逻辑 | 配置示例 |
|
||||
|---------|------|---------|---------|
|
||||
| 总是匹配 | AlwaysConditionStrategy | 总是返回true,用作兜底规则 | `{}` |
|
||||
| 机位数量匹配(模式1) | DeviceCountConditionStrategy | 实际机位数 ≥ deviceCount | `{"deviceCount": 4}` |
|
||||
| 机位数量匹配(模式2) | DeviceCountConditionStrategy | 从指定列表中过滤并匹配数量 ≥ deviceCount,只取前N个,保持配置顺序 | `{"deviceCount": 2, "deviceIds": [200, 300, 400]}` |
|
||||
| 机位ID匹配 | DeviceIdMatchConditionStrategy | 匹配指定的机位ID(支持ANY/ALL模式) | `{"deviceIds": [200, 300], "matchMode": "ALL"}` |
|
||||
|
||||
**数据源类型**:
|
||||
|
||||
| 类型 | 说明 | sourceFilter 配置 |
|
||||
|------|------|------------------|
|
||||
| DEVICE_IMAGE | 机位图片 | `{"deviceIndex": 0, "type": 2}` - deviceIndex指定使用第几个机位,type指定图片类型 |
|
||||
| USER_AVATAR | 用户头像 | `{}` |
|
||||
| QR_CODE | 二维码 | `{"content": "{orderId}"}` - 支持变量替换 |
|
||||
|
||||
**排序策略**:
|
||||
|
||||
| 策略 | 说明 |
|
||||
|------|------|
|
||||
| LATEST | 最新优先(按创建时间降序) |
|
||||
| EARLIEST | 最早优先(按创建时间升序) |
|
||||
| SCORE_DESC | 评分降序(适用于有评分的素材) |
|
||||
| SCORE_ASC | 评分升序 |
|
||||
| RANDOM | 随机选择 |
|
||||
|
||||
**降级策略**:
|
||||
- 每条明细可配置`fallbackValue`
|
||||
- 当数据源无法获取到值时,使用降级默认值
|
||||
- 如果降级值也为空,则跳过该元素的填充
|
||||
|
||||
**技术要点**:
|
||||
- 使用Spring的`@Component`自动注册策略
|
||||
- 使用Jackson解析JSON配置
|
||||
- 缓存机位数量和机位列表,单次执行仅查询一次
|
||||
- 详细日志记录规则匹配和填充过程
|
||||
|
||||
**使用场景**:
|
||||
- 根据机位数量选择不同布局(4机位用4宫格,6机位用六宫格)
|
||||
- 优先使用高质量机位的图片(指定机位200、300)
|
||||
- 多机位组合场景(只有机位A和B同时存在时使用特定布局)
|
||||
|
||||
**性能优化**:
|
||||
- 规则数量建议不超过10条/模板
|
||||
- 优先级高的规则应配置更精确的条件
|
||||
- 使用`ALWAYS`策略作为兜底,确保总有规则匹配
|
||||
|
||||
---
|
||||
|
||||
### 5. Controller接口层
|
||||
|
||||
#### PuzzleGenerateController
|
||||
```java
|
||||
@@ -337,9 +462,66 @@ POST /puzzle/generate
|
||||
|
||||
---
|
||||
|
||||
### 4. puzzle_fill_rule - 拼图填充规则表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|-----|
|
||||
| id | BIGINT | 主键ID |
|
||||
| template_id | BIGINT | 关联的模板ID(外键) |
|
||||
| rule_name | VARCHAR(100) | 规则名称 |
|
||||
| condition_type | VARCHAR(50) | 条件类型:DEVICE_COUNT/DEVICE_COUNT_RANGE/DEVICE_ID_MATCH/ALWAYS |
|
||||
| condition_value | TEXT | 条件配置(JSON格式) |
|
||||
| priority | INT | 优先级(数值越大越优先) |
|
||||
| enabled | TINYINT | 是否启用:0-禁用 1-启用 |
|
||||
| scenic_id | BIGINT | 景区ID(多租户隔离) |
|
||||
| description | TEXT | 规则描述 |
|
||||
| create_time | DATETIME | 创建时间 |
|
||||
| update_time | DATETIME | 更新时间 |
|
||||
| deleted | TINYINT | 删除标记:0-未删除 1-已删除 |
|
||||
| deleted_at | DATETIME | 删除时间 |
|
||||
|
||||
**索引**:
|
||||
- KEY `idx_template_scenic` (template_id, scenic_id, deleted)
|
||||
- KEY `idx_priority` (priority)
|
||||
|
||||
**业务逻辑**:
|
||||
- 规则按`priority`降序排列执行
|
||||
- 匹配到第一条符合条件的规则后停止
|
||||
- 建议使用`ALWAYS`类型作为兜底规则(最低优先级)
|
||||
|
||||
---
|
||||
|
||||
### 5. puzzle_fill_rule_item - 拼图填充规则明细表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|-----|
|
||||
| id | BIGINT | 主键ID |
|
||||
| rule_id | BIGINT | 关联的规则ID(外键) |
|
||||
| element_key | VARCHAR(50) | 目标元素标识(对应puzzle_element的element_key) |
|
||||
| data_source | VARCHAR(50) | 数据源类型:DEVICE_IMAGE/USER_AVATAR/QR_CODE等 |
|
||||
| source_filter | TEXT | 数据源过滤条件(JSON格式) |
|
||||
| sort_strategy | VARCHAR(50) | 排序策略:LATEST/EARLIEST/SCORE_DESC/SCORE_ASC/RANDOM |
|
||||
| fallback_value | VARCHAR(500) | 降级默认值(数据源无法获取时使用) |
|
||||
| item_order | INT | 明细排序(决定执行顺序) |
|
||||
| create_time | DATETIME | 创建时间 |
|
||||
| update_time | DATETIME | 更新时间 |
|
||||
| deleted | TINYINT | 删除标记:0-未删除 1-已删除 |
|
||||
| deleted_at | DATETIME | 删除时间 |
|
||||
|
||||
**索引**:
|
||||
- KEY `idx_rule_id` (rule_id, deleted)
|
||||
- KEY `idx_element_key` (element_key)
|
||||
|
||||
**业务逻辑**:
|
||||
- 明细项按`item_order`升序执行
|
||||
- 每条明细对应一个元素的填充逻辑
|
||||
- 支持降级策略(fallbackValue)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 关键业务流程
|
||||
|
||||
### 拼图生成完整流程
|
||||
### 拼图生成完整流程(含自动填充)
|
||||
|
||||
```
|
||||
用户请求 → Controller接收
|
||||
@@ -350,6 +532,16 @@ POST /puzzle/generate
|
||||
↓
|
||||
根据templateId查询所有元素(按z-index排序)
|
||||
↓
|
||||
【新增】调用PuzzleElementFillEngine.execute()(自动填充)
|
||||
├─ 查询该模板的所有填充规则(按优先级排序)
|
||||
├─ 构建ConditionContext(机位数量、机位列表等)
|
||||
├─ 遍历规则进行条件匹配
|
||||
├─ 找到匹配规则后,加载其明细列表
|
||||
├─ 对每条明细调用DataSourceResolver解析数据源
|
||||
└─ 返回Map<elementKey, dataValue>
|
||||
↓
|
||||
合并自动填充数据和用户手动数据(用户数据优先级更高)
|
||||
↓
|
||||
调用PuzzleImageRenderer.render()
|
||||
├─ 创建画布
|
||||
├─ 绘制背景
|
||||
@@ -390,22 +582,6 @@ POST /puzzle/generate
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 核心依赖
|
||||
- **Spring Boot**:框架基础
|
||||
- **MyBatis Plus**:数据访问
|
||||
- **Lombok**:减少样板代码
|
||||
- **Hutool**:工具类库(图片处理、HTTP下载)
|
||||
- **Java AWT/ImageIO**:图形绘制和图片处理
|
||||
- **SLF4J/Logback**:日志
|
||||
|
||||
### 外部依赖
|
||||
- **OSS对象存储**:图片上传和存储
|
||||
- **MySQL**:关系型数据库
|
||||
|
||||
---
|
||||
|
||||
## 📦 对外依赖
|
||||
|
||||
puzzle模块与其他模块的依赖关系:
|
||||
@@ -590,22 +766,3 @@ request.setQuality(90);
|
||||
PuzzleGenerateResponse response = generateService.generate(request);
|
||||
System.out.println("生成成功,图片URL: " + response.getImageUrl());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [MyBatis-Plus官方文档](https://baomidou.com/)
|
||||
- [Hutool工具类文档](https://hutool.cn/)
|
||||
- [Java AWT图形绘制教程](https://docs.oracle.com/javase/tutorial/2d/)
|
||||
- [阿里云OSS Java SDK](https://help.aliyun.com/document_detail/32008.html)
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
如有问题或建议,请联系模块负责人或提交Issue。
|
||||
|
||||
**维护者**:Claude
|
||||
**创建时间**:2025-01-17
|
||||
**最后更新**:2025-01-17
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.ycwl.basic.puzzle.controller;
|
||||
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleDTO;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleSaveRequest;
|
||||
import com.ycwl.basic.puzzle.service.IPuzzleFillRuleService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 拼图填充规则管理Controller
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-19
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/puzzle/admin/fill-rule")
|
||||
@RequiredArgsConstructor
|
||||
public class PuzzleFillRuleController {
|
||||
|
||||
private final IPuzzleFillRuleService fillRuleService;
|
||||
|
||||
/**
|
||||
* 创建填充规则
|
||||
*
|
||||
* @param request 保存请求(包含主规则+明细列表)
|
||||
* @return 规则ID
|
||||
*/
|
||||
@PostMapping
|
||||
public ApiResponse<Long> create(@RequestBody PuzzleFillRuleSaveRequest request) {
|
||||
log.info("创建填充规则, ruleName={}, itemCount={}",
|
||||
request.getRuleName(),
|
||||
request.getItems() != null ? request.getItems().size() : 0);
|
||||
|
||||
Long ruleId = fillRuleService.create(request);
|
||||
return ApiResponse.success(ruleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新填充规则
|
||||
*
|
||||
* @param id 规则ID
|
||||
* @param request 保存请求
|
||||
* @return 是否成功
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<Boolean> update(@PathVariable Long id,
|
||||
@RequestBody PuzzleFillRuleSaveRequest request) {
|
||||
log.info("更新填充规则, ruleId={}, ruleName={}", id, request.getRuleName());
|
||||
|
||||
request.setId(id);
|
||||
Boolean success = fillRuleService.update(request);
|
||||
return ApiResponse.success(success);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除填充规则
|
||||
*
|
||||
* @param id 规则ID
|
||||
* @return 是否成功
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||
log.info("删除填充规则, ruleId={}", id);
|
||||
|
||||
Boolean success = fillRuleService.delete(id);
|
||||
return ApiResponse.success(success);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单条规则(含明细)
|
||||
*
|
||||
* @param id 规则ID
|
||||
* @return 规则DTO
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<PuzzleFillRuleDTO> getById(@PathVariable Long id) {
|
||||
log.info("查询填充规则, ruleId={}", id);
|
||||
|
||||
PuzzleFillRuleDTO rule = fillRuleService.getById(id);
|
||||
return ApiResponse.success(rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询模板的所有规则(含明细)
|
||||
*
|
||||
* @param templateId 模板ID
|
||||
* @return 规则列表
|
||||
*/
|
||||
@GetMapping("/template/{templateId}")
|
||||
public ApiResponse<List<PuzzleFillRuleDTO>> listByTemplateId(@PathVariable Long templateId) {
|
||||
log.info("查询模板的所有填充规则, templateId={}", templateId);
|
||||
|
||||
List<PuzzleFillRuleDTO> rules = fillRuleService.listByTemplateId(templateId);
|
||||
return ApiResponse.success(rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用规则
|
||||
*
|
||||
* @param id 规则ID
|
||||
* @param enabled 是否启用(0-禁用 1-启用)
|
||||
* @return 是否成功
|
||||
*/
|
||||
@PostMapping("/{id}/toggle")
|
||||
public ApiResponse<Boolean> toggleEnabled(@PathVariable Long id,
|
||||
@RequestParam Integer enabled) {
|
||||
log.info("切换规则启用状态, ruleId={}, enabled={}", id, enabled);
|
||||
|
||||
Boolean success = fillRuleService.toggleEnabled(id, enabled);
|
||||
return ApiResponse.success(success);
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ public class PuzzleGenerateController {
|
||||
*/
|
||||
@PostMapping("/generate")
|
||||
public ApiResponse<PuzzleGenerateResponse> generatePuzzle(@RequestBody PuzzleGenerateRequest request) {
|
||||
log.info("拼图生成请求: templateCode={}, userId={}, orderId={}",
|
||||
request.getTemplateCode(), request.getUserId(), request.getOrderId());
|
||||
log.info("拼图生成请求: templateCode={}, userId={}, faceId={}",
|
||||
request.getTemplateCode(), request.getUserId(), request.getFaceId());
|
||||
|
||||
// 参数校验
|
||||
if (request.getTemplateCode() == null || request.getTemplateCode().trim().isEmpty()) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ycwl.basic.puzzle.controller;
|
||||
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
|
||||
@@ -80,6 +81,22 @@ public class PuzzleTemplateController {
|
||||
return ApiResponse.success(templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取模板列表
|
||||
*/
|
||||
@GetMapping("/templates/page")
|
||||
public ApiResponse<PageResponse<PuzzleTemplateDTO>> pageTemplates(
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
@RequestParam(defaultValue = "10") Integer pageSize,
|
||||
@RequestParam(required = false) Long scenicId,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) Integer status) {
|
||||
log.debug("分页查询模板列表: page={}, pageSize={}, scenicId={}, category={}, status={}",
|
||||
page, pageSize, scenicId, category, status);
|
||||
PageResponse<PuzzleTemplateDTO> templates = templateService.pageTemplates(page, pageSize, scenicId, category, status);
|
||||
return ApiResponse.success(templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为模板添加单个元素
|
||||
*/
|
||||
@@ -101,6 +118,17 @@ public class PuzzleTemplateController {
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量替换模板元素(删除旧元素,添加新元素)
|
||||
*/
|
||||
@PutMapping("/templates/{templateId}/elements")
|
||||
public ApiResponse<Void> replaceElements(@PathVariable Long templateId,
|
||||
@RequestBody List<ElementCreateRequest> elements) {
|
||||
log.info("批量替换元素请求: templateId={}, count={}", templateId, elements.size());
|
||||
templateService.replaceElements(templateId, elements);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新元素
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ycwl.basic.puzzle.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -22,21 +23,25 @@ public class ElementCreateRequest {
|
||||
/**
|
||||
* 模板ID
|
||||
*/
|
||||
@JsonProperty("templateId")
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
|
||||
*/
|
||||
@JsonProperty("elementType")
|
||||
private String elementType;
|
||||
|
||||
/**
|
||||
* 元素标识(用于动态数据映射)
|
||||
*/
|
||||
@JsonProperty("elementKey")
|
||||
private String elementKey;
|
||||
|
||||
/**
|
||||
* 元素名称(便于管理识别)
|
||||
*/
|
||||
@JsonProperty("elementName")
|
||||
private String elementName;
|
||||
|
||||
// ===== 位置和布局属性(所有元素通用) =====
|
||||
@@ -44,36 +49,43 @@ public class ElementCreateRequest {
|
||||
/**
|
||||
* X坐标(相对于画布左上角,像素)
|
||||
*/
|
||||
@JsonProperty("xPosition")
|
||||
private Integer xPosition;
|
||||
|
||||
/**
|
||||
* Y坐标(相对于画布左上角,像素)
|
||||
*/
|
||||
@JsonProperty("yPosition")
|
||||
private Integer yPosition;
|
||||
|
||||
/**
|
||||
* 宽度(像素)
|
||||
*/
|
||||
@JsonProperty("width")
|
||||
private Integer width;
|
||||
|
||||
/**
|
||||
* 高度(像素)
|
||||
*/
|
||||
@JsonProperty("height")
|
||||
private Integer height;
|
||||
|
||||
/**
|
||||
* 层级(数值越大越靠上)
|
||||
*/
|
||||
@JsonProperty("zIndex")
|
||||
private Integer zIndex;
|
||||
|
||||
/**
|
||||
* 旋转角度(0-360度,顺时针)
|
||||
*/
|
||||
@JsonProperty("rotation")
|
||||
private Integer rotation;
|
||||
|
||||
/**
|
||||
* 不透明度(0-100,100为完全不透明)
|
||||
*/
|
||||
@JsonProperty("opacity")
|
||||
private Integer opacity;
|
||||
|
||||
// ===== JSON配置(二选一) =====
|
||||
@@ -85,6 +97,7 @@ public class ElementCreateRequest {
|
||||
* - 文字元素:"{\"defaultText\":\"用户名\", \"fontFamily\":\"微软雅黑\", \"fontSize\":14}"
|
||||
* - 图片元素:"{\"defaultImageUrl\":\"https://...\", \"imageFitMode\":\"COVER\", \"borderRadius\":10}"
|
||||
*/
|
||||
@JsonProperty("config")
|
||||
private String config;
|
||||
|
||||
/**
|
||||
@@ -96,5 +109,6 @@ public class ElementCreateRequest {
|
||||
* configMap.put("fontSize", 14);
|
||||
* request.setConfigMap(configMap);
|
||||
*/
|
||||
@JsonProperty("configMap")
|
||||
private Map<String, Object> configMap;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ycwl.basic.puzzle.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -21,26 +22,31 @@ public class PuzzleElementDTO {
|
||||
/**
|
||||
* 元素ID
|
||||
*/
|
||||
@JsonProperty("id")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 模板ID
|
||||
*/
|
||||
@JsonProperty("templateId")
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 元素类型(TEXT-文字 IMAGE-图片 QRCODE-二维码等)
|
||||
*/
|
||||
@JsonProperty("elementType")
|
||||
private String elementType;
|
||||
|
||||
/**
|
||||
* 元素标识(用于动态数据映射)
|
||||
*/
|
||||
@JsonProperty("elementKey")
|
||||
private String elementKey;
|
||||
|
||||
/**
|
||||
* 元素名称(便于管理识别)
|
||||
*/
|
||||
@JsonProperty("elementName")
|
||||
private String elementName;
|
||||
|
||||
// ===== 位置和布局属性(所有元素通用) =====
|
||||
@@ -48,36 +54,43 @@ public class PuzzleElementDTO {
|
||||
/**
|
||||
* X坐标(相对于画布左上角,像素)
|
||||
*/
|
||||
@JsonProperty("xPosition")
|
||||
private Integer xPosition;
|
||||
|
||||
/**
|
||||
* Y坐标(相对于画布左上角,像素)
|
||||
*/
|
||||
@JsonProperty("yPosition")
|
||||
private Integer yPosition;
|
||||
|
||||
/**
|
||||
* 宽度(像素)
|
||||
*/
|
||||
@JsonProperty("width")
|
||||
private Integer width;
|
||||
|
||||
/**
|
||||
* 高度(像素)
|
||||
*/
|
||||
@JsonProperty("height")
|
||||
private Integer height;
|
||||
|
||||
/**
|
||||
* 层级(数值越大越靠上)
|
||||
*/
|
||||
@JsonProperty("zIndex")
|
||||
private Integer zIndex;
|
||||
|
||||
/**
|
||||
* 旋转角度(0-360度,顺时针)
|
||||
*/
|
||||
@JsonProperty("rotation")
|
||||
private Integer rotation;
|
||||
|
||||
/**
|
||||
* 不透明度(0-100,100为完全不透明)
|
||||
*/
|
||||
@JsonProperty("opacity")
|
||||
private Integer opacity;
|
||||
|
||||
// ===== JSON配置 =====
|
||||
@@ -85,10 +98,12 @@ public class PuzzleElementDTO {
|
||||
/**
|
||||
* JSON配置字符串
|
||||
*/
|
||||
@JsonProperty("config")
|
||||
private String config;
|
||||
|
||||
/**
|
||||
* JSON配置Map(方便前端使用)
|
||||
*/
|
||||
@JsonProperty("configMap")
|
||||
private Map<String, Object> configMap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.ycwl.basic.puzzle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 拼图填充规则DTO
|
||||
*/
|
||||
@Data
|
||||
public class PuzzleFillRuleDTO {
|
||||
|
||||
/**
|
||||
* 规则ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 关联的模板ID
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 规则名称
|
||||
*/
|
||||
private String ruleName;
|
||||
|
||||
/**
|
||||
* 条件类型
|
||||
*/
|
||||
private String conditionType;
|
||||
|
||||
/**
|
||||
* 条件值(JSON字符串)
|
||||
*/
|
||||
private String conditionValue;
|
||||
|
||||
/**
|
||||
* 优先级
|
||||
*/
|
||||
private Integer priority;
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
/**
|
||||
* 规则描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 明细列表
|
||||
*/
|
||||
private List<PuzzleFillRuleItemDTO> items;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ycwl.basic.puzzle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 拼图填充规则明细DTO
|
||||
*/
|
||||
@Data
|
||||
public class PuzzleFillRuleItemDTO {
|
||||
|
||||
/**
|
||||
* 明细ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 目标元素标识
|
||||
*/
|
||||
private String elementKey;
|
||||
|
||||
/**
|
||||
* 数据源类型
|
||||
*/
|
||||
private String dataSource;
|
||||
|
||||
/**
|
||||
* 数据过滤条件(JSON字符串)
|
||||
*/
|
||||
private String sourceFilter;
|
||||
|
||||
/**
|
||||
* 排序策略
|
||||
*/
|
||||
private String sortStrategy;
|
||||
|
||||
/**
|
||||
* 降级默认值
|
||||
*/
|
||||
private String fallbackValue;
|
||||
|
||||
/**
|
||||
* 明细排序
|
||||
*/
|
||||
private Integer itemOrder;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ycwl.basic.puzzle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 拼图填充规则保存请求
|
||||
* 包含主规则+明细列表
|
||||
*/
|
||||
@Data
|
||||
public class PuzzleFillRuleSaveRequest {
|
||||
|
||||
/**
|
||||
* 规则ID(更新时传入)
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 关联的模板ID
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 规则名称
|
||||
*/
|
||||
private String ruleName;
|
||||
|
||||
/**
|
||||
* 条件类型
|
||||
*/
|
||||
private String conditionType;
|
||||
|
||||
/**
|
||||
* 条件值(JSON字符串)
|
||||
*/
|
||||
private String conditionValue;
|
||||
|
||||
/**
|
||||
* 优先级
|
||||
*/
|
||||
private Integer priority;
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
/**
|
||||
* 规则描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 明细列表(主从一起保存)
|
||||
*/
|
||||
private List<PuzzleFillRuleItemDTO> items;
|
||||
}
|
||||
@@ -23,11 +23,6 @@ public class PuzzleGenerateRequest {
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 订单ID(可选)
|
||||
*/
|
||||
private String orderId;
|
||||
|
||||
/**
|
||||
* 业务类型(可选)
|
||||
*/
|
||||
@@ -38,9 +33,15 @@ public class PuzzleGenerateRequest {
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 人脸ID(可选,用于触发自动填充规则)
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 动态数据(key为元素的elementKey,value为实际值)
|
||||
* 例如:{"userAvatar": "https://...", "userName": "张三", "orderNumber": "ORDER123"}
|
||||
* 注意:手动传入的dynamicData优先级高于自动填充的数据
|
||||
*/
|
||||
private Map<String, String> dynamicData;
|
||||
|
||||
@@ -55,4 +56,11 @@ public class PuzzleGenerateRequest {
|
||||
* 仅对JPEG格式有效
|
||||
*/
|
||||
private Integer quality;
|
||||
|
||||
/**
|
||||
* 是否必须匹配填充规则才能生成(可选,默认false)
|
||||
* true: 如果没有规则匹配,抛出异常,不生成图片
|
||||
* false: 无论是否匹配规则,都继续生成(默认行为)
|
||||
*/
|
||||
private Boolean requireRuleMatch = false;
|
||||
}
|
||||
|
||||
@@ -46,9 +46,28 @@ public class PuzzleGenerateResponse {
|
||||
private Long recordId;
|
||||
|
||||
/**
|
||||
* 创建成功响应
|
||||
* 是否为复用历史记录
|
||||
* true-复用历史图片(未重新渲染), false-新生成
|
||||
*/
|
||||
private Boolean isDuplicate;
|
||||
|
||||
/**
|
||||
* 原始记录ID
|
||||
* 当isDuplicate=true时,记录被复用的原始记录ID
|
||||
*/
|
||||
private Long originalRecordId;
|
||||
|
||||
/**
|
||||
* 创建成功响应(新生成)
|
||||
*/
|
||||
public static PuzzleGenerateResponse success(String imageUrl, Long fileSize, Integer width, Integer height, Integer duration, Long recordId) {
|
||||
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId);
|
||||
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成功响应(支持去重标记)
|
||||
*/
|
||||
public static PuzzleGenerateResponse success(String imageUrl, Long fileSize, Integer width, Integer height, Integer duration, Long recordId, Boolean isDuplicate, Long originalRecordId) {
|
||||
return new PuzzleGenerateResponse(imageUrl, fileSize, width, height, duration, recordId, isDuplicate, originalRecordId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ public class PuzzleTemplateDTO {
|
||||
*/
|
||||
private String backgroundImage;
|
||||
|
||||
/**
|
||||
* 模板封面图片URL
|
||||
*/
|
||||
private String coverImage;
|
||||
|
||||
/**
|
||||
* 模板描述
|
||||
*/
|
||||
|
||||
@@ -46,6 +46,11 @@ public class TemplateCreateRequest {
|
||||
*/
|
||||
private String backgroundImage;
|
||||
|
||||
/**
|
||||
* 模板封面图片URL
|
||||
*/
|
||||
private String coverImage;
|
||||
|
||||
/**
|
||||
* 模板描述
|
||||
*/
|
||||
|
||||
@@ -50,11 +50,12 @@ public class ImageConfig implements ElementConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// 校验图片URL格式(可选)
|
||||
if (StrUtil.isNotBlank(defaultImageUrl)) {
|
||||
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
||||
}
|
||||
// 校验图片URL
|
||||
if (StrUtil.isBlank(defaultImageUrl)) {
|
||||
throw new IllegalArgumentException("默认图片URL不能为空");
|
||||
}
|
||||
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.ycwl.basic.puzzle.element.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||
import com.ycwl.basic.puzzle.element.config.ImageConfig;
|
||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
@@ -14,7 +14,12 @@ import java.awt.geom.Ellipse2D;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 图片元素实现
|
||||
@@ -25,6 +30,8 @@ import java.io.File;
|
||||
@Slf4j
|
||||
public class ImageElement extends BaseElement {
|
||||
|
||||
private static final int DOWNLOAD_TIMEOUT_MS = 5000;
|
||||
|
||||
private ImageConfig imageConfig;
|
||||
|
||||
@Override
|
||||
@@ -105,29 +112,32 @@ public class ImageElement extends BaseElement {
|
||||
* @param imageUrl 图片URL或本地文件路径
|
||||
* @return BufferedImage对象
|
||||
*/
|
||||
private BufferedImage downloadImage(String imageUrl) {
|
||||
try {
|
||||
log.debug("下载图片: url={}", imageUrl);
|
||||
|
||||
// 判断是否为本地文件路径
|
||||
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
|
||||
// 网络图片
|
||||
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
|
||||
return ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||
} else {
|
||||
// 本地文件
|
||||
File file = new File(imageUrl);
|
||||
if (file.exists()) {
|
||||
return ImageIO.read(file);
|
||||
} else {
|
||||
log.error("本地图片文件不存在: path={}", imageUrl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("图片下载失败: url={}", imageUrl, e);
|
||||
protected BufferedImage downloadImage(String imageUrl) {
|
||||
if (StrUtil.isBlank(imageUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRemoteUrl(imageUrl)) {
|
||||
if (!isSafeRemoteUrl(imageUrl)) {
|
||||
log.warn("图片URL未通过安全校验, 已拒绝下载: {}", imageUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("下载图片: url={}", imageUrl);
|
||||
byte[] imageBytes = HttpRequest.get(imageUrl)
|
||||
.timeout(DOWNLOAD_TIMEOUT_MS)
|
||||
.setFollowRedirects(false)
|
||||
.execute()
|
||||
.bodyBytes();
|
||||
return ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||
} catch (Exception e) {
|
||||
log.error("图片下载失败: url={}", imageUrl, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return loadLocalImage(imageUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,4 +261,63 @@ public class ImageElement extends BaseElement {
|
||||
// 绘制到主画布
|
||||
g2d.drawImage(rounded, position.getX(), position.getY(), null);
|
||||
}
|
||||
|
||||
private boolean isRemoteUrl(String imageUrl) {
|
||||
return StrUtil.startWithIgnoreCase(imageUrl, "http://") ||
|
||||
StrUtil.startWithIgnoreCase(imageUrl, "https://");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断URL是否为安全的公网HTTP地址,避免SSRF
|
||||
*/
|
||||
protected boolean isSafeRemoteUrl(String imageUrl) {
|
||||
if (StrUtil.isBlank(imageUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
URL url = new URL(imageUrl);
|
||||
String protocol = url.getProtocol();
|
||||
if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
InetAddress address = InetAddress.getByName(url.getHost());
|
||||
if (address.isAnyLocalAddress()
|
||||
|| address.isLoopbackAddress()
|
||||
|| address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("图片URL解析失败: {}", imageUrl, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage loadLocalImage(String imageUrl) {
|
||||
try {
|
||||
Path path;
|
||||
if (StrUtil.startWithIgnoreCase(imageUrl, "file:")) {
|
||||
path = Paths.get(new URI(imageUrl));
|
||||
} else {
|
||||
path = Paths.get(imageUrl);
|
||||
}
|
||||
|
||||
if (!Files.exists(path) || !Files.isRegularFile(path)) {
|
||||
log.error("本地图片文件不存在: {}", imageUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug("加载本地图片: {}", path);
|
||||
try (var inputStream = Files.newInputStream(path)) {
|
||||
return ImageIO.read(inputStream);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("本地图片加载失败: {}", imageUrl, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +141,12 @@ public class TextElement extends BaseElement {
|
||||
? textConfig.getTextAlign().toUpperCase()
|
||||
: "LEFT";
|
||||
|
||||
// 起始Y坐标
|
||||
int y = position.getY() + fm.getAscent();
|
||||
// 计算总文本高度并实现垂直居中
|
||||
int totalTextHeight = lineHeight * actualLines;
|
||||
int verticalOffset = (position.getHeight() - totalTextHeight) / 2;
|
||||
|
||||
// 起始Y坐标(垂直居中)
|
||||
int y = position.getY() + verticalOffset + fm.getAscent();
|
||||
|
||||
// 逐行绘制
|
||||
for (int i = 0; i < actualLines; i++) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ycwl.basic.puzzle.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 拼图自动填充规则实体
|
||||
*/
|
||||
@Data
|
||||
@TableName("puzzle_fill_rule")
|
||||
public class PuzzleFillRuleEntity {
|
||||
|
||||
/**
|
||||
* 规则ID
|
||||
*/
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 关联的模板ID
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 规则名称
|
||||
*/
|
||||
private String ruleName;
|
||||
|
||||
/**
|
||||
* 条件类型: DEVICE_COUNT-机位数量
|
||||
*/
|
||||
private String conditionType;
|
||||
|
||||
/**
|
||||
* 条件值(JSON格式)
|
||||
*/
|
||||
private String conditionValue;
|
||||
|
||||
/**
|
||||
* 优先级(数值越大越优先)
|
||||
*/
|
||||
private Integer priority;
|
||||
|
||||
/**
|
||||
* 是否启用(0-否 1-是)
|
||||
*/
|
||||
private Integer enabled;
|
||||
|
||||
/**
|
||||
* 规则描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 删除标记(0-未删除 1-已删除)
|
||||
*/
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ycwl.basic.puzzle.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 拼图自动填充规则明细实体
|
||||
*/
|
||||
@Data
|
||||
@TableName("puzzle_fill_rule_item")
|
||||
public class PuzzleFillRuleItemEntity {
|
||||
|
||||
/**
|
||||
* 明细ID
|
||||
*/
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 关联的规则ID
|
||||
*/
|
||||
private Long ruleId;
|
||||
|
||||
/**
|
||||
* 目标元素标识
|
||||
*/
|
||||
private String elementKey;
|
||||
|
||||
/**
|
||||
* 数据源类型: FACE_URL, SOURCE_IMAGE, DEVICE_IMAGE等
|
||||
*/
|
||||
private String dataSource;
|
||||
|
||||
/**
|
||||
* 数据过滤条件(JSON格式)
|
||||
*/
|
||||
private String sourceFilter;
|
||||
|
||||
/**
|
||||
* 排序策略: LATEST-最新, SCORE_DESC-分数降序
|
||||
*/
|
||||
private String sortStrategy;
|
||||
|
||||
/**
|
||||
* 降级默认值
|
||||
*/
|
||||
private String fallbackValue;
|
||||
|
||||
/**
|
||||
* 明细排序
|
||||
*/
|
||||
private Integer itemOrder;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -44,10 +44,10 @@ public class PuzzleGenerationRecordEntity {
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 关联订单号
|
||||
* 人脸ID(用于关联素材和追溯)
|
||||
*/
|
||||
@TableField("order_id")
|
||||
private String orderId;
|
||||
@TableField("face_id")
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 业务类型(如:order-订单 ticket-门票 certificate-证书)
|
||||
@@ -63,6 +63,13 @@ public class PuzzleGenerationRecordEntity {
|
||||
@TableField("generation_params")
|
||||
private String generationParams;
|
||||
|
||||
/**
|
||||
* 内容哈希(SHA256)
|
||||
* 用于去重检测,基于所有元素的实际内容计算
|
||||
*/
|
||||
@TableField("content_hash")
|
||||
private String contentHash;
|
||||
|
||||
/**
|
||||
* 生成的图片URL
|
||||
*/
|
||||
|
||||
@@ -67,6 +67,12 @@ public class PuzzleTemplateEntity {
|
||||
@TableField("background_image")
|
||||
private String backgroundImage;
|
||||
|
||||
/**
|
||||
* 模板封面图片URL(用于前端管理界面展示)
|
||||
*/
|
||||
@TableField("cover_image")
|
||||
private String coverImage;
|
||||
|
||||
/**
|
||||
* 模板描述
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ycwl.basic.puzzle.exception;
|
||||
|
||||
/**
|
||||
* 重复图片异常
|
||||
* 当所有图片元素使用相同URL时抛出此异常
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-21
|
||||
*/
|
||||
public class DuplicateImageException extends PuzzleBizException {
|
||||
|
||||
private static final String DEFAULT_MESSAGE_TEMPLATE = "检测到所有图片元素使用相同URL,拒绝生成: %s (元素数量: %d)";
|
||||
|
||||
private final String duplicateImageUrl;
|
||||
private final int elementCount;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param duplicateImageUrl 重复的图片URL
|
||||
* @param elementCount 使用相同URL的元素数量
|
||||
*/
|
||||
public DuplicateImageException(String duplicateImageUrl, int elementCount) {
|
||||
super(String.format(DEFAULT_MESSAGE_TEMPLATE, duplicateImageUrl, elementCount));
|
||||
this.duplicateImageUrl = duplicateImageUrl;
|
||||
this.elementCount = elementCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造函数(带自定义消息)
|
||||
*
|
||||
* @param message 自定义错误消息
|
||||
* @param duplicateImageUrl 重复的图片URL
|
||||
* @param elementCount 元素数量
|
||||
*/
|
||||
public DuplicateImageException(String message, String duplicateImageUrl, int elementCount) {
|
||||
super(message);
|
||||
this.duplicateImageUrl = duplicateImageUrl;
|
||||
this.elementCount = elementCount;
|
||||
}
|
||||
|
||||
public String getDuplicateImageUrl() {
|
||||
return duplicateImageUrl;
|
||||
}
|
||||
|
||||
public int getElementCount() {
|
||||
return elementCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ycwl.basic.puzzle.exception;
|
||||
|
||||
public class PuzzleBizException extends RuntimeException {
|
||||
public PuzzleBizException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
66
src/main/java/com/ycwl/basic/puzzle/fill/FillResult.java
Normal file
66
src/main/java/com/ycwl/basic/puzzle/fill/FillResult.java
Normal file
@@ -0,0 +1,66 @@
|
||||
package com.ycwl.basic.puzzle.fill;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 拼图元素填充结果
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-20
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FillResult {
|
||||
|
||||
/**
|
||||
* 是否匹配到规则
|
||||
*/
|
||||
private boolean ruleMatched;
|
||||
|
||||
/**
|
||||
* 匹配的规则名称(如果有)
|
||||
*/
|
||||
private String matchedRuleName;
|
||||
|
||||
/**
|
||||
* 填充的数据
|
||||
*/
|
||||
@Builder.Default
|
||||
private Map<String, String> dynamicData = new HashMap<>();
|
||||
|
||||
/**
|
||||
* 成功填充的元素数量
|
||||
*/
|
||||
private int filledCount;
|
||||
|
||||
/**
|
||||
* 创建空结果(未匹配)
|
||||
*/
|
||||
public static FillResult noMatch() {
|
||||
return FillResult.builder()
|
||||
.ruleMatched(false)
|
||||
.dynamicData(new HashMap<>())
|
||||
.filledCount(0)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建匹配成功的结果
|
||||
*/
|
||||
public static FillResult matched(String ruleName, Map<String, String> data, int count) {
|
||||
return FillResult.builder()
|
||||
.ruleMatched(true)
|
||||
.matchedRuleName(ruleName)
|
||||
.dynamicData(data)
|
||||
.filledCount(count)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.ycwl.basic.puzzle.fill;
|
||||
|
||||
import com.ycwl.basic.mapper.SourceMapper;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity;
|
||||
import com.ycwl.basic.puzzle.fill.condition.ConditionContext;
|
||||
import com.ycwl.basic.puzzle.fill.condition.ConditionEvaluator;
|
||||
import com.ycwl.basic.puzzle.fill.datasource.DataSourceContext;
|
||||
import com.ycwl.basic.puzzle.fill.datasource.DataSourceResolver;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleItemMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 拼图元素自动填充引擎
|
||||
* 核心业务逻辑:
|
||||
* 1. 查询模板的所有规则(按priority DESC)
|
||||
* 2. 遍历规则,评估条件是否匹配
|
||||
* 3. 匹配成功后,批量填充该规则的所有明细项
|
||||
* 4. 匹配第一条后停止(优先级逻辑)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PuzzleElementFillEngine {
|
||||
|
||||
@Autowired
|
||||
private PuzzleFillRuleMapper ruleMapper;
|
||||
|
||||
@Autowired
|
||||
private PuzzleFillRuleItemMapper itemMapper;
|
||||
|
||||
@Autowired
|
||||
private SourceMapper sourceMapper;
|
||||
|
||||
@Autowired
|
||||
private ConditionEvaluator conditionEvaluator;
|
||||
|
||||
@Autowired
|
||||
private DataSourceResolver dataSourceResolver;
|
||||
|
||||
/**
|
||||
* 执行填充规则
|
||||
*
|
||||
* @param templateId 模板ID
|
||||
* @param faceId 人脸ID
|
||||
* @param scenicId 景区ID
|
||||
* @return 填充结果(包含是否匹配规则的信息)
|
||||
*/
|
||||
public FillResult execute(Long templateId, Long faceId, Long scenicId) {
|
||||
if (faceId == null) {
|
||||
log.debug("自动填充被跳过, templateId={}, faceId={}", templateId, faceId);
|
||||
return FillResult.noMatch();
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 查询模板的所有启用规则(按priority DESC排序)
|
||||
List<PuzzleFillRuleEntity> rules = ruleMapper.listByTemplateId(templateId);
|
||||
if (rules == null || rules.isEmpty()) {
|
||||
log.debug("模板[{}]没有配置自动填充规则", templateId);
|
||||
return FillResult.noMatch();
|
||||
}
|
||||
|
||||
log.info("模板[{}]共有{}条填充规则,开始执行...", templateId, rules.size());
|
||||
|
||||
// 2. 统计机位数量和获取机位列表(缓存,避免重复查询)
|
||||
Integer deviceCount = sourceMapper.countDistinctDevicesByFaceId(faceId);
|
||||
List<Long> deviceIds = sourceMapper.getDeviceIdsByFaceId(faceId);
|
||||
log.debug("faceId[{}]关联机位数量: {}, 机位列表: {}", faceId, deviceCount, deviceIds);
|
||||
|
||||
// 3. 构建条件评估上下文
|
||||
ConditionContext conditionContext = ConditionContext.builder()
|
||||
.faceId(faceId)
|
||||
.deviceCount(deviceCount)
|
||||
.deviceIds(deviceIds)
|
||||
.build();
|
||||
|
||||
// 4. 遍历规则,匹配第一条后停止
|
||||
for (PuzzleFillRuleEntity rule : rules) {
|
||||
// 评估条件是否匹配
|
||||
boolean matched = conditionEvaluator.evaluate(rule, conditionContext);
|
||||
|
||||
if (!matched) {
|
||||
log.debug("规则[{}]条件不匹配,跳过", rule.getRuleName());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 条件匹配!查询该规则的所有明细
|
||||
log.info("规则[{}]匹配成功,开始执行填充...", rule.getRuleName());
|
||||
List<PuzzleFillRuleItemEntity> items = itemMapper.listByRuleId(rule.getId());
|
||||
|
||||
if (items == null || items.isEmpty()) {
|
||||
log.warn("规则[{}]没有配置明细项", rule.getRuleName());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. 批量填充dynamicData
|
||||
DataSourceContext dataSourceContext = DataSourceContext.builder()
|
||||
.faceId(faceId)
|
||||
.scenicId(scenicId)
|
||||
.extra(conditionContext.getExtra())
|
||||
.build();
|
||||
|
||||
Map<String, String> dynamicData = new HashMap<>();
|
||||
int successCount = 0;
|
||||
for (PuzzleFillRuleItemEntity item : items) {
|
||||
String value = dataSourceResolver.resolve(
|
||||
item.getDataSource(),
|
||||
item.getSourceFilter(),
|
||||
item.getSortStrategy(),
|
||||
item.getFallbackValue(),
|
||||
dataSourceContext
|
||||
);
|
||||
|
||||
if (value != null && !value.isEmpty()) {
|
||||
dynamicData.put(item.getElementKey(), value);
|
||||
successCount++;
|
||||
log.debug("填充成功: {} -> {}", item.getElementKey(), value);
|
||||
} else {
|
||||
log.debug("填充失败(值为空): {}", item.getElementKey());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("规则[{}]执行完成,成功填充{}/{}个元素", rule.getRuleName(), successCount, items.size());
|
||||
|
||||
// 6. 返回匹配成功的结果
|
||||
return FillResult.matched(rule.getRuleName(), dynamicData, successCount);
|
||||
}
|
||||
|
||||
// 所有规则都不匹配
|
||||
log.info("所有规则都不匹配, templateId={}, faceId={}", templateId, faceId);
|
||||
return FillResult.noMatch();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("自动填充引擎执行异常, templateId={}, faceId={}", templateId, faceId, e);
|
||||
return FillResult.noMatch();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ycwl.basic.puzzle.fill.condition;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ycwl.basic.puzzle.fill.enums.ConditionType;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 总是匹配策略(兜底规则)
|
||||
*/
|
||||
@Component
|
||||
public class AlwaysConditionStrategy implements ConditionStrategy {
|
||||
|
||||
@Override
|
||||
public boolean evaluate(JsonNode conditionValue, ConditionContext context) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSupportedType() {
|
||||
return ConditionType.ALWAYS.getCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ycwl.basic.puzzle.fill.condition;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 条件评估上下文
|
||||
* 包含评估所需的各种运行时数据
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class ConditionContext {
|
||||
|
||||
/**
|
||||
* 人脸ID
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 机位数量(缓存值,避免重复查询)
|
||||
*/
|
||||
private Integer deviceCount;
|
||||
|
||||
/**
|
||||
* 机位ID列表(用于精确匹配指定的机位)
|
||||
*/
|
||||
private List<Long> deviceIds;
|
||||
|
||||
/**
|
||||
* 可扩展的其他上下文数据
|
||||
*/
|
||||
private Map<String, Object> extra;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.ycwl.basic.puzzle.fill.condition;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 条件评估器
|
||||
* 使用策略模式,根据conditionType动态选择评估策略
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ConditionEvaluator {
|
||||
|
||||
private final Map<String, ConditionStrategy> strategyMap;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
public ConditionEvaluator(List<ConditionStrategy> strategies, ObjectMapper objectMapper) {
|
||||
this.strategyMap = strategies.stream()
|
||||
.collect(Collectors.toMap(
|
||||
ConditionStrategy::getSupportedType,
|
||||
Function.identity()
|
||||
));
|
||||
this.objectMapper = objectMapper;
|
||||
log.info("初始化条件评估器,已注册{}个策略: {}", strategyMap.size(), strategyMap.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估规则条件是否匹配
|
||||
*
|
||||
* @param rule 规则实体
|
||||
* @param context 评估上下文
|
||||
* @return true-匹配, false-不匹配
|
||||
*/
|
||||
public boolean evaluate(PuzzleFillRuleEntity rule, ConditionContext context) {
|
||||
String conditionType = rule.getConditionType();
|
||||
ConditionStrategy strategy = strategyMap.get(conditionType);
|
||||
|
||||
if (strategy == null) {
|
||||
log.warn("未找到条件类型[{}]对应的评估策略,规则ID:{}", conditionType, rule.getId());
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode conditionValue = objectMapper.readTree(rule.getConditionValue());
|
||||
boolean result = strategy.evaluate(conditionValue, context);
|
||||
log.debug("规则[{}]条件评估结果: {}, 条件类型: {}, 条件值: {}",
|
||||
rule.getRuleName(), result, conditionType, rule.getConditionValue());
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("规则[{}]条件评估异常,规则ID:{}", rule.getRuleName(), rule.getId(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ycwl.basic.puzzle.fill.condition;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* 条件评估策略接口
|
||||
* 使用策略模式,每种条件类型实现独立的评估逻辑,方便测试和扩展
|
||||
*/
|
||||
public interface ConditionStrategy {
|
||||
|
||||
/**
|
||||
* 评估条件是否匹配
|
||||
*
|
||||
* @param conditionValue 条件值(JSON)
|
||||
* @param context 评估上下文
|
||||
* @return true-匹配, false-不匹配
|
||||
*/
|
||||
boolean evaluate(JsonNode conditionValue, ConditionContext context);
|
||||
|
||||
/**
|
||||
* 获取支持的条件类型
|
||||
*/
|
||||
String getSupportedType();
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.ycwl.basic.puzzle.fill.condition;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ycwl.basic.puzzle.fill.enums.ConditionType;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 机位数量匹配策略(大于等于匹配)
|
||||
*
|
||||
* <p>支持两种匹配模式:</p>
|
||||
* <ul>
|
||||
* <li>模式1:全局数量匹配 - 只指定 deviceCount,匹配所有机位的数量 ≥ deviceCount</li>
|
||||
* <li>模式2:指定列表数量匹配 - 同时指定 deviceCount + deviceIds,从指定列表中过滤并匹配数量 ≥ deviceCount</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>配置示例:</p>
|
||||
* <pre>
|
||||
* // 模式1:全局数量匹配(大于等于)
|
||||
* {
|
||||
* "deviceCount": 4
|
||||
* }
|
||||
* // 匹配:用户有4个或更多机位时匹配成功
|
||||
*
|
||||
* // 模式2:指定列表数量匹配(大于等于)
|
||||
* {
|
||||
* "deviceCount": 2,
|
||||
* "deviceIds": [200, 300, 400]
|
||||
* }
|
||||
* // 匹配:从列表中过滤出≥2个机位时匹配成功,只取前2个供数据源使用
|
||||
* </pre>
|
||||
*
|
||||
* <p>模式2匹配逻辑:</p>
|
||||
* <ul>
|
||||
* <li>从 deviceIds 列表中过滤出实际存在的机位</li>
|
||||
* <li>保持配置顺序(不按 deviceId 排序)</li>
|
||||
* <li>判断过滤后的数量是否 ≥ deviceCount</li>
|
||||
* <li>匹配成功后,只取前 deviceCount 个机位存入 context.extra,供数据源解析使用</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-20
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DeviceCountConditionStrategy implements ConditionStrategy {
|
||||
|
||||
@Override
|
||||
public boolean evaluate(JsonNode conditionValue, ConditionContext context) {
|
||||
if (conditionValue == null || !conditionValue.has("deviceCount")) {
|
||||
log.warn("DEVICE_COUNT条件缺少deviceCount字段");
|
||||
return false;
|
||||
}
|
||||
|
||||
int expectedCount = conditionValue.get("deviceCount").asInt();
|
||||
if (expectedCount <= 0) {
|
||||
log.warn("deviceCount必须大于0, 当前值: {}", expectedCount);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否指定了 deviceIds(模式2)
|
||||
if (conditionValue.has("deviceIds")) {
|
||||
return evaluateWithDeviceIdList(conditionValue, context, expectedCount);
|
||||
} else {
|
||||
// 模式1:全局数量匹配
|
||||
return evaluateGlobalCount(context, expectedCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模式1:全局数量匹配(大于等于)
|
||||
*/
|
||||
private boolean evaluateGlobalCount(ConditionContext context, int expectedCount) {
|
||||
Integer actualCount = context.getDeviceCount();
|
||||
if (actualCount == null) {
|
||||
log.debug("上下文中没有机位数量信息");
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean matched = actualCount >= expectedCount;
|
||||
if (matched) {
|
||||
log.info("DEVICE_COUNT全局匹配成功: 最小数量={}, 实际数量={}", expectedCount, actualCount);
|
||||
} else {
|
||||
log.debug("DEVICE_COUNT全局匹配失败: 最小数量={}, 实际数量={}", expectedCount, actualCount);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模式2:指定列表数量匹配(大于等于,只取前N个)
|
||||
*/
|
||||
private boolean evaluateWithDeviceIdList(JsonNode conditionValue, ConditionContext context, int expectedCount) {
|
||||
// 1. 读取配置的 deviceIds 列表
|
||||
JsonNode deviceIdsNode = conditionValue.get("deviceIds");
|
||||
if (!deviceIdsNode.isArray()) {
|
||||
log.warn("deviceIds字段必须是数组");
|
||||
return false;
|
||||
}
|
||||
|
||||
List<Long> requiredDeviceIds = new ArrayList<>();
|
||||
deviceIdsNode.forEach(node -> requiredDeviceIds.add(node.asLong()));
|
||||
|
||||
if (requiredDeviceIds.isEmpty()) {
|
||||
log.warn("deviceIds数组为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 获取上下文中的机位列表
|
||||
List<Long> contextDeviceIds = context.getDeviceIds();
|
||||
if (contextDeviceIds == null || contextDeviceIds.isEmpty()) {
|
||||
log.debug("上下文中没有机位ID列表");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 按配置顺序过滤出实际存在的机位
|
||||
List<Long> matchedDeviceIds = requiredDeviceIds.stream()
|
||||
.filter(contextDeviceIds::contains)
|
||||
.collect(Collectors.toList()); // 保持配置顺序,不排序
|
||||
|
||||
// 4. 判断是否满足最小数量要求(大于等于)
|
||||
boolean matched = matchedDeviceIds.size() >= expectedCount;
|
||||
|
||||
if (matched) {
|
||||
// 5. 只取前 expectedCount 个机位存入 context.extra
|
||||
List<Long> limitedDeviceIds = matchedDeviceIds.stream()
|
||||
.limit(expectedCount)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> extra = new HashMap<>();
|
||||
extra.put("filteredDeviceIds", limitedDeviceIds);
|
||||
context.setExtra(extra);
|
||||
|
||||
log.info("DEVICE_COUNT列表匹配成功: 配置列表={}, 过滤后={}, 最小数量={}, 实际数量={}, 取前{}个={}",
|
||||
requiredDeviceIds, matchedDeviceIds, expectedCount, matchedDeviceIds.size(), expectedCount, limitedDeviceIds);
|
||||
} else {
|
||||
log.debug("DEVICE_COUNT列表匹配失败: 配置列表={}, 过滤后={}, 最小数量={}, 实际数量={}",
|
||||
requiredDeviceIds, matchedDeviceIds, expectedCount, matchedDeviceIds.size());
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSupportedType() {
|
||||
return ConditionType.DEVICE_COUNT.getCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.ycwl.basic.puzzle.fill.condition;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 机位ID匹配条件策略
|
||||
* 判断指定的机位ID是否在当前上下文的机位列表中
|
||||
*
|
||||
* 支持两种匹配模式:
|
||||
* 1. 单个机位匹配: {"deviceId": 123}
|
||||
* 2. 多个机位匹配: {"deviceIds": [123, 456], "matchMode": "ALL"/"ANY"}
|
||||
* - ALL: 所有指定的机位都必须存在
|
||||
* - ANY: 至少存在一个指定的机位(默认)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DeviceIdMatchConditionStrategy implements ConditionStrategy {
|
||||
|
||||
@Override
|
||||
public boolean evaluate(JsonNode conditionValue, ConditionContext context) {
|
||||
if (conditionValue == null) {
|
||||
log.warn("DEVICE_ID_MATCH条件值为null");
|
||||
return false;
|
||||
}
|
||||
|
||||
List<Long> contextDeviceIds = context.getDeviceIds();
|
||||
if (contextDeviceIds == null || contextDeviceIds.isEmpty()) {
|
||||
log.debug("上下文中没有机位ID列表");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 单个机位匹配模式
|
||||
if (conditionValue.has("deviceId")) {
|
||||
Long requiredDeviceId = conditionValue.get("deviceId").asLong();
|
||||
boolean matched = contextDeviceIds.contains(requiredDeviceId);
|
||||
log.debug("单机位匹配: deviceId={}, matched={}", requiredDeviceId, matched);
|
||||
return matched;
|
||||
}
|
||||
|
||||
// 多个机位匹配模式
|
||||
if (conditionValue.has("deviceIds")) {
|
||||
JsonNode deviceIdsNode = conditionValue.get("deviceIds");
|
||||
if (!deviceIdsNode.isArray()) {
|
||||
log.warn("deviceIds字段必须是数组");
|
||||
return false;
|
||||
}
|
||||
|
||||
List<Long> requiredDeviceIds = new ArrayList<>();
|
||||
deviceIdsNode.forEach(node -> requiredDeviceIds.add(node.asLong()));
|
||||
|
||||
if (requiredDeviceIds.isEmpty()) {
|
||||
log.warn("deviceIds数组为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取匹配模式,默认为ANY
|
||||
String matchMode = conditionValue.has("matchMode")
|
||||
? conditionValue.get("matchMode").asText()
|
||||
: "ANY";
|
||||
|
||||
boolean matched;
|
||||
if ("ALL".equalsIgnoreCase(matchMode)) {
|
||||
// ALL模式: 所有指定的机位都必须存在
|
||||
matched = contextDeviceIds.containsAll(requiredDeviceIds);
|
||||
log.debug("多机位ALL匹配: requiredDeviceIds={}, matched={}", requiredDeviceIds, matched);
|
||||
} else {
|
||||
// ANY模式: 至少存在一个指定的机位
|
||||
matched = requiredDeviceIds.stream().anyMatch(contextDeviceIds::contains);
|
||||
log.debug("多机位ANY匹配: requiredDeviceIds={}, matched={}", requiredDeviceIds, matched);
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
log.warn("DEVICE_ID_MATCH条件缺少deviceId或deviceIds字段");
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSupportedType() {
|
||||
return "DEVICE_ID_MATCH";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ycwl.basic.puzzle.fill.datasource;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 数据源解析上下文
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class DataSourceContext {
|
||||
|
||||
/**
|
||||
* 人脸ID
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 可扩展的其他上下文数据
|
||||
*/
|
||||
private Map<String, Object> extra;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.ycwl.basic.puzzle.fill.datasource;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 数据源解析器
|
||||
* 使用策略模式,根据dataSource类型动态选择解析策略
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DataSourceResolver {
|
||||
|
||||
private final Map<String, DataSourceStrategy> strategyMap;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
public DataSourceResolver(List<DataSourceStrategy> strategies, ObjectMapper objectMapper) {
|
||||
this.strategyMap = strategies.stream()
|
||||
.collect(Collectors.toMap(
|
||||
DataSourceStrategy::getSupportedType,
|
||||
Function.identity()
|
||||
));
|
||||
this.objectMapper = objectMapper;
|
||||
log.info("初始化数据源解析器,已注册{}个策略: {}", strategyMap.size(), strategyMap.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析数据源,返回填充值
|
||||
*
|
||||
* @param dataSource 数据源类型
|
||||
* @param sourceFilterJson 过滤条件(JSON字符串)
|
||||
* @param sortStrategy 排序策略
|
||||
* @param fallbackValue 降级默认值
|
||||
* @param context 解析上下文
|
||||
* @return 填充值
|
||||
*/
|
||||
public String resolve(String dataSource,
|
||||
String sourceFilterJson,
|
||||
String sortStrategy,
|
||||
String fallbackValue,
|
||||
DataSourceContext context) {
|
||||
DataSourceStrategy strategy = strategyMap.get(dataSource);
|
||||
|
||||
if (strategy == null) {
|
||||
log.warn("未找到数据源类型[{}]对应的解析策略", dataSource);
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode sourceFilter = null;
|
||||
if (sourceFilterJson != null && !sourceFilterJson.isEmpty()) {
|
||||
sourceFilter = objectMapper.readTree(sourceFilterJson);
|
||||
}
|
||||
|
||||
String value = strategy.resolve(sourceFilter, sortStrategy, context);
|
||||
|
||||
if (value == null || value.isEmpty()) {
|
||||
log.debug("数据源[{}]解析结果为空,使用降级值: {}", dataSource, fallbackValue);
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (Exception e) {
|
||||
log.error("数据源[{}]解析异常,使用降级值: {}", dataSource, fallbackValue, e);
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ycwl.basic.puzzle.fill.datasource;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* 数据源解析策略接口
|
||||
* 使用策略模式,每种数据源类型实现独立的解析逻辑
|
||||
*/
|
||||
public interface DataSourceStrategy {
|
||||
|
||||
/**
|
||||
* 解析数据源,返回填充值
|
||||
*
|
||||
* @param sourceFilter 数据源过滤条件(JSON)
|
||||
* @param sortStrategy 排序策略
|
||||
* @param context 解析上下文
|
||||
* @return 填充值(通常是URL)
|
||||
*/
|
||||
String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context);
|
||||
|
||||
/**
|
||||
* 获取支持的数据源类型
|
||||
*/
|
||||
String getSupportedType();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.ycwl.basic.puzzle.fill.datasource;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ycwl.basic.mapper.SourceMapper;
|
||||
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
|
||||
import com.ycwl.basic.puzzle.fill.enums.DataSourceType;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 设备图片数据源策略
|
||||
* 根据deviceIndex指定第N个设备的图片
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DeviceImageDataSourceStrategy implements DataSourceStrategy {
|
||||
|
||||
@Autowired
|
||||
private SourceMapper sourceMapper;
|
||||
|
||||
@Override
|
||||
public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) {
|
||||
try {
|
||||
// 默认type=2(图片)
|
||||
Integer type = 2;
|
||||
if (sourceFilter != null && sourceFilter.has("type")) {
|
||||
type = sourceFilter.get("type").asInt();
|
||||
}
|
||||
|
||||
// 获取deviceIndex
|
||||
Integer deviceIndex = 0;
|
||||
if (sourceFilter != null && sourceFilter.has("deviceIndex")) {
|
||||
deviceIndex = sourceFilter.get("deviceIndex").asInt();
|
||||
}
|
||||
|
||||
// 使用默认策略
|
||||
if (sortStrategy == null || sortStrategy.isEmpty()) {
|
||||
sortStrategy = "LATEST";
|
||||
}
|
||||
|
||||
// 1. 检查是否有过滤后的机位列表
|
||||
Map<String, Object> extra = context.getExtra();
|
||||
if (extra != null && extra.containsKey("filteredDeviceIds")) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Long> filteredDeviceIds = (List<Long>) extra.get("filteredDeviceIds");
|
||||
|
||||
if (filteredDeviceIds != null && !filteredDeviceIds.isEmpty()) {
|
||||
// 使用过滤后的机位列表
|
||||
if (deviceIndex >= filteredDeviceIds.size()) {
|
||||
log.warn("deviceIndex[{}]超出过滤后的机位列表范围, 最大索引={}",
|
||||
deviceIndex, filteredDeviceIds.size() - 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
Long targetDeviceId = filteredDeviceIds.get(deviceIndex);
|
||||
log.debug("使用过滤后的机位列表, deviceIndex={}, targetDeviceId={}",
|
||||
deviceIndex, targetDeviceId);
|
||||
|
||||
SourceEntity source = sourceMapper.getSourceByFaceAndDeviceId(
|
||||
context.getFaceId(),
|
||||
targetDeviceId,
|
||||
type,
|
||||
sortStrategy
|
||||
);
|
||||
|
||||
if (source != null) {
|
||||
String url = type == 1 ? source.getVideoUrl() : source.getUrl();
|
||||
log.debug("解析DEVICE_IMAGE成功(过滤模式), faceId={}, deviceId={}, type={}, url={}",
|
||||
context.getFaceId(), targetDeviceId, type, url);
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 降级到原有逻辑(使用deviceIndex直接查询)
|
||||
SourceEntity source = sourceMapper.getSourceByFaceAndDeviceIndex(
|
||||
context.getFaceId(),
|
||||
deviceIndex,
|
||||
type,
|
||||
sortStrategy
|
||||
);
|
||||
|
||||
if (source != null) {
|
||||
String url = type == 1 ? source.getVideoUrl() : source.getUrl();
|
||||
log.debug("解析DEVICE_IMAGE成功(索引模式), faceId={}, deviceIndex={}, type={}, url={}",
|
||||
context.getFaceId(), deviceIndex, type, url);
|
||||
return url;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("解析DEVICE_IMAGE异常, faceId={}", context.getFaceId(), e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSupportedType() {
|
||||
return DataSourceType.DEVICE_IMAGE.getCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ycwl.basic.puzzle.fill.datasource;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ycwl.basic.mapper.FaceMapper;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.puzzle.fill.enums.DataSourceType;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 人脸URL数据源策略
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class FaceUrlDataSourceStrategy implements DataSourceStrategy {
|
||||
|
||||
@Autowired
|
||||
private FaceMapper faceMapper;
|
||||
|
||||
@Override
|
||||
public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) {
|
||||
try {
|
||||
FaceEntity face = faceMapper.get(context.getFaceId());
|
||||
if (face != null && face.getFaceUrl() != null) {
|
||||
log.debug("解析FACE_URL成功, faceId={}, url={}", context.getFaceId(), face.getFaceUrl());
|
||||
return face.getFaceUrl();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("解析FACE_URL异常, faceId={}", context.getFaceId(), e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSupportedType() {
|
||||
return DataSourceType.FACE_URL.getCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.ycwl.basic.puzzle.fill.datasource;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ycwl.basic.puzzle.fill.enums.DataSourceType;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 静态值数据源策略
|
||||
* 支持直接返回配置值或指向本地文件的路径
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class StaticValueDataSourceStrategy implements DataSourceStrategy {
|
||||
|
||||
@Override
|
||||
public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) {
|
||||
if (sourceFilter == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sourceFilter.hasNonNull("localPath")) {
|
||||
String localPath = sourceFilter.get("localPath").asText();
|
||||
return resolveLocalPath(localPath);
|
||||
}
|
||||
|
||||
if (sourceFilter.hasNonNull("value")) {
|
||||
String value = sourceFilter.get("value").asText();
|
||||
log.debug("解析STATIC_VALUE成功, value={}", value);
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveLocalPath(String rawPath) {
|
||||
if (StrUtil.isBlank(rawPath)) {
|
||||
log.warn("localPath为空, 无法解析静态值数据源");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Path path;
|
||||
if (StrUtil.startWithIgnoreCase(rawPath, "file:")) {
|
||||
path = Paths.get(new URI(rawPath));
|
||||
} else {
|
||||
path = Paths.get(rawPath);
|
||||
}
|
||||
|
||||
if (!path.isAbsolute()) {
|
||||
path = path.toAbsolutePath();
|
||||
}
|
||||
|
||||
if (!Files.exists(path) || !Files.isRegularFile(path)) {
|
||||
log.warn("localPath不存在或不是文件: {}", path);
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug("解析STATIC_VALUE本地路径成功: {}", path);
|
||||
return path.toString();
|
||||
} catch (Exception e) {
|
||||
log.error("解析本地路径失败: {}", rawPath, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSupportedType() {
|
||||
return DataSourceType.STATIC_VALUE.getCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.ycwl.basic.puzzle.fill.datasource;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
||||
import com.ycwl.basic.puzzle.fill.enums.DataSourceType;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.utils.WxMpUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 微信小程序二维码数据源策略
|
||||
* 用于生成微信小程序二维码并上传至OSS
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class WechatQrcodeDataSourceStrategy implements DataSourceStrategy {
|
||||
|
||||
@Autowired
|
||||
private ScenicRepository scenicRepository;
|
||||
|
||||
@Override
|
||||
public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) {
|
||||
if (context.getFaceId() == null) {
|
||||
log.warn("生成微信小程序二维码失败: faceId为空");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (context.getScenicId() == null) {
|
||||
log.warn("生成微信小程序二维码失败: scenicId为空");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取景区的小程序配置
|
||||
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(context.getScenicId());
|
||||
if (scenicMpConfig == null) {
|
||||
log.error("生成微信小程序二维码失败: 未找到景区[{}]的小程序配置", context.getScenicId());
|
||||
return null;
|
||||
}
|
||||
|
||||
// 从sourceFilter中获取page路径,默认使用 pages/videoSynthesis/from_face
|
||||
String page = "pages/videoSynthesis/from_face";
|
||||
if (sourceFilter != null && sourceFilter.has("page")) {
|
||||
page = sourceFilter.get("page").asText();
|
||||
}
|
||||
|
||||
// 生成临时文件
|
||||
File qrcode = new File("qrcode_" + context.getFaceId() + "_" + UUID.randomUUID().toString().substring(0, 8) + ".jpg");
|
||||
|
||||
try {
|
||||
// 调用微信API生成小程序码
|
||||
WxMpUtil.generateUnlimitedWXAQRCode(
|
||||
scenicMpConfig.getAppId(),
|
||||
scenicMpConfig.getAppSecret(),
|
||||
page,
|
||||
context.getFaceId().toString(),
|
||||
qrcode
|
||||
);
|
||||
|
||||
// 上传到OSS
|
||||
try (FileInputStream fis = new FileInputStream(qrcode)) {
|
||||
String fileName = String.format("qrcode_%d_%s.jpg",
|
||||
context.getFaceId(),
|
||||
UUID.randomUUID().toString().replace("-", "").substring(0, 16));
|
||||
|
||||
String qrcodeUrl = StorageFactory.use().uploadFile(
|
||||
"image/jpeg",
|
||||
fis,
|
||||
"puzzle",
|
||||
"wechat_qrcode",
|
||||
fileName
|
||||
);
|
||||
|
||||
log.info("生成微信小程序二维码成功: faceId={}, page={}, url={}",
|
||||
context.getFaceId(), page, qrcodeUrl);
|
||||
|
||||
return qrcodeUrl;
|
||||
}
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
if (qrcode.exists()) {
|
||||
try {
|
||||
Files.delete(qrcode.toPath());
|
||||
} catch (Exception e) {
|
||||
log.warn("删除临时二维码文件失败: {}", qrcode.getAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("生成微信小程序二维码异常: faceId={}, scenicId={}",
|
||||
context.getFaceId(), context.getScenicId(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSupportedType() {
|
||||
return DataSourceType.WECHAT_QRCODE.getCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ycwl.basic.puzzle.fill.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 填充规则条件类型枚举
|
||||
*/
|
||||
@Getter
|
||||
public enum ConditionType {
|
||||
|
||||
/**
|
||||
* 机位数量匹配
|
||||
*/
|
||||
DEVICE_COUNT("DEVICE_COUNT", "机位数量匹配"),
|
||||
|
||||
/**
|
||||
* 机位ID精确匹配
|
||||
*/
|
||||
DEVICE_ID_MATCH("DEVICE_ID_MATCH", "机位ID精确匹配"),
|
||||
|
||||
/**
|
||||
* 总是匹配(兜底规则)
|
||||
*/
|
||||
ALWAYS("ALWAYS", "总是匹配");
|
||||
|
||||
private final String code;
|
||||
private final String description;
|
||||
|
||||
ConditionType(String code, String description) {
|
||||
this.code = code;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据code获取枚举
|
||||
*/
|
||||
public static ConditionType fromCode(String code) {
|
||||
for (ConditionType type : values()) {
|
||||
if (type.code.equals(code)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("未知的条件类型: " + code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ycwl.basic.puzzle.fill.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 数据源类型枚举
|
||||
*/
|
||||
@Getter
|
||||
public enum DataSourceType {
|
||||
|
||||
/**
|
||||
* 人脸URL(来自face表的face_url)
|
||||
*/
|
||||
FACE_URL("FACE_URL", "人脸URL"),
|
||||
|
||||
/**
|
||||
* 素材图片(来自source表,type=2)
|
||||
*/
|
||||
SOURCE_IMAGE("SOURCE_IMAGE", "素材图片"),
|
||||
|
||||
/**
|
||||
* 素材视频(来自source表,type=1)
|
||||
*/
|
||||
SOURCE_VIDEO("SOURCE_VIDEO", "素材视频"),
|
||||
|
||||
/**
|
||||
* 设备图片(根据deviceIndex指定第N个设备的图片)
|
||||
*/
|
||||
DEVICE_IMAGE("DEVICE_IMAGE", "设备图片"),
|
||||
|
||||
/**
|
||||
* 静态值(直接使用fallbackValue)
|
||||
*/
|
||||
STATIC_VALUE("STATIC_VALUE", "静态值"),
|
||||
|
||||
/**
|
||||
* 微信小程序二维码(生成小程序二维码)
|
||||
*/
|
||||
WECHAT_QRCODE("WECHAT_QRCODE", "微信小程序二维码");
|
||||
|
||||
private final String code;
|
||||
private final String description;
|
||||
|
||||
DataSourceType(String code, String description) {
|
||||
this.code = code;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据code获取枚举
|
||||
*/
|
||||
public static DataSourceType fromCode(String code) {
|
||||
for (DataSourceType type : values()) {
|
||||
if (type.code.equals(code)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("未知的数据源类型: " + code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.ycwl.basic.puzzle.fill.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 素材排序策略枚举
|
||||
*/
|
||||
@Getter
|
||||
public enum SortStrategy {
|
||||
|
||||
/**
|
||||
* 最新创建(按create_time DESC)
|
||||
*/
|
||||
LATEST("LATEST", "最新创建"),
|
||||
|
||||
/**
|
||||
* 最早创建(按create_time ASC)
|
||||
*/
|
||||
EARLIEST("EARLIEST", "最早创建"),
|
||||
|
||||
/**
|
||||
* 分数降序(按score DESC,需要source表有score字段)
|
||||
*/
|
||||
SCORE_DESC("SCORE_DESC", "分数降序"),
|
||||
|
||||
/**
|
||||
* 优先已购买(按is_buy DESC, create_time DESC)
|
||||
*/
|
||||
PURCHASED_FIRST("PURCHASED_FIRST", "优先已购买");
|
||||
|
||||
private final String code;
|
||||
private final String description;
|
||||
|
||||
SortStrategy(String code, String description) {
|
||||
this.code = code;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据code获取枚举
|
||||
*/
|
||||
public static SortStrategy fromCode(String code) {
|
||||
for (SortStrategy strategy : values()) {
|
||||
if (strategy.code.equals(code)) {
|
||||
return strategy;
|
||||
}
|
||||
}
|
||||
return LATEST; // 默认返回最新
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ycwl.basic.puzzle.mapper;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
@@ -11,6 +12,7 @@ import java.util.List;
|
||||
* @author Claude
|
||||
* @since 2025-01-17
|
||||
*/
|
||||
@Mapper
|
||||
public interface PuzzleElementMapper {
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,12 @@ public interface PuzzleElementMapper {
|
||||
*/
|
||||
List<PuzzleElementEntity> getByTemplateId(@Param("templateId") Long templateId);
|
||||
|
||||
/**
|
||||
* 根据模板ID和元素Key查询元素
|
||||
*/
|
||||
PuzzleElementEntity getByTemplateIdAndKey(@Param("templateId") Long templateId,
|
||||
@Param("elementKey") String elementKey);
|
||||
|
||||
/**
|
||||
* 插入元素
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ycwl.basic.puzzle.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 拼图自动填充规则明细 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface PuzzleFillRuleItemMapper extends BaseMapper<PuzzleFillRuleItemEntity> {
|
||||
|
||||
/**
|
||||
* 根据规则ID查询所有明细(按item_order升序)
|
||||
*
|
||||
* @param ruleId 规则ID
|
||||
* @return 明细列表
|
||||
*/
|
||||
List<PuzzleFillRuleItemEntity> listByRuleId(@Param("ruleId") Long ruleId);
|
||||
|
||||
/**
|
||||
* 批量插入明细
|
||||
*
|
||||
* @param items 明细列表
|
||||
* @return 插入数量
|
||||
*/
|
||||
int batchInsert(@Param("items") List<PuzzleFillRuleItemEntity> items);
|
||||
|
||||
/**
|
||||
* 根据规则ID删除所有明细
|
||||
*
|
||||
* @param ruleId 规则ID
|
||||
* @return 删除数量
|
||||
*/
|
||||
int deleteByRuleId(@Param("ruleId") Long ruleId);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ycwl.basic.puzzle.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 拼图自动填充规则 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface PuzzleFillRuleMapper extends BaseMapper<PuzzleFillRuleEntity> {
|
||||
|
||||
/**
|
||||
* 根据模板ID查询所有启用的规则(按优先级降序)
|
||||
*
|
||||
* @param templateId 模板ID
|
||||
* @return 规则列表
|
||||
*/
|
||||
List<PuzzleFillRuleEntity> listByTemplateId(@Param("templateId") Long templateId);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ycwl.basic.puzzle.mapper;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
@@ -11,6 +12,7 @@ import java.util.List;
|
||||
* @author Claude
|
||||
* @since 2025-01-17
|
||||
*/
|
||||
@Mapper
|
||||
public interface PuzzleGenerationRecordMapper {
|
||||
|
||||
/**
|
||||
@@ -25,9 +27,15 @@ public interface PuzzleGenerationRecordMapper {
|
||||
@Param("limit") Integer limit);
|
||||
|
||||
/**
|
||||
* 查询订单的生成记录列表
|
||||
* 查询人脸ID的生成记录列表
|
||||
*/
|
||||
List<PuzzleGenerationRecordEntity> listByOrderId(@Param("orderId") String orderId);
|
||||
List<PuzzleGenerationRecordEntity> listByFaceId(@Param("faceId") Long faceId);
|
||||
|
||||
|
||||
/**
|
||||
* 统计人脸ID的生成记录数量
|
||||
*/
|
||||
int countByFaceId(@Param("faceId") Long faceId);
|
||||
|
||||
/**
|
||||
* 插入记录
|
||||
@@ -54,4 +62,18 @@ public interface PuzzleGenerationRecordMapper {
|
||||
*/
|
||||
int updateFail(@Param("id") Long id,
|
||||
@Param("errorMessage") String errorMessage);
|
||||
|
||||
/**
|
||||
* 根据内容哈希查询历史记录(用于去重)
|
||||
* 查询条件: template_id + content_hash + scenic_id + status=1
|
||||
* 返回最新的成功记录
|
||||
*
|
||||
* @param templateId 模板ID
|
||||
* @param contentHash 内容哈希
|
||||
* @param scenicId 景区ID
|
||||
* @return 历史记录,如果不存在返回null
|
||||
*/
|
||||
PuzzleGenerationRecordEntity findByContentHash(@Param("templateId") Long templateId,
|
||||
@Param("contentHash") String contentHash,
|
||||
@Param("scenicId") Long scenicId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ycwl.basic.puzzle.mapper;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
@@ -11,6 +12,7 @@ import java.util.List;
|
||||
* @author Claude
|
||||
* @since 2025-01-17
|
||||
*/
|
||||
@Mapper
|
||||
public interface PuzzleTemplateMapper {
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ycwl.basic.puzzle.service;
|
||||
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleDTO;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleSaveRequest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 拼图填充规则服务接口
|
||||
*/
|
||||
public interface IPuzzleFillRuleService {
|
||||
|
||||
/**
|
||||
* 创建规则(主+明细)
|
||||
*
|
||||
* @param request 保存请求
|
||||
* @return 规则ID
|
||||
*/
|
||||
Long create(PuzzleFillRuleSaveRequest request);
|
||||
|
||||
/**
|
||||
* 更新规则(主+明细)
|
||||
*
|
||||
* @param request 保存请求
|
||||
* @return 是否成功
|
||||
*/
|
||||
Boolean update(PuzzleFillRuleSaveRequest request);
|
||||
|
||||
/**
|
||||
* 删除规则(级联删除明细)
|
||||
*
|
||||
* @param id 规则ID
|
||||
* @return 是否成功
|
||||
*/
|
||||
Boolean delete(Long id);
|
||||
|
||||
/**
|
||||
* 查询单条规则(含明细)
|
||||
*
|
||||
* @param id 规则ID
|
||||
* @return 规则DTO
|
||||
*/
|
||||
PuzzleFillRuleDTO getById(Long id);
|
||||
|
||||
/**
|
||||
* 查询模板的所有规则(含明细)
|
||||
*
|
||||
* @param templateId 模板ID
|
||||
* @return 规则列表
|
||||
*/
|
||||
List<PuzzleFillRuleDTO> listByTemplateId(Long templateId);
|
||||
|
||||
/**
|
||||
* 启用/禁用规则
|
||||
*
|
||||
* @param id 规则ID
|
||||
* @param enabled 是否启用
|
||||
* @return 是否成功
|
||||
*/
|
||||
Boolean toggleEnabled(Long id, Integer enabled);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ycwl.basic.puzzle.service;
|
||||
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
|
||||
@@ -45,6 +46,19 @@ public interface IPuzzleTemplateService {
|
||||
*/
|
||||
List<PuzzleTemplateDTO> listTemplates(Long scenicId, String category, Integer status);
|
||||
|
||||
/**
|
||||
* 分页获取模板列表
|
||||
*
|
||||
* @param page 页码(从1开始)
|
||||
* @param pageSize 每页大小
|
||||
* @param scenicId 景区ID(可选)
|
||||
* @param category 模板分类(可选)
|
||||
* @param status 状态(可选): 0-禁用 1-启用
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResponse<PuzzleTemplateDTO> pageTemplates(Integer page, Integer pageSize,
|
||||
Long scenicId, String category, Integer status);
|
||||
|
||||
/**
|
||||
* 为模板添加元素
|
||||
*/
|
||||
@@ -55,6 +69,14 @@ public interface IPuzzleTemplateService {
|
||||
*/
|
||||
void batchAddElements(Long templateId, List<ElementCreateRequest> elements);
|
||||
|
||||
/**
|
||||
* 批量替换元素(删除旧元素,添加新元素)
|
||||
*
|
||||
* @param templateId 模板ID
|
||||
* @param elements 新的元素列表
|
||||
*/
|
||||
void replaceElements(Long templateId, List<ElementCreateRequest> elements);
|
||||
|
||||
/**
|
||||
* 更新元素
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.ycwl.basic.puzzle.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleDTO;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleItemDTO;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleFillRuleSaveRequest;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleItemMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleMapper;
|
||||
import com.ycwl.basic.puzzle.service.IPuzzleFillRuleService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 拼图填充规则服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PuzzleFillRuleServiceImpl implements IPuzzleFillRuleService {
|
||||
|
||||
@Autowired
|
||||
private PuzzleFillRuleMapper ruleMapper;
|
||||
|
||||
@Autowired
|
||||
private PuzzleFillRuleItemMapper itemMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(PuzzleFillRuleSaveRequest request) {
|
||||
// 1. 保存主规则
|
||||
PuzzleFillRuleEntity ruleEntity = new PuzzleFillRuleEntity();
|
||||
BeanUtils.copyProperties(request, ruleEntity);
|
||||
ruleMapper.insert(ruleEntity);
|
||||
|
||||
Long ruleId = ruleEntity.getId();
|
||||
log.info("创建填充规则成功, ruleId={}, ruleName={}", ruleId, request.getRuleName());
|
||||
|
||||
// 2. 批量保存明细
|
||||
if (request.getItems() != null && !request.getItems().isEmpty()) {
|
||||
List<PuzzleFillRuleItemEntity> itemEntities = request.getItems().stream()
|
||||
.map(dto -> {
|
||||
PuzzleFillRuleItemEntity entity = new PuzzleFillRuleItemEntity();
|
||||
BeanUtils.copyProperties(dto, entity);
|
||||
entity.setRuleId(ruleId);
|
||||
return entity;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
itemMapper.batchInsert(itemEntities);
|
||||
log.info("批量保存规则明细成功, count={}", itemEntities.size());
|
||||
}
|
||||
|
||||
return ruleId;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean update(PuzzleFillRuleSaveRequest request) {
|
||||
if (request.getId() == null) {
|
||||
throw new IllegalArgumentException("更新规则时ID不能为空");
|
||||
}
|
||||
|
||||
// 1. 更新主规则
|
||||
PuzzleFillRuleEntity ruleEntity = new PuzzleFillRuleEntity();
|
||||
BeanUtils.copyProperties(request, ruleEntity);
|
||||
ruleMapper.updateById(ruleEntity);
|
||||
|
||||
// 2. 删除旧明细
|
||||
itemMapper.deleteByRuleId(request.getId());
|
||||
|
||||
// 3. 批量插入新明细
|
||||
if (request.getItems() != null && !request.getItems().isEmpty()) {
|
||||
List<PuzzleFillRuleItemEntity> itemEntities = request.getItems().stream()
|
||||
.map(dto -> {
|
||||
PuzzleFillRuleItemEntity entity = new PuzzleFillRuleItemEntity();
|
||||
BeanUtils.copyProperties(dto, entity);
|
||||
entity.setRuleId(request.getId());
|
||||
return entity;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
itemMapper.batchInsert(itemEntities);
|
||||
}
|
||||
|
||||
log.info("更新填充规则成功, ruleId={}, itemCount={}", request.getId(),
|
||||
request.getItems() != null ? request.getItems().size() : 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean delete(Long id) {
|
||||
// MyBatis-Plus会级联删除明细(通过外键ON DELETE CASCADE)
|
||||
ruleMapper.deleteById(id);
|
||||
log.info("删除填充规则成功, ruleId={}", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PuzzleFillRuleDTO getById(Long id) {
|
||||
PuzzleFillRuleEntity ruleEntity = ruleMapper.selectById(id);
|
||||
if (ruleEntity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PuzzleFillRuleDTO dto = new PuzzleFillRuleDTO();
|
||||
BeanUtils.copyProperties(ruleEntity, dto);
|
||||
|
||||
// 查询明细
|
||||
List<PuzzleFillRuleItemEntity> itemEntities = itemMapper.listByRuleId(id);
|
||||
if (itemEntities != null && !itemEntities.isEmpty()) {
|
||||
List<PuzzleFillRuleItemDTO> itemDTOs = itemEntities.stream()
|
||||
.map(entity -> {
|
||||
PuzzleFillRuleItemDTO itemDTO = new PuzzleFillRuleItemDTO();
|
||||
BeanUtils.copyProperties(entity, itemDTO);
|
||||
return itemDTO;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
dto.setItems(itemDTOs);
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PuzzleFillRuleDTO> listByTemplateId(Long templateId) {
|
||||
List<PuzzleFillRuleEntity> ruleEntities = ruleMapper.listByTemplateId(templateId);
|
||||
if (ruleEntities == null || ruleEntities.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return ruleEntities.stream()
|
||||
.map(ruleEntity -> {
|
||||
PuzzleFillRuleDTO dto = new PuzzleFillRuleDTO();
|
||||
BeanUtils.copyProperties(ruleEntity, dto);
|
||||
|
||||
// 查询明细
|
||||
List<PuzzleFillRuleItemEntity> itemEntities = itemMapper.listByRuleId(ruleEntity.getId());
|
||||
if (itemEntities != null && !itemEntities.isEmpty()) {
|
||||
List<PuzzleFillRuleItemDTO> itemDTOs = itemEntities.stream()
|
||||
.map(entity -> {
|
||||
PuzzleFillRuleItemDTO itemDTO = new PuzzleFillRuleItemDTO();
|
||||
BeanUtils.copyProperties(entity, itemDTO);
|
||||
return itemDTO;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
dto.setItems(itemDTOs);
|
||||
}
|
||||
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean toggleEnabled(Long id, Integer enabled) {
|
||||
LambdaUpdateWrapper<PuzzleFillRuleEntity> updateWrapper = new LambdaUpdateWrapper<>();
|
||||
updateWrapper.eq(PuzzleFillRuleEntity::getId, id)
|
||||
.set(PuzzleFillRuleEntity::getEnabled, enabled);
|
||||
|
||||
int count = ruleMapper.update(null, updateWrapper);
|
||||
log.info("切换规则启用状态, ruleId={}, enabled={}", id, enabled);
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,23 @@ package com.ycwl.basic.puzzle.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.fill.FillResult;
|
||||
import com.ycwl.basic.puzzle.fill.PuzzleElementFillEngine;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.utils.WxMpUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -21,9 +27,14 @@ import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@@ -41,12 +52,20 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
private final PuzzleElementMapper elementMapper;
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
private final PuzzleImageRenderer imageRenderer;
|
||||
private final PuzzleElementFillEngine fillEngine;
|
||||
private final ScenicRepository scenicRepository;
|
||||
private final PuzzleDuplicationDetector duplicationDetector;
|
||||
|
||||
@Override
|
||||
public PuzzleGenerateResponse generate(PuzzleGenerateRequest request) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
log.info("开始生成拼图: templateCode={}, userId={}, orderId={}",
|
||||
request.getTemplateCode(), request.getUserId(), request.getOrderId());
|
||||
log.info("开始生成拼图: templateCode={}, userId={}, faceId={}",
|
||||
request.getTemplateCode(), request.getUserId(), request.getFaceId());
|
||||
|
||||
// 业务层校验:faceId 必填
|
||||
if (request.getFaceId() == null) {
|
||||
throw new IllegalArgumentException("人脸ID不能为空");
|
||||
}
|
||||
|
||||
// 1. 查询模板和元素
|
||||
PuzzleTemplateEntity template = templateMapper.getByCode(request.getTemplateCode());
|
||||
@@ -58,28 +77,65 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
|
||||
}
|
||||
|
||||
// 2. 校验景区隔离
|
||||
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
|
||||
|
||||
List<PuzzleElementEntity> elements = elementMapper.getByTemplateId(template.getId());
|
||||
if (elements.isEmpty()) {
|
||||
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
|
||||
}
|
||||
|
||||
// 2. 按z-index排序元素
|
||||
// 3. 按z-index排序元素
|
||||
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
|
||||
Comparator.nullsFirst(Comparator.naturalOrder())));
|
||||
|
||||
// 3. 创建生成记录
|
||||
PuzzleGenerationRecordEntity record = createRecord(template, request);
|
||||
// 4. 准备dynamicData(合并自动填充和手动数据)
|
||||
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
|
||||
|
||||
// 5. 执行重复图片检测
|
||||
// 如果所有IMAGE元素使用相同URL,抛出DuplicateImageException
|
||||
duplicationDetector.detectDuplicateImages(finalDynamicData, elements);
|
||||
|
||||
// 6. 计算内容哈希
|
||||
String contentHash = duplicationDetector.calculateContentHash(finalDynamicData);
|
||||
|
||||
// 7. 查询历史记录(去重核心逻辑)
|
||||
PuzzleGenerationRecordEntity duplicateRecord = duplicationDetector.findDuplicateRecord(
|
||||
template.getId(), contentHash, resolvedScenicId);
|
||||
|
||||
if (duplicateRecord != null) {
|
||||
// 发现重复内容,直接返回历史记录
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.info("检测到重复内容,复用历史记录: recordId={}, imageUrl={}, duration={}ms",
|
||||
duplicateRecord.getId(), duplicateRecord.getResultImageUrl(), duration);
|
||||
|
||||
// 直接返回历史图片URL(语义化生成成功)
|
||||
return PuzzleGenerateResponse.success(
|
||||
duplicateRecord.getResultImageUrl(),
|
||||
duplicateRecord.getResultFileSize(),
|
||||
duplicateRecord.getResultWidth(),
|
||||
duplicateRecord.getResultHeight(),
|
||||
(int) duration,
|
||||
duplicateRecord.getId(),
|
||||
true, // isDuplicate=true
|
||||
duplicateRecord.getId() // originalRecordId(复用时指向自己)
|
||||
);
|
||||
}
|
||||
|
||||
// 8. 没有历史记录,创建新的生成记录
|
||||
PuzzleGenerationRecordEntity record = createRecord(template, request, resolvedScenicId);
|
||||
record.setContentHash(contentHash);
|
||||
recordMapper.insert(record);
|
||||
|
||||
try {
|
||||
// 4. 渲染图片
|
||||
BufferedImage resultImage = imageRenderer.render(template, elements, request.getDynamicData());
|
||||
// 9. 渲染图片
|
||||
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
|
||||
|
||||
// 5. 上传到OSS
|
||||
// 10. 上传到OSS
|
||||
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
|
||||
log.info("图片上传成功: url={}", imageUrl);
|
||||
|
||||
// 6. 更新记录为成功
|
||||
// 11. 更新记录为成功
|
||||
long duration = (int) (System.currentTimeMillis() - startTime);
|
||||
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
|
||||
recordMapper.updateSuccess(
|
||||
@@ -91,7 +147,7 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
(int) duration
|
||||
);
|
||||
|
||||
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
|
||||
log.info("拼图生成成功(新生成): recordId={}, imageUrl={}, duration={}ms",
|
||||
record.getId(), imageUrl, duration);
|
||||
|
||||
return PuzzleGenerateResponse.success(
|
||||
@@ -100,7 +156,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
resultImage.getWidth(),
|
||||
resultImage.getHeight(),
|
||||
(int) duration,
|
||||
record.getId()
|
||||
record.getId(),
|
||||
false, // isDuplicate=false
|
||||
null // originalRecordId=null
|
||||
);
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -114,14 +172,16 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
/**
|
||||
* 创建生成记录
|
||||
*/
|
||||
private PuzzleGenerationRecordEntity createRecord(PuzzleTemplateEntity template, PuzzleGenerateRequest request) {
|
||||
private PuzzleGenerationRecordEntity createRecord(PuzzleTemplateEntity template,
|
||||
PuzzleGenerateRequest request,
|
||||
Long scenicId) {
|
||||
PuzzleGenerationRecordEntity record = new PuzzleGenerationRecordEntity();
|
||||
record.setTemplateId(template.getId());
|
||||
record.setTemplateCode(template.getCode());
|
||||
record.setUserId(request.getUserId());
|
||||
record.setOrderId(request.getOrderId());
|
||||
record.setFaceId(request.getFaceId());
|
||||
record.setBusinessType(request.getBusinessType());
|
||||
record.setScenicId(request.getScenicId());
|
||||
record.setScenicId(scenicId);
|
||||
record.setStatus(0); // 生成中
|
||||
record.setRetryCount(0);
|
||||
|
||||
@@ -179,4 +239,165 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建dynamicData(合并自动填充和手动数据)
|
||||
* 优先级: 手动传入的数据 > 自动填充的数据
|
||||
*/
|
||||
private Map<String, String> buildDynamicData(PuzzleTemplateEntity template,
|
||||
PuzzleGenerateRequest request,
|
||||
Long scenicId,
|
||||
List<PuzzleElementEntity> elements) {
|
||||
Map<String, String> dynamicData = new HashMap<>();
|
||||
|
||||
// 0. 检查是否需要自动生成 travelResultWxaCode 二维码
|
||||
if (request.getFaceId() != null && scenicId != null) {
|
||||
boolean hasTravelResultWxaCode = elements.stream()
|
||||
.anyMatch(e -> "travelResultWxaCode".equals(e.getElementKey()));
|
||||
|
||||
if (hasTravelResultWxaCode && !dynamicDataContainsKey(request.getDynamicData(), "travelResultWxaCode")) {
|
||||
String qrcodeUrl = generateWechatQrcode(request.getFaceId(), scenicId);
|
||||
if (qrcodeUrl != null) {
|
||||
dynamicData.put("travelResultWxaCode", qrcodeUrl);
|
||||
log.info("自动生成微信小程序二维码成功: faceId={}, url={}", request.getFaceId(), qrcodeUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 自动填充(基于faceId和规则)
|
||||
boolean ruleMatched = false;
|
||||
if (request.getFaceId() != null) {
|
||||
try {
|
||||
FillResult fillResult = fillEngine.execute(
|
||||
template.getId(),
|
||||
request.getFaceId(),
|
||||
scenicId
|
||||
);
|
||||
|
||||
ruleMatched = fillResult.isRuleMatched();
|
||||
|
||||
if (fillResult.isRuleMatched()) {
|
||||
log.info("自动填充成功: 匹配规则[{}], 填充了{}个元素",
|
||||
fillResult.getMatchedRuleName(),
|
||||
fillResult.getFilledCount());
|
||||
dynamicData.putAll(fillResult.getDynamicData());
|
||||
} else {
|
||||
log.info("自动填充未匹配任何规则, templateId={}, faceId={}",
|
||||
template.getId(), request.getFaceId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("自动填充异常, templateId={}, faceId={}", template.getId(), request.getFaceId(), e);
|
||||
// 自动填充失败不影响整体流程,继续执行
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否必须匹配规则
|
||||
Boolean requireRuleMatch = request.getRequireRuleMatch();
|
||||
if (Boolean.TRUE.equals(requireRuleMatch) && !ruleMatched) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("未匹配到任何填充规则,无法生成图片 (templateCode=%s, faceId=%s, requireRuleMatch=true)",
|
||||
request.getTemplateCode(), request.getFaceId())
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 手动数据覆盖(优先级更高)
|
||||
if (request.getDynamicData() != null && !request.getDynamicData().isEmpty()) {
|
||||
dynamicData.putAll(request.getDynamicData());
|
||||
log.debug("合并手动传入的dynamicData, count={}", request.getDynamicData().size());
|
||||
}
|
||||
|
||||
log.info("最终dynamicData: {}", dynamicData.keySet());
|
||||
return dynamicData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查dynamicData中是否包含指定key
|
||||
*/
|
||||
private boolean dynamicDataContainsKey(Map<String, String> dynamicData, String key) {
|
||||
return dynamicData != null && dynamicData.containsKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成微信小程序二维码
|
||||
*
|
||||
* @param faceId 人脸ID
|
||||
* @param scenicId 景区ID
|
||||
* @return 二维码URL,失败返回null
|
||||
*/
|
||||
private String generateWechatQrcode(Long faceId, Long scenicId) {
|
||||
File qrcode = null;
|
||||
try {
|
||||
// 获取景区的小程序配置
|
||||
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(scenicId);
|
||||
if (scenicMpConfig == null) {
|
||||
log.error("生成微信小程序二维码失败: 未找到景区[{}]的小程序配置", scenicId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 生成临时文件
|
||||
qrcode = new File("qrcode_" + faceId + "_" + UUID.randomUUID().toString().substring(0, 8) + ".jpg");
|
||||
|
||||
// 调用微信API生成小程序码
|
||||
WxMpUtil.generateUnlimitedWXAQRCode(
|
||||
scenicMpConfig.getAppId(),
|
||||
scenicMpConfig.getAppSecret(),
|
||||
"pages/videoSynthesis/from_face",
|
||||
faceId.toString(),
|
||||
qrcode
|
||||
);
|
||||
|
||||
// 上传到OSS
|
||||
try (FileInputStream fis = new FileInputStream(qrcode)) {
|
||||
String fileName = String.format("qrcode_%d.jpg", faceId);
|
||||
boolean exists = StorageFactory.use().isExists("puzzle", "wechat_qrcode", fileName);
|
||||
if (exists) {
|
||||
log.debug("微信小程序二维码已存在, 不重复上传: faceId={}, url={}", faceId, StorageFactory.use().getUrl("puzzle", "wechat_qrcode", fileName));
|
||||
return StorageFactory.use().getUrl("puzzle", "wechat_qrcode", fileName);
|
||||
}
|
||||
return StorageFactory.use().uploadFile(
|
||||
"image/jpeg",
|
||||
fis,
|
||||
"puzzle",
|
||||
"wechat_qrcode",
|
||||
fileName
|
||||
);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("生成微信小程序二维码失败: faceId={}, scenicId={}", faceId, scenicId, e);
|
||||
return null;
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
if (qrcode != null && qrcode.exists()) {
|
||||
try {
|
||||
Files.delete(qrcode.toPath());
|
||||
} catch (Exception e) {
|
||||
log.warn("删除临时二维码文件失败: {}", qrcode.getAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验模板与请求景区的合法性
|
||||
*
|
||||
* @param template 模板
|
||||
* @param requestedScenic 请求中的景区ID
|
||||
* @return 最终生效的景区ID
|
||||
*/
|
||||
private Long resolveScenicId(PuzzleTemplateEntity template, Long requestedScenic) {
|
||||
Long templateScenicId = template.getScenicId();
|
||||
if (templateScenicId == null) {
|
||||
return requestedScenic;
|
||||
}
|
||||
|
||||
if (requestedScenic == null) {
|
||||
throw new IllegalArgumentException("模板绑定景区, scenicId为必填项");
|
||||
}
|
||||
|
||||
if (!templateScenicId.equals(requestedScenic)) {
|
||||
throw new IllegalArgumentException("模板不属于当前景区, 请检查templateCode与scenicId");
|
||||
}
|
||||
|
||||
return templateScenicId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.ycwl.basic.puzzle.util.ElementConfigHelper;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleElementDTO;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
|
||||
@@ -19,6 +20,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -134,6 +137,44 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResponse<PuzzleTemplateDTO> pageTemplates(Integer page, Integer pageSize,
|
||||
Long scenicId, String category, Integer status) {
|
||||
log.debug("分页查询拼图模板: page={}, pageSize={}, scenicId={}, category={}, status={}",
|
||||
page, pageSize, scenicId, category, status);
|
||||
|
||||
// 参数校验
|
||||
if (page == null || page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
if (pageSize == null || pageSize < 1) {
|
||||
pageSize = 10;
|
||||
}
|
||||
if (pageSize > 100) {
|
||||
pageSize = 100; // 限制最大页面大小
|
||||
}
|
||||
|
||||
// 使用PageHelper进行分页
|
||||
com.github.pagehelper.Page<PuzzleTemplateEntity> pageResult =
|
||||
com.github.pagehelper.PageHelper.startPage(page, pageSize)
|
||||
.doSelectPage(() -> templateMapper.list(scenicId, category, status));
|
||||
|
||||
// 转换为DTO
|
||||
List<PuzzleTemplateDTO> dtoList = pageResult.getResult().stream()
|
||||
.map(this::convertToDTO)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 构建分页响应
|
||||
PageResponse<PuzzleTemplateDTO> response = new PageResponse<>();
|
||||
response.setList(dtoList);
|
||||
response.setTotal((int) pageResult.getTotal());
|
||||
response.setPage(page);
|
||||
response.setPageSize(pageSize);
|
||||
|
||||
log.debug("分页查询完成: total={}, currentSize={}", pageResult.getTotal(), dtoList.size());
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long addElement(ElementCreateRequest request) {
|
||||
@@ -188,6 +229,72 @@ public class PuzzleTemplateServiceImpl implements IPuzzleTemplateService {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void replaceElements(Long templateId, List<ElementCreateRequest> elements) {
|
||||
log.info("批量替换元素: templateId={}, count={}", templateId, elements.size());
|
||||
|
||||
// 1. 校验模板
|
||||
PuzzleTemplateEntity template = templateMapper.getById(templateId);
|
||||
if (template == null) {
|
||||
throw new IllegalArgumentException("模板不存在: " + templateId);
|
||||
}
|
||||
|
||||
// 2. 获取现有元素(建立 elementKey -> Entity 的映射)
|
||||
List<PuzzleElementEntity> existingElements = elementMapper.getByTemplateId(templateId);
|
||||
Map<String, PuzzleElementEntity> existingMap = existingElements.stream()
|
||||
.collect(Collectors.toMap(
|
||||
PuzzleElementEntity::getElementKey,
|
||||
entity -> entity,
|
||||
(e1, e2) -> e1 // 如果有重复key,保留第一个
|
||||
));
|
||||
|
||||
// 3. 构建新元素的 key 集合
|
||||
Set<String> newElementKeys = elements.stream()
|
||||
.map(ElementCreateRequest::getElementKey)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 4. 找出需要删除的元素(旧元素中不在新元素列表中的)
|
||||
List<Long> toDeleteIds = existingElements.stream()
|
||||
.filter(entity -> !newElementKeys.contains(entity.getElementKey()))
|
||||
.map(PuzzleElementEntity::getId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 5. 删除不需要的元素
|
||||
int deletedCount = 0;
|
||||
for (Long id : toDeleteIds) {
|
||||
elementMapper.deleteById(id);
|
||||
deletedCount++;
|
||||
}
|
||||
log.info("删除不需要的元素: templateId={}, count={}", templateId, deletedCount);
|
||||
|
||||
// 6. 处理新元素列表:更新或插入
|
||||
int updatedCount = 0;
|
||||
int insertedCount = 0;
|
||||
for (ElementCreateRequest request : elements) {
|
||||
request.setTemplateId(templateId);
|
||||
ElementConfigHelper.validateRequest(request);
|
||||
|
||||
PuzzleElementEntity existingEntity = existingMap.get(request.getElementKey());
|
||||
if (existingEntity != null) {
|
||||
// 更新现有元素
|
||||
PuzzleElementEntity entity = ElementConfigHelper.toEntity(request);
|
||||
entity.setId(existingEntity.getId());
|
||||
elementMapper.update(entity);
|
||||
updatedCount++;
|
||||
} else {
|
||||
// 插入新元素
|
||||
PuzzleElementEntity entity = ElementConfigHelper.toEntity(request);
|
||||
entity.setDeleted(0);
|
||||
elementMapper.insert(entity);
|
||||
insertedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("批量替换元素完成: templateId={}, deleted={}, updated={}, inserted={}",
|
||||
templateId, deletedCount, updatedCount, insertedCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateElement(Long id, ElementCreateRequest request) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ycwl.basic.puzzle.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.ycwl.basic.puzzle.dto.ElementCreateRequest;
|
||||
import com.ycwl.basic.puzzle.element.enums.ElementType;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -108,13 +109,12 @@ public class ElementConfigHelper {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 当前支持的类型
|
||||
return "TEXT".equalsIgnoreCase(elementType) ||
|
||||
"IMAGE".equalsIgnoreCase(elementType) ||
|
||||
"QRCODE".equalsIgnoreCase(elementType) ||
|
||||
"GRADIENT".equalsIgnoreCase(elementType) ||
|
||||
"SHAPE".equalsIgnoreCase(elementType) ||
|
||||
"DYNAMIC_IMAGE".equalsIgnoreCase(elementType);
|
||||
try {
|
||||
ElementType type = ElementType.fromCode(elementType);
|
||||
return type.isImplemented();
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.ycwl.basic.puzzle.util;
|
||||
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.exception.DuplicateImageException;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 拼图去重检测器
|
||||
* 负责检测重复内容和重复图片,避免不必要的图片生成
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-21
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PuzzleDuplicationDetector {
|
||||
private final Set<String> skippedElementKeys = Set.of("dateStr");
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
|
||||
/**
|
||||
* 计算内容哈希
|
||||
* 基于所有元素的实际内容值(按elementKey排序后拼接)计算SHA256哈希
|
||||
*
|
||||
* @param finalData 最终的元素数据映射(elementKey -> value)
|
||||
* @return SHA256哈希值(64位16进制字符串)
|
||||
*/
|
||||
public String calculateContentHash(Map<String, String> finalData) {
|
||||
if (finalData == null || finalData.isEmpty()) {
|
||||
log.warn("计算内容哈希时发现空数据,返回空哈希");
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 按key排序,确保相同内容生成相同哈希
|
||||
List<String> sortedKeys = new ArrayList<>(finalData.keySet());
|
||||
Collections.sort(sortedKeys);
|
||||
|
||||
// 2. 拼接为固定格式: "key1:value1|key2:value2|..."
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < sortedKeys.size(); i++) {
|
||||
String key = sortedKeys.get(i);
|
||||
if (skippedElementKeys.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
String value = finalData.get(key);
|
||||
sb.append(key).append(":").append(value != null ? value : "");
|
||||
if (i < sortedKeys.size() - 1) {
|
||||
sb.append("|");
|
||||
}
|
||||
}
|
||||
|
||||
String content = sb.toString();
|
||||
log.debug("内容哈希计算原文: {}", content);
|
||||
|
||||
// 3. 计算SHA256
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashBytes = digest.digest(content.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
// 4. 转换为16进制字符串
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
|
||||
String hash = hexString.toString();
|
||||
log.debug("计算得到内容哈希: {}", hash);
|
||||
return hash;
|
||||
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
log.error("SHA-256算法不可用", e);
|
||||
throw new RuntimeException("内容哈希计算失败: SHA-256算法不可用", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测重复图片
|
||||
* 检查所有IMAGE类型元素是否使用相同的图片URL
|
||||
* 如果发现所有图片元素使用同一个URL,抛出异常
|
||||
*
|
||||
* @param finalData 最终的元素数据映射
|
||||
* @param elements 元素列表
|
||||
* @throws DuplicateImageException 所有图片元素使用相同URL时抛出
|
||||
*/
|
||||
public void detectDuplicateImages(Map<String, String> finalData, List<PuzzleElementEntity> elements) {
|
||||
if (finalData == null || finalData.isEmpty() || elements == null || elements.isEmpty()) {
|
||||
log.debug("跳过重复图片检测: 数据为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 筛选出所有IMAGE类型的元素(elementType="IMAGE")
|
||||
List<PuzzleElementEntity> imageElements = elements.stream()
|
||||
.filter(e -> "IMAGE".equals(e.getElementType()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (imageElements.size() < 2) {
|
||||
log.debug("图片元素数量不足2个,跳过重复检测");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 提取所有图片元素的实际URL值
|
||||
List<String> imageUrls = new ArrayList<>();
|
||||
for (PuzzleElementEntity element : imageElements) {
|
||||
String url = finalData.get(element.getElementKey());
|
||||
if (url != null && !url.trim().isEmpty()) {
|
||||
imageUrls.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrls.isEmpty()) {
|
||||
log.debug("没有有效的图片URL,跳过重复检测");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 对URL去重
|
||||
Set<String> uniqueUrls = new HashSet<>(imageUrls);
|
||||
|
||||
// 4. 如果去重后只有1个URL,说明所有图片相同
|
||||
if (uniqueUrls.size() == 1) {
|
||||
String duplicateUrl = uniqueUrls.iterator().next();
|
||||
log.warn("检测到重复图片: 所有{}个图片元素使用相同URL: {}", imageUrls.size(), duplicateUrl);
|
||||
throw new DuplicateImageException(duplicateUrl, imageUrls.size());
|
||||
}
|
||||
|
||||
log.debug("重复图片检测通过: 发现{}个不同的图片URL", uniqueUrls.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找重复记录
|
||||
* 根据模板ID、内容哈希和景区ID查询历史记录
|
||||
* 返回最新的成功生成记录(如果存在)
|
||||
*
|
||||
* @param templateId 模板ID
|
||||
* @param contentHash 内容哈希
|
||||
* @param scenicId 景区ID
|
||||
* @return 历史记录,如果不存在则返回null
|
||||
*/
|
||||
public PuzzleGenerationRecordEntity findDuplicateRecord(Long templateId, String contentHash, Long scenicId) {
|
||||
if (contentHash == null || contentHash.isEmpty()) {
|
||||
log.debug("内容哈希为空,跳过去重查询");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
PuzzleGenerationRecordEntity record = recordMapper.findByContentHash(templateId, contentHash, scenicId);
|
||||
|
||||
if (record != null) {
|
||||
log.info("发现重复内容: templateId={}, contentHash={}, 历史记录ID={}, imageUrl={}",
|
||||
templateId, contentHash, record.getId(), record.getResultImageUrl());
|
||||
} else {
|
||||
log.debug("未发现重复内容: templateId={}, contentHash={}", templateId, contentHash);
|
||||
}
|
||||
|
||||
return record;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("查询重复记录失败: templateId={}, contentHash={}", templateId, contentHash, e);
|
||||
// 查询失败时返回null,继续正常生成流程
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,11 @@ public class FaceRepository {
|
||||
|
||||
public FaceEntity getFace(Long id) {
|
||||
if (redisTemplate.hasKey(String.format(FACE_CACHE_KEY, id))) {
|
||||
return JacksonUtil.parseObject(redisTemplate.opsForValue().get(String.format(FACE_CACHE_KEY, id)), FaceEntity.class);
|
||||
String json = redisTemplate.opsForValue().get(String.format(FACE_CACHE_KEY, id));
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
return JacksonUtil.parseObject(json, FaceEntity.class);
|
||||
}
|
||||
FaceEntity face = faceMapper.get(id);
|
||||
if (face != null) {
|
||||
|
||||
@@ -86,22 +86,6 @@ public class OrderRepository {
|
||||
return orderMapper.getUserBuyItem(userId, goodsType, goodsId);
|
||||
}
|
||||
|
||||
public boolean checkUserBuyFaceSourceImage(Long userId, Long faceId) {
|
||||
return checkUserBuyItem(userId, 2, faceId);
|
||||
}
|
||||
|
||||
public boolean checkUserBuyFaceSourceVideo(Long userId, Long faceId) {
|
||||
return checkUserBuyItem(userId, 1, faceId);
|
||||
}
|
||||
|
||||
public boolean checkUserBuyVideo(Long userId, Long videoId) {
|
||||
return checkUserBuyItem(userId, 0, videoId);
|
||||
}
|
||||
|
||||
public boolean checkUserBuyTemplate(Long userId, Long templateId) {
|
||||
return checkUserBuyItem(userId, -1, templateId);
|
||||
}
|
||||
|
||||
public void clearUserBuyItemCache(Long userId, int goodsType, Long goodsId) {
|
||||
redisTemplate.delete(String.format(ORDER_USER_TYPE_BUY_ITEM_CACHE_KEY, userId, goodsType, goodsId));
|
||||
}
|
||||
|
||||
@@ -62,38 +62,6 @@ public class SourceRepository {
|
||||
memberRelationRepository.clearSCacheByFace(faceId);
|
||||
}
|
||||
|
||||
public boolean getUserIsBuy(Long userId, int type, Long faceId) {
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
log.info("faceId:{} is not exist", faceId);
|
||||
return false;
|
||||
}
|
||||
// 确认人员faceId是否有券码
|
||||
List<VoucherInfo> voucherDetails = iVoucherService.getVoucherDetails(faceId, face.getScenicId());
|
||||
if (voucherDetails != null && !voucherDetails.isEmpty()) {
|
||||
VoucherInfo voucherInfo = voucherDetails.getFirst();
|
||||
if (voucherInfo.getDiscountType().equals(VoucherDiscountType.FREE_ALL)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
switch (type) {
|
||||
case 1:
|
||||
List<SourceEntity> videoSourceList = sourceMapper.listVideoByFaceRelation(faceId);
|
||||
if (videoSourceList == null || videoSourceList.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return videoSourceList.stream().filter(Objects::nonNull).anyMatch(item -> Integer.valueOf(1).equals(item.getIsBuy()));
|
||||
case 2:
|
||||
List<SourceEntity> imageSourceList = sourceMapper.listImageByFaceRelation(faceId);
|
||||
if (imageSourceList == null || imageSourceList.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return imageSourceList.stream().filter(Objects::nonNull).anyMatch(item -> Integer.valueOf(1).equals(item.getIsBuy()));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public SourceEntity getSource(Long id) {
|
||||
return sourceMapper.getEntity(id);
|
||||
}
|
||||
|
||||
@@ -107,35 +107,6 @@ public class VideoRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean getUserIsBuy(Long userId, Long videoId) {
|
||||
MemberVideoEntity memberVideo = videoMapper.queryUserVideo(userId, videoId);
|
||||
if (memberVideo == null) {
|
||||
return false;
|
||||
}
|
||||
boolean isBuy = Integer.valueOf(1).equals(memberVideo.getIsBuy());
|
||||
if (isBuy) {
|
||||
return isBuy;
|
||||
}
|
||||
|
||||
// 一口价
|
||||
IsBuyBatchRespVO buy = priceBiz.isBuy(userId, memberVideo.getFaceId(), memberVideo.getScenicId(), -1, null);
|
||||
if (buy == null) {
|
||||
return false;
|
||||
}
|
||||
if (buy.isBuy()) {
|
||||
return true;
|
||||
}
|
||||
// 确认人员faceId是否有券码
|
||||
List<VoucherInfo> voucherDetails = iVoucherService.getVoucherDetails(memberVideo.getFaceId(), memberVideo.getScenicId());
|
||||
if (voucherDetails != null && !voucherDetails.isEmpty()) {
|
||||
VoucherInfo voucherInfo = voucherDetails.getFirst();
|
||||
if (voucherInfo.getDiscountType().equals(VoucherDiscountType.FREE_ALL)) {
|
||||
isBuy = true;
|
||||
}
|
||||
}
|
||||
return isBuy;
|
||||
}
|
||||
|
||||
public boolean clearVideoCache(Long videoId) {
|
||||
if (redisTemplate.hasKey(String.format(VIDEO_CACHE_KEY, videoId))) {
|
||||
VideoEntity video = getVideo(videoId);
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.ycwl.basic.exception.BizException;
|
||||
import com.ycwl.basic.mapper.VideoMapper;
|
||||
import com.ycwl.basic.mapper.VideoReviewMapper;
|
||||
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
|
||||
import com.ycwl.basic.repository.DeviceRepository;
|
||||
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO;
|
||||
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
|
||||
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO;
|
||||
@@ -45,6 +46,9 @@ public class VideoReviewServiceImpl implements VideoReviewService {
|
||||
@Autowired
|
||||
private VideoMapper videoMapper;
|
||||
|
||||
@Autowired
|
||||
private DeviceRepository deviceRepository;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
@@ -153,11 +157,37 @@ public class VideoReviewServiceImpl implements VideoReviewService {
|
||||
reqDTO.setPageSize(Integer.MAX_VALUE);
|
||||
List<VideoReviewRespDTO> list = videoReviewMapper.selectReviewList(reqDTO);
|
||||
|
||||
// 2. 创建Excel工作簿
|
||||
// 2. 收集所有机位ID并批量查询机位名称
|
||||
Set<Long> allDeviceIds = new LinkedHashSet<>();
|
||||
for (VideoReviewRespDTO review : list) {
|
||||
Map<String, Map<String, Integer>> cameraRating = review.getCameraPositionRating();
|
||||
if (cameraRating != null && !cameraRating.isEmpty()) {
|
||||
// 收集机位ID (按顺序)
|
||||
for (String deviceIdStr : cameraRating.keySet()) {
|
||||
try {
|
||||
allDeviceIds.add(Long.valueOf(deviceIdStr));
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("无效的机位ID: {}", deviceIdStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查询机位名称
|
||||
Map<Long, String> deviceNames = new HashMap<>();
|
||||
if (!allDeviceIds.isEmpty()) {
|
||||
deviceNames = deviceRepository.batchGetDeviceNames(new ArrayList<>(allDeviceIds));
|
||||
}
|
||||
|
||||
// 对机位ID按ID排序,保证表头顺序一致
|
||||
List<Long> sortedDeviceIds = new ArrayList<>(allDeviceIds);
|
||||
sortedDeviceIds.sort(Long::compareTo);
|
||||
|
||||
// 3. 创建Excel工作簿
|
||||
Workbook workbook = new XSSFWorkbook();
|
||||
Sheet sheet = workbook.createSheet("视频评价数据");
|
||||
|
||||
// 3. 创建标题行样式
|
||||
// 4. 创建标题行样式
|
||||
CellStyle headerStyle = workbook.createCellStyle();
|
||||
Font headerFont = workbook.createFont();
|
||||
headerFont.setBold(true);
|
||||
@@ -165,53 +195,110 @@ public class VideoReviewServiceImpl implements VideoReviewService {
|
||||
headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
|
||||
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
|
||||
// 4. 创建标题行
|
||||
// 5. 创建单元格自动换行样式
|
||||
CellStyle wrapStyle = workbook.createCellStyle();
|
||||
wrapStyle.setWrapText(true);
|
||||
wrapStyle.setVerticalAlignment(VerticalAlignment.TOP);
|
||||
|
||||
// 6. 生成动态表头 - 使用机位名称作为表头
|
||||
Row headerRow = sheet.createRow(0);
|
||||
String[] headers = {"评价ID", "视频ID", "景区ID", "景区名称", "评价人ID", "评价人名称",
|
||||
"评分", "文字评价", "机位评价", "创建时间", "更新时间"};
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
List<String> headerList = new ArrayList<>();
|
||||
headerList.add("评价ID");
|
||||
headerList.add("视频ID");
|
||||
headerList.add("视频模板名称");
|
||||
headerList.add("景区名称");
|
||||
headerList.add("评价人名称");
|
||||
headerList.add("评分");
|
||||
headerList.add("文字评价");
|
||||
|
||||
// 添加机位列 - 表头直接使用机位名称
|
||||
Map<Long, String> finalDeviceNames = deviceNames;
|
||||
for (Long deviceId : sortedDeviceIds) {
|
||||
String deviceName = finalDeviceNames.getOrDefault(deviceId, "未知设备(ID:" + deviceId + ")");
|
||||
headerList.add(deviceName);
|
||||
}
|
||||
|
||||
headerList.add("创建时间");
|
||||
headerList.add("更新时间");
|
||||
|
||||
// 设置表头
|
||||
for (int i = 0; i < headerList.size(); i++) {
|
||||
Cell cell = headerRow.createCell(i);
|
||||
cell.setCellValue(headers[i]);
|
||||
cell.setCellValue(headerList.get(i));
|
||||
cell.setCellStyle(headerStyle);
|
||||
}
|
||||
|
||||
// 5. 填充数据
|
||||
// 7. 填充数据
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
int rowNum = 1;
|
||||
|
||||
for (VideoReviewRespDTO review : list) {
|
||||
Row row = sheet.createRow(rowNum++);
|
||||
row.createCell(0).setCellValue(review.getId());
|
||||
row.createCell(1).setCellValue(review.getVideoId());
|
||||
row.createCell(2).setCellValue(review.getScenicId());
|
||||
row.createCell(3).setCellValue(review.getScenicName());
|
||||
row.createCell(4).setCellValue(review.getCreator());
|
||||
row.createCell(5).setCellValue(review.getCreatorName());
|
||||
row.createCell(6).setCellValue(review.getRating());
|
||||
row.createCell(7).setCellValue(review.getContent());
|
||||
int colIndex = 0;
|
||||
|
||||
// 机位评价JSON转字符串
|
||||
try {
|
||||
String cameraRatingJson = review.getCameraPositionRating() != null ?
|
||||
objectMapper.writeValueAsString(review.getCameraPositionRating()) : "";
|
||||
row.createCell(8).setCellValue(cameraRatingJson);
|
||||
} catch (Exception e) {
|
||||
row.createCell(8).setCellValue("");
|
||||
// 基础信息列
|
||||
row.createCell(colIndex++).setCellValue(review.getId());
|
||||
row.createCell(colIndex++).setCellValue(review.getVideoId());
|
||||
row.createCell(colIndex++).setCellValue(review.getTemplateName() != null ? review.getTemplateName() : "");
|
||||
row.createCell(colIndex++).setCellValue(review.getScenicName());
|
||||
row.createCell(colIndex++).setCellValue(review.getCreatorName());
|
||||
row.createCell(colIndex++).setCellValue(review.getRating());
|
||||
row.createCell(colIndex++).setCellValue(review.getContent());
|
||||
|
||||
// 机位评价列 - 按表头顺序填充
|
||||
Map<String, Map<String, Integer>> cameraRating = review.getCameraPositionRating();
|
||||
for (Long deviceId : sortedDeviceIds) {
|
||||
String deviceIdStr = String.valueOf(deviceId);
|
||||
Map<String, Integer> dimensions = null;
|
||||
|
||||
if (cameraRating != null && cameraRating.containsKey(deviceIdStr)) {
|
||||
dimensions = cameraRating.get(deviceIdStr);
|
||||
}
|
||||
|
||||
// 构建单元格内容: 只显示评分维度(不再重复机位名称)
|
||||
StringBuilder cellContent = new StringBuilder();
|
||||
|
||||
if (dimensions != null && !dimensions.isEmpty()) {
|
||||
// 按维度名排序,保证一致性
|
||||
List<Map.Entry<String, Integer>> sortedDimensions = dimensions.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, Integer> dimEntry : sortedDimensions) {
|
||||
if (!first) {
|
||||
cellContent.append("\n");
|
||||
}
|
||||
cellContent.append(dimEntry.getKey()).append(":").append(dimEntry.getValue());
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
Cell cell = row.createCell(colIndex++);
|
||||
cell.setCellValue(cellContent.toString());
|
||||
cell.setCellStyle(wrapStyle);
|
||||
}
|
||||
|
||||
row.createCell(9).setCellValue(review.getCreateTime() != null ? sdf.format(review.getCreateTime()) : "");
|
||||
row.createCell(10).setCellValue(review.getUpdateTime() != null ? sdf.format(review.getUpdateTime()) : "");
|
||||
// 时间列
|
||||
row.createCell(colIndex++).setCellValue(review.getCreateTime() != null ? sdf.format(review.getCreateTime()) : "");
|
||||
row.createCell(colIndex).setCellValue(review.getUpdateTime() != null ? sdf.format(review.getUpdateTime()) : "");
|
||||
}
|
||||
|
||||
// 6. 自动调整列宽
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
// 8. 自动调整列宽
|
||||
for (int i = 0; i < headerList.size(); i++) {
|
||||
sheet.autoSizeColumn(i);
|
||||
// 对于机位列,设置最小宽度以便换行内容显示完整
|
||||
if (i >= 7 && i < 7 + sortedDeviceIds.size()) {
|
||||
int currentWidth = sheet.getColumnWidth(i);
|
||||
sheet.setColumnWidth(i, Math.max(currentWidth, 5000)); // 最小25个字符宽度
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 写入输出流
|
||||
// 9. 写入输出流
|
||||
workbook.write(outputStream);
|
||||
workbook.close();
|
||||
|
||||
log.info("导出视频评价数据成功,共{}条", list.size());
|
||||
log.info("导出视频评价数据成功,共{}条,机位数:{}", list.size(), sortedDeviceIds.size());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,13 +11,6 @@ import java.util.List;
|
||||
*/
|
||||
public interface GoodsService {
|
||||
|
||||
/**
|
||||
* 查询商品列表
|
||||
* @param query 查询条件
|
||||
* @return
|
||||
*/
|
||||
ApiResponse<List<GoodsPageVO>> goodsList(GoodsReqQuery query);
|
||||
|
||||
/**
|
||||
* 查询源素材商品列表
|
||||
*
|
||||
@@ -57,4 +50,6 @@ public interface GoodsService {
|
||||
* @return 视频更新检查结果
|
||||
*/
|
||||
VideoUpdateCheckVO checkVideoUpdate(Long videoId);
|
||||
|
||||
ApiResponse<List<GoodsPageVO>> listGoodsByFaceIdList(List<Long> faceIds, Integer isBuy, Long scenicId);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import com.ycwl.basic.biz.OrderBiz;
|
||||
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||
import com.ycwl.basic.model.mobile.video.dto.VideoViewPermissionDTO;
|
||||
import com.ycwl.basic.model.pc.task.entity.TaskEntity;
|
||||
import com.ycwl.basic.model.pc.video.entity.UserVideoViewEntity;
|
||||
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.repository.UserVideoViewRepository;
|
||||
import com.ycwl.basic.repository.VideoRepository;
|
||||
import com.ycwl.basic.repository.VideoTaskRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -28,6 +30,7 @@ public class VideoViewPermissionService {
|
||||
private final VideoRepository videoRepository;
|
||||
private final ScenicRepository scenicRepository;
|
||||
private final OrderBiz orderBiz;
|
||||
private final VideoTaskRepository videoTaskRepository;
|
||||
|
||||
/**
|
||||
* 检查并记录用户查看视频
|
||||
@@ -50,9 +53,10 @@ public class VideoViewPermissionService {
|
||||
log.warn("视频缺少景区信息: videoId={}", videoId);
|
||||
return createErrorPermission("视频信息不完整");
|
||||
}
|
||||
TaskEntity taskById = videoTaskRepository.getTaskById(video.getTaskId());
|
||||
|
||||
// 检查用户是否已购买
|
||||
IsBuyRespVO buy = orderBiz.isBuy(userId, scenicId, 0, videoId);
|
||||
IsBuyRespVO buy = orderBiz.isBuy(scenicId, userId, taskById.getFaceId(), 0, videoId);
|
||||
if (buy != null && (buy.isBuy() || buy.isFree())) {
|
||||
// 已购买,不限制查看
|
||||
log.debug("用户已购买视频,无查看限制: userId={}, videoId={}", userId, videoId);
|
||||
@@ -119,9 +123,10 @@ public class VideoViewPermissionService {
|
||||
if (scenicId == null) {
|
||||
return createErrorPermission("视频信息不完整");
|
||||
}
|
||||
TaskEntity taskById = videoTaskRepository.getTaskById(video.getTaskId());
|
||||
|
||||
// 检查用户是否已购买
|
||||
IsBuyRespVO buy = orderBiz.isBuy(userId, scenicId, 0, videoId);
|
||||
IsBuyRespVO buy = orderBiz.isBuy(scenicId, userId, taskById.getFaceId(), 0, videoId);
|
||||
if (buy != null && (buy.isBuy() || buy.isFree())) {
|
||||
// 已购买,不限制查看
|
||||
log.debug("用户已购买视频,无查看限制: userId={}, videoId={}", userId, videoId);
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.ycwl.basic.utils.JacksonUtil;
|
||||
import com.ycwl.basic.biz.CouponBiz;
|
||||
import com.ycwl.basic.biz.OrderBiz;
|
||||
import com.ycwl.basic.biz.TaskStatusBiz;
|
||||
import com.ycwl.basic.constant.BaseContextHandler;
|
||||
import com.ycwl.basic.constant.StorageConstant;
|
||||
import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
|
||||
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
|
||||
@@ -44,8 +43,6 @@ import com.ycwl.basic.repository.VideoRepository;
|
||||
import com.ycwl.basic.repository.VideoTaskRepository;
|
||||
import com.ycwl.basic.service.mobile.GoodsService;
|
||||
import com.ycwl.basic.repository.TemplateRepository;
|
||||
import com.ycwl.basic.repository.SourceRepository;
|
||||
import com.ycwl.basic.biz.TemplateBiz;
|
||||
import com.ycwl.basic.config.VideoUpdateConfig;
|
||||
import com.ycwl.basic.model.repository.TaskUpdateResult;
|
||||
import com.ycwl.basic.service.task.TaskService;
|
||||
@@ -56,6 +53,7 @@ import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.utils.WxMpUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -66,6 +64,7 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -102,95 +101,11 @@ public class GoodsServiceImpl implements GoodsService {
|
||||
@Autowired
|
||||
private CouponBiz couponBiz;
|
||||
@Autowired
|
||||
private SourceRepository sourceRepository;
|
||||
@Autowired
|
||||
private TemplateBiz templateBiz;
|
||||
@Autowired
|
||||
private VideoUpdateConfig videoUpdateConfig;
|
||||
@Autowired
|
||||
private MemberRelationRepository memberRelationRepository;
|
||||
|
||||
public ApiResponse<List<GoodsPageVO>> goodsList(GoodsReqQuery query) {
|
||||
Long scenicId = query.getScenicId();
|
||||
if (query.getFaceId() != null) {
|
||||
FaceEntity face = faceRepository.getFace(query.getFaceId());
|
||||
if (face == null) {
|
||||
return ApiResponse.success(Collections.emptyList());
|
||||
}
|
||||
scenicId = face.getScenicId();
|
||||
}
|
||||
//查询原素材
|
||||
List<GoodsPageVO> goodsList = new ArrayList<>();
|
||||
VideoReqQuery videoReqQuery = new VideoReqQuery();
|
||||
videoReqQuery.setScenicId(scenicId);
|
||||
videoReqQuery.setIsBuy(query.getIsBuy());
|
||||
videoReqQuery.setFaceId(query.getFaceId());
|
||||
//查询成片vlog
|
||||
List<VideoRespVO> videoList = videoMapper.queryByRelation(videoReqQuery);
|
||||
videoList.forEach(videoRespVO -> {
|
||||
GoodsPageVO goodsPageVO = new GoodsPageVO();
|
||||
goodsPageVO.setGoodsName(videoRespVO.getTemplateName());
|
||||
goodsPageVO.setScenicId(videoRespVO.getScenicId());
|
||||
try {
|
||||
ScenicV2DTO scenic = scenicRepository.getScenicBasic(videoRespVO.getScenicId());
|
||||
goodsPageVO.setScenicName(scenic.getName());
|
||||
} catch (Exception e) {
|
||||
goodsPageVO.setScenicName("");
|
||||
}
|
||||
goodsPageVO.setGoodsType(0);
|
||||
goodsPageVO.setFaceId(videoRespVO.getFaceId());
|
||||
goodsPageVO.setGoodsId(videoRespVO.getId());
|
||||
goodsPageVO.setTemplateName(videoRespVO.getTemplateName());
|
||||
goodsPageVO.setTemplateCoverUrl(videoRespVO.getTemplateCoverUrl());
|
||||
goodsList.add(goodsPageVO);
|
||||
});
|
||||
|
||||
SourceReqQuery sourceReqQuery = new SourceReqQuery();
|
||||
sourceReqQuery.setScenicId(scenicId);
|
||||
sourceReqQuery.setIsBuy(query.getIsBuy());
|
||||
sourceReqQuery.setFaceId(query.getFaceId());
|
||||
//查询源素材
|
||||
List<SourceRespVO> sourceList = sourceMapper.queryByRelation(sourceReqQuery);
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
|
||||
List<GoodsPageVO> sourceGoods = sourceList.stream().collect(Collectors.groupingBy(SourceRespVO::getFaceId)).entrySet().stream().flatMap((faceEntry) -> {
|
||||
Long faceId = faceEntry.getKey();
|
||||
List<SourceRespVO> goods = faceEntry.getValue();
|
||||
return goods.stream().collect(Collectors.groupingBy(SourceRespVO::getType)).keySet().stream().filter(type -> {
|
||||
if (Integer.valueOf(1).equals(type)) {
|
||||
return !Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_video"));
|
||||
} else if (Integer.valueOf(2).equals(type)) {
|
||||
return !Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_image"));
|
||||
}
|
||||
return true;
|
||||
}).map(type -> {
|
||||
GoodsPageVO goodsPageVO = new GoodsPageVO();
|
||||
goodsPageVO.setFaceId(faceId);
|
||||
goodsPageVO.setGoodsType(type);
|
||||
if (type == 1) {
|
||||
goodsPageVO.setGoodsName("录像集");
|
||||
goodsPageVO.setTemplateCoverUrl(scenicConfig.getString("video_cover_url"));
|
||||
} else if (type == 2) {
|
||||
goodsPageVO.setGoodsName("照片集");
|
||||
goodsPageVO.setTemplateCoverUrl(scenicConfig.getString("photo_cover_url"));
|
||||
} else {
|
||||
goodsPageVO.setGoodsName("未知商品");
|
||||
}
|
||||
if (StringUtils.isBlank(goodsPageVO.getTemplateCoverUrl())) {
|
||||
goodsPageVO.setTemplateCoverUrl(goods.getFirst().getUrl());
|
||||
}
|
||||
goodsPageVO.setScenicId(query.getScenicId());
|
||||
return goodsPageVO;
|
||||
});
|
||||
}).toList();
|
||||
if (!sourceGoods.isEmpty()) {
|
||||
if (goodsList.size() > 2) {
|
||||
goodsList.addAll(2, sourceGoods);
|
||||
} else {
|
||||
goodsList.addAll(sourceGoods);
|
||||
}
|
||||
}
|
||||
return ApiResponse.success(goodsList);
|
||||
}
|
||||
@Autowired
|
||||
private PrinterMapper printerMapper;
|
||||
|
||||
@Override
|
||||
public List<GoodsDetailVO> sourceGoodsList(GoodsReqQuery query) {
|
||||
@@ -218,7 +133,7 @@ public class GoodsServiceImpl implements GoodsService {
|
||||
//图片编号
|
||||
int i=1;
|
||||
for (SourceRespVO sourceRespVO : list) {
|
||||
GoodsDetailVO goodsDetailVO = new GoodsDetailVO();
|
||||
GoodsDetailPrintSceneVO goodsDetailVO = new GoodsDetailPrintSceneVO();
|
||||
goodsDetailVO.setFaceId(sourceRespVO.getFaceId());
|
||||
goodsDetailVO.setGoodsId(sourceRespVO.getId());
|
||||
String shootingTime = DateUtil.format(sourceRespVO.getCreateTime(), "yyyy.MM.dd HH:mm:ss");
|
||||
@@ -261,6 +176,10 @@ public class GoodsServiceImpl implements GoodsService {
|
||||
goodsDetailVO.setUrl(sourceRespVO.getUrl());
|
||||
goodsDetailVO.setCreateTime(sourceRespVO.getCreateTime());
|
||||
goodsDetailVO.setIsFree(sourceRespVO.getIsFree());
|
||||
if (Strings.CI.equals("print", query.getScene())) {
|
||||
// 查询该素材是否在用户打印列表中
|
||||
goodsDetailVO.setInList(printerMapper.countFacePhoto(sourceRespVO.getScenicId(), face.getId(), sourceRespVO.getId()) > 0);
|
||||
}
|
||||
goodsDetailVOList.add(goodsDetailVO);
|
||||
i++;
|
||||
}
|
||||
@@ -307,7 +226,7 @@ public class GoodsServiceImpl implements GoodsService {
|
||||
goodsDetailVO.setFaceId(entity.getFaceId());
|
||||
goodsDetailVO.setIsBuy(entity.getIsBuy());
|
||||
if (Integer.valueOf(0).equals(entity.getIsBuy())) {
|
||||
IsBuyRespVO buy = orderBiz.isBuy(userId, videoRespVO.getScenicId(), 0, videoId);
|
||||
IsBuyRespVO buy = orderBiz.isBuy(videoRespVO.getScenicId(), userId, entity.getFaceId(), 0, videoId);
|
||||
if (!buy.isBuy()) {
|
||||
PriceObj priceObj = orderBiz.queryPrice(videoRespVO.getScenicId(), 0, videoId);
|
||||
if (priceObj.isFree()) {
|
||||
@@ -581,7 +500,7 @@ public class GoodsServiceImpl implements GoodsService {
|
||||
goodsUrlVO.setCreateTime(source.getCreateTime());
|
||||
return goodsUrlVO;
|
||||
}).collect(Collectors.toList());
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(face.getMemberId(), face.getScenicId(), query.getSourceType(), face.getId());
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), query.getSourceType(), face.getId());
|
||||
if (!isBuy.isBuy()) {
|
||||
ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(face.getScenicId());
|
||||
if (scenicConfig != null && ((scenicConfig.getAntiScreenRecordType() & 2) == 0)) {
|
||||
@@ -673,7 +592,7 @@ public class GoodsServiceImpl implements GoodsService {
|
||||
}
|
||||
return true;
|
||||
}).count();
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(face.getMemberId(), face.getScenicId(), query.getSourceType(), face.getId());
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), query.getSourceType(), face.getId());
|
||||
if (count > 0) {
|
||||
if (!isBuy.isBuy()) {
|
||||
return Collections.emptyList();
|
||||
@@ -829,4 +748,102 @@ public class GoodsServiceImpl implements GoodsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<List<GoodsPageVO>> listGoodsByFaceIdList(List<Long> faceIds, Integer isBuy, Long scenicId) {
|
||||
// 参数校验
|
||||
if (faceIds == null || faceIds.isEmpty()) {
|
||||
return ApiResponse.success(Collections.emptyList());
|
||||
}
|
||||
if (scenicId == null) {
|
||||
return ApiResponse.success(Collections.emptyList());
|
||||
}
|
||||
|
||||
// 获取景区配置
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
|
||||
|
||||
// 使用 LinkedHashMap 按 goodsType-goodsId 去重
|
||||
Map<String, GoodsPageVO> goodsMap = new LinkedHashMap<>();
|
||||
|
||||
// 循环查询每个 faceId 的商品
|
||||
for (Long faceId : faceIds) {
|
||||
// 查询成片 vlog (goodsType = 0)
|
||||
VideoReqQuery videoReqQuery = new VideoReqQuery();
|
||||
videoReqQuery.setScenicId(scenicId);
|
||||
videoReqQuery.setIsBuy(isBuy);
|
||||
videoReqQuery.setFaceId(faceId);
|
||||
|
||||
List<VideoRespVO> videoList = videoMapper.queryByRelation(videoReqQuery);
|
||||
for (VideoRespVO videoRespVO : videoList) {
|
||||
String key = "0-" + videoRespVO.getId(); // goodsType=0, goodsId=videoId
|
||||
if (!goodsMap.containsKey(key)) {
|
||||
GoodsPageVO goodsPageVO = new GoodsPageVO();
|
||||
goodsPageVO.setGoodsName(videoRespVO.getTemplateName());
|
||||
goodsPageVO.setScenicId(videoRespVO.getScenicId());
|
||||
try {
|
||||
ScenicV2DTO scenic = scenicRepository.getScenicBasic(videoRespVO.getScenicId());
|
||||
goodsPageVO.setScenicName(scenic.getName());
|
||||
} catch (Exception e) {
|
||||
goodsPageVO.setScenicName("");
|
||||
}
|
||||
goodsPageVO.setGoodsType(0);
|
||||
goodsPageVO.setFaceId(videoRespVO.getFaceId());
|
||||
goodsPageVO.setGoodsId(videoRespVO.getId());
|
||||
goodsPageVO.setTemplateName(videoRespVO.getTemplateName());
|
||||
goodsPageVO.setTemplateCoverUrl(videoRespVO.getTemplateCoverUrl());
|
||||
goodsMap.put(key, goodsPageVO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询源素材 (goodsType = 1/2) - 使用新的 GROUP BY 方法
|
||||
SourceReqQuery sourceReqQuery = new SourceReqQuery();
|
||||
sourceReqQuery.setScenicId(scenicId);
|
||||
sourceReqQuery.setIsBuy(isBuy);
|
||||
sourceReqQuery.setFaceIds(faceIds);
|
||||
|
||||
// 使用 queryGroupedByFaceAndType 方法,数据库已经按 faceId+type 分组
|
||||
List<SourceRespVO> sourceList = sourceMapper.queryGroupedByFaceAndType(sourceReqQuery);
|
||||
|
||||
// 遍历分组后的结果,每个 faceId+type 组合只有一条记录
|
||||
for (SourceRespVO source : sourceList) {
|
||||
Integer type = source.getType();
|
||||
Long sourceFaceId = source.getFaceId();
|
||||
|
||||
// 根据景区配置过滤禁用的素材类型
|
||||
boolean isDisabled = false;
|
||||
if (Integer.valueOf(1).equals(type)) {
|
||||
isDisabled = Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_video"));
|
||||
} else if (Integer.valueOf(2).equals(type)) {
|
||||
isDisabled = Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_image"));
|
||||
}
|
||||
|
||||
if (!isDisabled) {
|
||||
String key = type + "-" + sourceFaceId; // goodsType=type, goodsId=faceId(源素材用faceId作为ID)
|
||||
if (!goodsMap.containsKey(key)) {
|
||||
GoodsPageVO goodsPageVO = new GoodsPageVO();
|
||||
goodsPageVO.setFaceId(sourceFaceId);
|
||||
goodsPageVO.setGoodsType(type);
|
||||
if (type == 1) {
|
||||
goodsPageVO.setGoodsName("录像集");
|
||||
goodsPageVO.setTemplateCoverUrl(scenicConfig.getString("video_cover_url"));
|
||||
} else if (type == 2) {
|
||||
goodsPageVO.setGoodsName("照片集");
|
||||
goodsPageVO.setTemplateCoverUrl(scenicConfig.getString("photo_cover_url"));
|
||||
} else {
|
||||
goodsPageVO.setGoodsName("未知商品");
|
||||
}
|
||||
if (StringUtils.isBlank(goodsPageVO.getTemplateCoverUrl())) {
|
||||
goodsPageVO.setTemplateCoverUrl(source.getUrl());
|
||||
}
|
||||
goodsPageVO.setScenicId(scenicId);
|
||||
goodsMap.put(key, goodsPageVO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回去重后的商品列表
|
||||
List<GoodsPageVO> resultList = new ArrayList<>(goodsMap.values());
|
||||
return ApiResponse.success(resultList);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public interface FaceService {
|
||||
|
||||
FaceRecognizeResp faceUpload(MultipartFile file, Long scenicId, Long userId, String scene);
|
||||
|
||||
List<FaceRespVO> listByUser(Long userId, String scenicId);
|
||||
List<FaceRespVO> listByUser(Long userId, Long scenicId);
|
||||
SearchFaceRespVo matchFaceId(Long faceId);
|
||||
|
||||
SearchFaceRespVo matchFaceId(Long faceId, boolean isNew);
|
||||
|
||||
@@ -45,6 +45,15 @@ import com.ycwl.basic.model.pc.video.entity.VideoEntity;
|
||||
import com.ycwl.basic.model.repository.TaskUpdateResult;
|
||||
|
||||
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||
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.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||
import com.ycwl.basic.repository.DeviceRepository;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||
@@ -173,6 +182,13 @@ public class FaceServiceImpl implements FaceService {
|
||||
private BuyStatusProcessor buyStatusProcessor;
|
||||
@Autowired
|
||||
private VideoRecreationHandler videoRecreationHandler;
|
||||
@Autowired
|
||||
private PuzzleGenerationRecordMapper puzzleGenerationRecordMapper;
|
||||
@Autowired
|
||||
private IPriceCalculationService iPriceCalculationService;
|
||||
@Autowired
|
||||
private PuzzleTemplateMapper puzzleTemplateMapper;
|
||||
|
||||
@Override
|
||||
public ApiResponse<PageInfo<FaceRespVO>> pageQuery(FaceReqQuery faceReqQuery) {
|
||||
PageHelper.startPage(faceReqQuery.getPageNum(),faceReqQuery.getPageSize());
|
||||
@@ -303,7 +319,7 @@ public class FaceServiceImpl implements FaceService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FaceRespVO> listByUser(Long userId, String scenicId) {
|
||||
public List<FaceRespVO> listByUser(Long userId, Long scenicId) {
|
||||
return faceMapper.listByScenicAndUserId(scenicId, userId);
|
||||
}
|
||||
|
||||
@@ -438,6 +454,50 @@ public class FaceServiceImpl implements FaceService {
|
||||
}
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
List<PuzzleTemplateEntity> puzzleTemplateEntityList = puzzleTemplateMapper.list(face.getScenicId(), null, 1);
|
||||
if (!puzzleTemplateEntityList.isEmpty()) {
|
||||
List<PuzzleGenerationRecordEntity> records = puzzleGenerationRecordMapper.listByFaceId(faceId);
|
||||
puzzleTemplateEntityList.forEach(template -> {
|
||||
Optional<PuzzleGenerationRecordEntity> optionalRecord = records.stream().filter(r -> r.getTemplateId().equals(template.getId())).findFirst();
|
||||
ContentPageVO sfpContent = new ContentPageVO();
|
||||
sfpContent.setName(template.getName());
|
||||
sfpContent.setGroup("plog");
|
||||
sfpContent.setScenicId(face.getScenicId());
|
||||
sfpContent.setContentType(3);
|
||||
sfpContent.setSourceType(3);
|
||||
sfpContent.setLockType(-1);
|
||||
sfpContent.setContentId(optionalRecord.map(PuzzleGenerationRecordEntity::getId).orElse(null));
|
||||
sfpContent.setTemplateId(template.getId());
|
||||
sfpContent.setTemplateCoverUrl(template.getCoverImage());
|
||||
sfpContent.setGoodsType(3);
|
||||
sfpContent.setSort(0);
|
||||
if (optionalRecord.isPresent()) {
|
||||
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), faceId, 5, optionalRecord.get().getTemplateId());
|
||||
if (isBuyRespVO.isBuy()) {
|
||||
sfpContent.setIsBuy(1);
|
||||
} else {
|
||||
sfpContent.setIsBuy(0);
|
||||
}
|
||||
}
|
||||
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
|
||||
ProductItem productItem = new ProductItem();
|
||||
productItem.setProductType(ProductType.PHOTO_LOG);
|
||||
productItem.setProductId(template.getId().toString());
|
||||
productItem.setPurchaseCount(1);
|
||||
productItem.setScenicId(face.getScenicId().toString());
|
||||
calculationRequest.setProducts(Collections.singletonList(productItem));
|
||||
calculationRequest.setUserId(face.getMemberId());
|
||||
calculationRequest.setFaceId(face.getId());
|
||||
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
PriceCalculationResult calculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
||||
if (calculationResult.getFinalAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
sfpContent.setFreeCount(0);
|
||||
} else {
|
||||
sfpContent.setFreeCount(1);
|
||||
}
|
||||
contentList.add(1, sfpContent);
|
||||
});
|
||||
}
|
||||
SourceReqQuery sourceReqQuery = new SourceReqQuery();
|
||||
sourceReqQuery.setScenicId(face.getScenicId());
|
||||
sourceReqQuery.setFaceId(faceId);
|
||||
@@ -460,9 +520,8 @@ public class FaceServiceImpl implements FaceService {
|
||||
sourceImageContent.setLockType(-1);
|
||||
sourceVideoContent.setGroup("直出原片");
|
||||
sourceImageContent.setGroup("直出原片");
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
|
||||
if (!scenicConfigFacade.isDisableSourceImage(face.getScenicId())) {
|
||||
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(userId, face.getScenicId(), SourceType.IMAGE.getCode(), faceId);
|
||||
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), userId, faceId, SourceType.IMAGE.getCode(), faceId);
|
||||
sourceImageContent.setSourceType(isBuyRespVO.getGoodsType());
|
||||
sourceImageContent.setContentId(isBuyRespVO.getGoodsId());
|
||||
if (isBuyRespVO.isBuy()) {
|
||||
@@ -481,7 +540,7 @@ public class FaceServiceImpl implements FaceService {
|
||||
contentList.add(sourceImageContent);
|
||||
}
|
||||
if (!scenicConfigFacade.isDisableSourceVideo(face.getScenicId())) {
|
||||
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(userId, face.getScenicId(), SourceType.VIDEO.getCode(), faceId);
|
||||
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), userId, faceId, SourceType.VIDEO.getCode(), faceId);
|
||||
sourceVideoContent.setSourceType(isBuyRespVO.getGoodsType());
|
||||
sourceVideoContent.setContentId(isBuyRespVO.getGoodsId());
|
||||
if (isBuyRespVO.isBuy()) {
|
||||
@@ -513,7 +572,6 @@ public class FaceServiceImpl implements FaceService {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return contentList;
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,9 @@ public class OrderServiceImpl implements OrderService {
|
||||
} else if (Integer.valueOf(4).equals(orderItemList.getFirst().getGoodsType())) {
|
||||
item.setGoodsName("一体机打印");
|
||||
item.setOrderType("一体机打印");
|
||||
} else if (Integer.valueOf(5).equals(orderItemList.getFirst().getGoodsType())) {
|
||||
item.setGoodsName("plog图");
|
||||
item.setOrderType("plog图");
|
||||
} else {
|
||||
item.setGoodsName("未知商品");
|
||||
item.setOrderType("未知商品");
|
||||
@@ -447,6 +450,8 @@ public class OrderServiceImpl implements OrderService {
|
||||
goodsName = "景区录像包";
|
||||
} else if (type == 2) {
|
||||
goodsName = "景区照片包";
|
||||
} else {
|
||||
goodsName = "景区售卖物品";
|
||||
}
|
||||
}
|
||||
wxPayOrderReqVO.setOpenId(order.getOpenId())
|
||||
@@ -691,10 +696,6 @@ public class OrderServiceImpl implements OrderService {
|
||||
|
||||
// 使用synchronized确保同一用户对同一商品的订单创建操作串行化
|
||||
synchronized (orderKey.intern()) {
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(userId, createOrderReqVO.getScenicId(), createOrderReqVO.getGoodsType(), createOrderReqVO.getGoodsId());
|
||||
if (isBuy.isBuy()) {
|
||||
return ApiResponse.fail("您已购买此内容,无需重复购买!");
|
||||
}
|
||||
// 看看有没有之前购买的订单
|
||||
OrderEntity order = orderMapper.getUserOrderItem(userId, createOrderReqVO.getScenicId(), 0, null, createOrderReqVO.getGoodsType(), createOrderReqVO.getGoodsId());
|
||||
boolean haveOldOrder = false;
|
||||
@@ -722,8 +723,14 @@ public class OrderServiceImpl implements OrderService {
|
||||
order.setPrice(priceObj.getPrice());
|
||||
// 判断是否是本人数据
|
||||
FaceEntity goodsFace = faceRepository.getFace(priceObj.getFaceId());
|
||||
if (goodsFace != null && !goodsFace.getMemberId().equals(userId)) {
|
||||
return ApiResponse.fail("您无权购买此内容!");
|
||||
if (goodsFace != null) {
|
||||
if (!goodsFace.getMemberId().equals(userId)) {
|
||||
return ApiResponse.fail("您无权购买此内容!");
|
||||
}
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(createOrderReqVO.getScenicId(), userId, goodsFace.getId(), createOrderReqVO.getGoodsType(), createOrderReqVO.getGoodsId());
|
||||
if (isBuy.isBuy()) {
|
||||
return ApiResponse.fail("您已购买此内容,无需重复购买!");
|
||||
}
|
||||
}
|
||||
// promo code
|
||||
order.setPayPrice(priceObj.getPrice());
|
||||
@@ -920,12 +927,14 @@ public class OrderServiceImpl implements OrderService {
|
||||
FaceEntity face = faceRepository.getFace(request.getFaceId());
|
||||
ProductItem productItem = request.getProducts().getFirst();
|
||||
Integer type = switch (productItem.getProductType()) {
|
||||
case PHOTO_LOG -> 5;
|
||||
case PHOTO_SET -> 2;
|
||||
case VLOG_VIDEO -> 0;
|
||||
case RECORDING_SET -> 1;
|
||||
default -> 0;
|
||||
};
|
||||
Long goodsId = switch (productItem.getProductType()) {
|
||||
case PHOTO_LOG -> Long.valueOf(productItem.getProductId());
|
||||
case PHOTO_SET, RECORDING_SET -> face.getId();
|
||||
case VLOG_VIDEO -> {
|
||||
List<MemberVideoEntity> videos = memberRelationRepository.listRelationByFaceAndTemplate(face.getId(), Long.valueOf(productItem.getProductId()));
|
||||
@@ -933,7 +942,7 @@ public class OrderServiceImpl implements OrderService {
|
||||
}
|
||||
default -> 0L;
|
||||
};
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(userId, face.getScenicId(), type, goodsId);
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(face.getScenicId(), userId, face.getId(), type, goodsId);
|
||||
if (isBuy.isBuy()) {
|
||||
throw new BaseException("您已购买此内容,无需重复购买!");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
package com.ycwl.basic.service.pc.orchestrator;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import com.ycwl.basic.biz.TaskStatusBiz;
|
||||
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
|
||||
import com.ycwl.basic.puzzle.service.IPuzzleTemplateService;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.ycwl.basic.biz.OrderBiz;
|
||||
import com.ycwl.basic.exception.BaseException;
|
||||
@@ -79,6 +92,14 @@ public class FaceMatchingOrchestrator {
|
||||
private BuyStatusProcessor buyStatusProcessor;
|
||||
@Autowired
|
||||
private VideoRecreationHandler videoRecreationHandler;
|
||||
@Autowired
|
||||
private IPuzzleTemplateService puzzleTemplateService;
|
||||
@Autowired
|
||||
private IPuzzleGenerateService puzzleGenerateService;
|
||||
@Autowired
|
||||
private PuzzleGenerationRecordMapper puzzleGenerationRecordMapper;
|
||||
@Autowired
|
||||
private TaskStatusBiz taskStatusBiz;
|
||||
|
||||
/**
|
||||
* 编排人脸匹配的完整流程
|
||||
@@ -92,6 +113,11 @@ public class FaceMatchingOrchestrator {
|
||||
throw new IllegalArgumentException("faceId 不能为空");
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
// 新用户,设置任务状态为待处理
|
||||
taskStatusBiz.setFaceCutStatus(faceId, 0);
|
||||
}
|
||||
|
||||
// 步骤1: 数据准备
|
||||
MatchingContext context = prepareMatchingContext(faceId, isNew);
|
||||
if (context == null) {
|
||||
@@ -106,7 +132,7 @@ public class FaceMatchingOrchestrator {
|
||||
SearchFaceRespVo searchResult = executeFaceRecognition(context);
|
||||
if (searchResult == null) {
|
||||
log.warn("人脸识别返回结果为空,faceId={}", faceId);
|
||||
throw new BaseException("人脸识别失败,请换一张试试把~");
|
||||
throw new BaseException("人脸识别失败,请换一张试试把~");
|
||||
}
|
||||
|
||||
// 执行补救逻辑
|
||||
@@ -119,6 +145,9 @@ public class FaceMatchingOrchestrator {
|
||||
// 步骤4-6: 处理源文件关联和业务逻辑
|
||||
processSourceRelations(context, searchResult, faceId, isNew);
|
||||
|
||||
// 步骤7: 异步生成拼图模板
|
||||
asyncGeneratePuzzleTemplate(context.face.getScenicId(), faceId, context.face.getMemberId());
|
||||
|
||||
return searchResult;
|
||||
|
||||
} catch (BaseException e) {
|
||||
@@ -318,9 +347,93 @@ public class FaceMatchingOrchestrator {
|
||||
taskService.autoCreateTaskByFaceId(faceId);
|
||||
} else {
|
||||
log.debug("景区配置 face_select_first=true,跳过自动创建任务:faceId={}", faceId);
|
||||
taskStatusBiz.setFaceCutStatus(faceId, 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤8: 异步生成拼图模板
|
||||
* 在人脸匹配完成后,异步为该景区的所有启用的拼图模板生成图片
|
||||
*/
|
||||
private void asyncGeneratePuzzleTemplate(Long scenicId, Long faceId, Long memberId) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
log.info("开始异步生成景区拼图模板: scenicId={}, faceId={}", scenicId, faceId);
|
||||
|
||||
// 查询该景区所有启用状态的拼图模板
|
||||
List<PuzzleTemplateDTO> templateList = puzzleTemplateService.listTemplates(
|
||||
scenicId, null, 1); // 查询启用状态的模板
|
||||
|
||||
if (templateList == null || templateList.isEmpty()) {
|
||||
log.info("景区不存在启用的拼图模板,跳过生成: scenicId={}", scenicId);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("景区存在 {} 个启用的拼图模板,开始逐个生成: scenicId={}", templateList.size(), scenicId);
|
||||
|
||||
// 获取人脸信息用于动态数据
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
log.warn("人脸信息不存在,无法生成拼图: faceId={}", faceId);
|
||||
return;
|
||||
}
|
||||
ScenicV2DTO scenicBasic = scenicRepository.getScenicBasic(face.getScenicId());
|
||||
|
||||
// 准备公共动态数据
|
||||
Map<String, String> baseDynamicData = new HashMap<>();
|
||||
if (face.getFaceUrl() != null) {
|
||||
baseDynamicData.put("faceImage", face.getFaceUrl());
|
||||
baseDynamicData.put("userAvatar", face.getFaceUrl());
|
||||
}
|
||||
baseDynamicData.put("faceId", String.valueOf(faceId));
|
||||
baseDynamicData.put("scenicName", scenicBasic.getName());
|
||||
baseDynamicData.put("scenicText", scenicBasic.getName());
|
||||
baseDynamicData.put("dateStr", DateUtil.format(new Date(), "yyyy.MM.dd"));
|
||||
|
||||
// 遍历所有模板,逐个生成
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
for (PuzzleTemplateDTO template : templateList) {
|
||||
try {
|
||||
log.info("开始生成拼图: scenicId={}, templateCode={}, templateName={}",
|
||||
scenicId, template.getCode(), template.getName());
|
||||
|
||||
// 构建生成请求
|
||||
PuzzleGenerateRequest generateRequest = new PuzzleGenerateRequest();
|
||||
generateRequest.setScenicId(scenicId);
|
||||
generateRequest.setUserId(memberId);
|
||||
generateRequest.setFaceId(faceId);
|
||||
generateRequest.setBusinessType("face_matching");
|
||||
generateRequest.setTemplateCode(template.getCode());
|
||||
generateRequest.setOutputFormat("PNG");
|
||||
generateRequest.setQuality(90);
|
||||
generateRequest.setDynamicData(new HashMap<>(baseDynamicData));
|
||||
generateRequest.setRequireRuleMatch(true);
|
||||
|
||||
// 调用拼图生成服务
|
||||
PuzzleGenerateResponse response = puzzleGenerateService.generate(generateRequest);
|
||||
|
||||
log.info("拼图生成成功: scenicId={}, templateCode={}, imageUrl={}",
|
||||
scenicId, template.getCode(), response.getImageUrl());
|
||||
successCount++;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("拼图生成失败: scenicId={}, templateCode={}, templateName={}",
|
||||
scenicId, template.getCode(), template.getName(), e);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("景区拼图模板批量生成完成: scenicId={}, 总数={}, 成功={}, 失败={}",
|
||||
scenicId, templateList.size(), successCount, failCount);
|
||||
|
||||
} catch (Exception e) {
|
||||
// 异步任务失败不影响主流程,仅记录日志
|
||||
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
|
||||
}
|
||||
}, "PuzzleTemplateGenerator-" + scenicId).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配上下文
|
||||
* 封装匹配过程中需要的所有上下文信息
|
||||
|
||||
@@ -45,7 +45,7 @@ public class BuyStatusProcessor {
|
||||
}
|
||||
|
||||
// 获取用户购买状态
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(memberId, scenicId,
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(scenicId, memberId, faceId,
|
||||
memberSourceEntityList.getFirst().getType(),
|
||||
faceId);
|
||||
|
||||
|
||||
@@ -90,4 +90,15 @@ public interface PrinterService {
|
||||
* @return 成功数量
|
||||
*/
|
||||
int rejectPrintTasks(List<Integer> taskIds);
|
||||
|
||||
/**
|
||||
* 从拼图记录添加到打印列表
|
||||
* @param memberId 用户ID
|
||||
* @param scenicId 景区ID
|
||||
* @param faceId 人脸ID
|
||||
* @param resultImageUrl 拼图结果图片URL
|
||||
* @param puzzleRecordId 拼图记录ID
|
||||
* @return 添加成功的MemberPrint记录ID
|
||||
*/
|
||||
Integer addUserPhotoFromPuzzle(Long memberId, Long scenicId, Long faceId, String resultImageUrl, Long puzzleRecordId);
|
||||
}
|
||||
@@ -755,7 +755,7 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
PrinterEntity printer = printerMapper.getById(item.getPrinterId());
|
||||
// 水印处理逻辑(仅当sourceId不为空时执行)
|
||||
String printUrl = item.getCropUrl();
|
||||
if (item.getSourceId() != null) {
|
||||
if (item.getSourceId() != null && item.getSourceId() > 0) {
|
||||
try {
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId());
|
||||
String printWatermarkType = scenicConfig.getString("print_watermark_type");
|
||||
@@ -789,11 +789,18 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
boolean needRotation = false;
|
||||
|
||||
try {
|
||||
HttpUtil.downloadFile(item.getCropUrl().replace("oss.zhentuai.com", "frametour-assets.oss-cn-shanghai-internal.aliyuncs.com"), originalFile);
|
||||
HttpUtil.downloadFile(item.getCropUrl(), originalFile);
|
||||
WatermarkInfo watermarkInfo = new WatermarkInfo();
|
||||
|
||||
// 判断图片方向并处理旋转
|
||||
boolean isLandscape = ImageUtils.isLandscape(originalFile);
|
||||
log.info("打印照片方向检测,照片ID: {}, 是否为横图: {}", item.getId(), isLandscape);
|
||||
boolean isLandscape = false;
|
||||
try {
|
||||
Integer rotate = JacksonUtil.getInt(item.getCrop(), "rotation");
|
||||
if (rotate != null) {
|
||||
isLandscape = rotate % 180 == 0;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
if (!isLandscape) {
|
||||
// 竖图需要旋转为横图
|
||||
@@ -801,10 +808,10 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
rotatedOriginalFile = new File("print_" + processId + "_rotated.jpg");
|
||||
ImageUtils.rotateImage90(originalFile, rotatedOriginalFile);
|
||||
log.info("竖图已旋转为横图,照片ID: {}", item.getId());
|
||||
watermarkInfo.setOffsetLeft(40);
|
||||
}
|
||||
|
||||
// 处理水印
|
||||
WatermarkInfo watermarkInfo = new WatermarkInfo();
|
||||
watermarkInfo.setScenicLine(scenicConfig.getString("print_watermark_scenic_text", ""));
|
||||
watermarkInfo.setOriginalFile(needRotation ? rotatedOriginalFile : originalFile);
|
||||
watermarkInfo.setWatermarkedFile(watermarkedFile);
|
||||
@@ -856,6 +863,55 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
} catch (Exception e) {
|
||||
log.error("获取景区配置失败,使用原始照片进行打印。景区ID: {}, 照片ID: {}", item.getScenicId(), item.getId(), e);
|
||||
}
|
||||
} else if (item.getSourceId() != null && item.getSourceId() == 0) {
|
||||
// 拼图:添加白边框并向上偏移以避免打印机偏移
|
||||
try {
|
||||
// 生成唯一的处理标识符,避免多线程环境下的文件冲突
|
||||
String processId = item.getId() + "_" + UUID.randomUUID().toString();
|
||||
File originalFile = new File("puzzle_" + processId + ".png");
|
||||
File processedFile = new File("puzzle_" + processId + "_processed.png");
|
||||
|
||||
try {
|
||||
// 下载原图
|
||||
HttpUtil.downloadFile(item.getCropUrl(), originalFile);
|
||||
|
||||
// 添加白边框(左右20px,上下30px)并向上偏移15px
|
||||
ImageUtils.addBorderAndShiftUp(originalFile, processedFile, 20, 30, 15);
|
||||
|
||||
// 上传处理后的图片
|
||||
IStorageAdapter adapter;
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId());
|
||||
String storeType = scenicConfig.getString("store_type");
|
||||
if (storeType != null) {
|
||||
adapter = StorageFactory.get(storeType);
|
||||
String storeConfigJson = scenicConfig.getString("store_config_json");
|
||||
if (StringUtils.isNotBlank(storeConfigJson)) {
|
||||
adapter.loadConfig(JacksonUtil.parseObject(storeConfigJson, Map.class));
|
||||
}
|
||||
} else {
|
||||
adapter = StorageFactory.use("assets-ext");
|
||||
}
|
||||
|
||||
String processedUrl = adapter.uploadFile(null, processedFile, StorageConstant.PHOTO_WATERMARKED_PATH, processedFile.getName());
|
||||
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH, processedFile.getName());
|
||||
|
||||
printUrl = processedUrl;
|
||||
log.info("拼图照片添加白边框并向上偏移成功,照片ID: {}, 新URL: {}", item.getId(), processedUrl);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("拼图照片处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e);
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
if (originalFile != null && originalFile.exists()) {
|
||||
originalFile.delete();
|
||||
}
|
||||
if (processedFile != null && processedFile.exists()) {
|
||||
processedFile.delete();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("拼图照片处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据数量创建多个打印任务
|
||||
@@ -965,7 +1021,7 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
Long faceId = null;
|
||||
|
||||
// 查询该用户在该景区的所有人脸记录
|
||||
List<FaceRespVO> userFaces = faceMapper.listByScenicAndUserId(scenicId.toString(), userId);
|
||||
List<FaceRespVO> userFaces = faceMapper.listByScenicAndUserId(scenicId, userId);
|
||||
|
||||
// 查找是否存在相同URL的记录
|
||||
for (FaceRespVO faceResp : userFaces) {
|
||||
@@ -1202,4 +1258,56 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
|
||||
return selectedPrinter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer addUserPhotoFromPuzzle(Long memberId, Long scenicId, Long faceId, String resultImageUrl, Long puzzleRecordId) {
|
||||
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
|
||||
log.error("拼图图片URL为空: memberId={}, scenicId={}, puzzleRecordId={}", memberId, scenicId, puzzleRecordId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查是否已经存在未打印的记录(status=0),避免重复导入
|
||||
List<MemberPrintResp> existingPhotos = printerMapper.listRelationByFaceId(memberId, scenicId, faceId);
|
||||
if (existingPhotos != null && !existingPhotos.isEmpty()) {
|
||||
for (MemberPrintResp photo : existingPhotos) {
|
||||
// 检查是否是同一个拼图记录且状态为0(未打印)
|
||||
if (photo.getOrigUrl() != null && photo.getOrigUrl().equals(resultImageUrl)
|
||||
&& photo.getStatus() != null && photo.getStatus() == 0) {
|
||||
log.info("拼图照片已存在于打印列表中,直接返回: memberId={}, scenicId={}, puzzleRecordId={}, memberPrintId={}",
|
||||
memberId, scenicId, puzzleRecordId, photo.getId());
|
||||
return photo.getId();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MemberPrintEntity entity = new MemberPrintEntity();
|
||||
entity.setMemberId(memberId);
|
||||
entity.setScenicId(scenicId);
|
||||
entity.setFaceId(faceId);
|
||||
entity.setSourceId(puzzleRecordId); // 使用拼图记录ID作为sourceId
|
||||
entity.setOrigUrl(resultImageUrl);
|
||||
|
||||
// 获取打印尺寸并裁剪图片
|
||||
String cropUrl = resultImageUrl; // 默认使用原图
|
||||
|
||||
entity.setCropUrl(cropUrl);
|
||||
entity.setStatus(0);
|
||||
|
||||
try {
|
||||
int rows = printerMapper.addUserPhoto(entity);
|
||||
if (rows > 0 && entity.getId() != null) {
|
||||
log.info("拼图照片添加到打印列表成功: memberId={}, scenicId={}, puzzleRecordId={}, memberPrintId={}",
|
||||
memberId, scenicId, puzzleRecordId, entity.getId());
|
||||
return entity.getId();
|
||||
} else {
|
||||
log.error("拼图照片添加到打印列表失败: memberId={}, scenicId={}, puzzleRecordId={}",
|
||||
memberId, scenicId, puzzleRecordId);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("拼图照片添加到打印列表异常: memberId={}, scenicId={}, puzzleRecordId={}",
|
||||
memberId, scenicId, puzzleRecordId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ public class TaskFaceServiceImpl implements TaskFaceService {
|
||||
return memberSourceEntity;
|
||||
}).collect(Collectors.toList());
|
||||
if (!memberSourceEntityList.isEmpty()) {
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(face.getMemberId(), face.getScenicId(), memberSourceEntityList.getFirst().getType(), faceEntity.getId());
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), faceEntity.getId(), memberSourceEntityList.getFirst().getType(), faceEntity.getId());
|
||||
for (MemberSourceEntity memberSourceEntity : memberSourceEntityList) {
|
||||
if (isBuy.isBuy()) { // 如果用户买过
|
||||
memberSourceEntity.setIsBuy(1);
|
||||
|
||||
@@ -442,7 +442,7 @@ public class TaskTaskServiceImpl implements TaskService {
|
||||
memberVideoEntity.setTaskId(list.getFirst().getId());
|
||||
VideoEntity video = videoMapper.findByTaskId(list.getFirst().getId());
|
||||
if (video != null) {
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(face.getMemberId(), list.getFirst().getScenicId(), 0, video.getId());
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(list.getFirst().getScenicId(), face.getMemberId(), face.getId(), 0, video.getId());
|
||||
if (isBuy.isBuy()) {
|
||||
memberVideoEntity.setIsBuy(1);
|
||||
memberVideoEntity.setOrderId(isBuy.getOrderId());
|
||||
@@ -516,7 +516,7 @@ public class TaskTaskServiceImpl implements TaskService {
|
||||
int isBuy = 0;
|
||||
FaceEntity face = faceRepository.getFace(task.getFaceId());
|
||||
if (face != null) {
|
||||
IsBuyRespVO priceObj = orderBiz.isBuy(face.getMemberId(), task.getScenicId(), 0, video.getId());
|
||||
IsBuyRespVO priceObj = orderBiz.isBuy(task.getScenicId(), face.getMemberId(), face.getId(), 0, video.getId());
|
||||
if (priceObj.isBuy()) {
|
||||
isBuy = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.ycwl.basic.task;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ycwl.basic.device.DeviceFactory;
|
||||
import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
|
||||
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||
import com.ycwl.basic.device.operator.IDeviceStorageOperator;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
|
||||
import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
||||
import com.ycwl.basic.repository.DeviceRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 设备视频连续性检查定时任务
|
||||
* - 仅在生产环境(prod)运行
|
||||
* - 每5分钟执行一次
|
||||
* - 检查前12分钟到前2分钟的视频连续性
|
||||
* - 仅在9点到18点之间检查
|
||||
* - 结果缓存到Redis中
|
||||
*
|
||||
* @author Claude Code
|
||||
* @date 2025-09-01
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@EnableScheduling
|
||||
@Profile("prod")
|
||||
public class DeviceVideoContinuityCheckTask {
|
||||
|
||||
private static final String REDIS_KEY_PREFIX = "device:video:continuity:";
|
||||
private static final int CACHE_TTL_HOURS = 24; // 缓存24小时
|
||||
private static final int START_HOUR = 9; // 开始检查时间 9:00
|
||||
private static final int END_HOUR = 18; // 结束检查时间 18:00
|
||||
|
||||
@Autowired
|
||||
private DeviceIntegrationService deviceIntegrationService;
|
||||
|
||||
@Autowired
|
||||
private DeviceRepository deviceRepository;
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 定时任务:每5分钟执行一次
|
||||
* cron表达式: 0 0/5 * * * * 表示每5分钟执行一次
|
||||
*/
|
||||
@Scheduled(cron = "0 0/5 * * * *")
|
||||
public void checkDeviceVideoContinuity() {
|
||||
// 检查是否在执行时间范围内(9:00-18:00)
|
||||
int currentHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
|
||||
if (currentHour < START_HOUR || currentHour >= END_HOUR) {
|
||||
log.debug("当前时间 {}:00 不在检查时间范围内({}:00-{}:00),跳过检查",
|
||||
currentHour, START_HOUR, END_HOUR);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("开始执行设备视频连续性检查定时任务");
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
// 获取所有激活的设备(分页获取,每次100个)
|
||||
int pageSize = 100;
|
||||
int currentPage = 1;
|
||||
int totalChecked = 0;
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
|
||||
while (true) {
|
||||
PageResponse<DeviceV2DTO> pageResponse = deviceIntegrationService.listDevices(
|
||||
currentPage, pageSize, null, null, null, 1, null
|
||||
);
|
||||
|
||||
if (pageResponse == null || pageResponse.getList() == null
|
||||
|| pageResponse.getList().isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查每个设备的视频连续性
|
||||
for (DeviceV2DTO device : pageResponse.getList()) {
|
||||
try {
|
||||
boolean checked = checkSingleDevice(device);
|
||||
totalChecked++;
|
||||
if (checked) {
|
||||
successCount++;
|
||||
} else {
|
||||
failureCount++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("检查设备 {} 视频连续性失败: {}", device.getId(), e.getMessage(), e);
|
||||
failureCount++;
|
||||
totalChecked++;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否还有更多页
|
||||
int totalPages = (int) Math.ceil((double) pageResponse.getTotal() / pageSize);
|
||||
if (currentPage >= totalPages) {
|
||||
break;
|
||||
}
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
long endTime = System.currentTimeMillis();
|
||||
log.info("设备视频连续性检查任务完成: 总计检查 {} 个设备, 成功 {}, 失败 {}, 耗时 {}ms",
|
||||
totalChecked, successCount, failureCount, (endTime - startTime));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("执行设备视频连续性检查定时任务失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个设备的视频连续性
|
||||
*
|
||||
* @param device 设备信息
|
||||
* @return true表示检查成功并缓存,false表示跳过检查
|
||||
*/
|
||||
private boolean checkSingleDevice(DeviceV2DTO device) {
|
||||
try {
|
||||
// 获取设备配置
|
||||
DeviceConfigEntity config = deviceRepository.getDeviceConfig(device.getId());
|
||||
if (config == null) {
|
||||
log.debug("设备 {} 没有配置信息,跳过检查", device.getId());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取设备的存储操作器
|
||||
IDeviceStorageOperator operator = DeviceFactory.getDeviceStorageOperator(device, config);
|
||||
if (operator == null) {
|
||||
log.debug("设备 {} 没有配置存储操作器,跳过检查", device.getId());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算检查时间范围: 当前时间向前12分钟到向前2分钟
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.add(Calendar.MINUTE, -2);
|
||||
Date endDate = calendar.getTime();
|
||||
|
||||
calendar.add(Calendar.MINUTE, -10); // 再向前10分钟,总共12分钟
|
||||
Date startDate = calendar.getTime();
|
||||
|
||||
// 执行连续性检查(允许2秒间隙)
|
||||
VideoContinuityResult result = operator.checkVideoContinuity(startDate, endDate, 2000L);
|
||||
|
||||
// 创建缓存对象
|
||||
DeviceVideoContinuityCache cache = DeviceVideoContinuityCache.fromResult(
|
||||
device.getId(), result, startDate, endDate
|
||||
);
|
||||
|
||||
// 存储到Redis
|
||||
String redisKey = REDIS_KEY_PREFIX + device.getId();
|
||||
String cacheJson = objectMapper.writeValueAsString(cache);
|
||||
redisTemplate.opsForValue().set(redisKey, cacheJson, CACHE_TTL_HOURS, TimeUnit.HOURS);
|
||||
|
||||
log.info("设备 {} 视频连续性检查完成: support={}, continuous={}, videos={}, gaps={}, duration={}ms",
|
||||
device.getId(), result.isSupport(), result.isContinuous(),
|
||||
result.getTotalVideos(), result.getGapCount(), result.getTotalDurationMs());
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("检查设备 {} 视频连续性失败", device.getId(), e);
|
||||
throw new RuntimeException("检查设备视频连续性失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发检查(用于测试)
|
||||
*
|
||||
* @param deviceId 设备ID
|
||||
* @return 检查结果
|
||||
*/
|
||||
public DeviceVideoContinuityCache manualCheck(Long deviceId) {
|
||||
log.info("手动触发设备 {} 的视频连续性检查", deviceId);
|
||||
|
||||
try {
|
||||
// 获取设备信息
|
||||
DeviceV2DTO device = deviceIntegrationService.getDevice(deviceId);
|
||||
if (device == null) {
|
||||
throw new RuntimeException("设备不存在: " + deviceId);
|
||||
}
|
||||
|
||||
// 检查设备
|
||||
checkSingleDevice(device);
|
||||
|
||||
// 从Redis获取结果
|
||||
String redisKey = REDIS_KEY_PREFIX + deviceId;
|
||||
String cacheJson = redisTemplate.opsForValue().get(redisKey);
|
||||
if (cacheJson == null) {
|
||||
throw new RuntimeException("检查完成但未找到缓存结果");
|
||||
}
|
||||
|
||||
return objectMapper.readValue(cacheJson, DeviceVideoContinuityCache.class);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("手动检查设备 {} 视频连续性失败", deviceId, e);
|
||||
throw new RuntimeException("手动检查失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,7 +401,7 @@ public class VideoPieceGetter {
|
||||
videoSource.setFaceId(task.getFaceId());
|
||||
videoSource.setScenicId(deviceV2.getScenicId());
|
||||
videoSource.setSourceId(sourceEntity.getId());
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(task.getMemberId(), deviceV2.getScenicId(), 1, task.getFaceId());
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(deviceV2.getScenicId(), task.getMemberId(), task.getFaceId(), 1, task.getFaceId());
|
||||
if (isBuy.isBuy()) { // 如果用户买过
|
||||
videoSource.setIsBuy(1);
|
||||
} else if (isBuy.isFree()) { // 全免费逻辑
|
||||
@@ -432,7 +432,7 @@ public class VideoPieceGetter {
|
||||
// 有原视频,source已存在,可以直接添加关联关系
|
||||
if (task.memberId != null && task.faceId != null) {
|
||||
List<MemberSourceEntity> memberSourceEntities = memberRelationRepository.listSourceByFaceRelation(task.faceId, 1);
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(task.getMemberId(), deviceV2.getScenicId(), 1, task.getFaceId());
|
||||
IsBuyRespVO isBuy = orderBiz.isBuy(deviceV2.getScenicId(), task.getMemberId(), task.getFaceId(), 1, task.getFaceId());
|
||||
MemberSourceEntity videoSource = new MemberSourceEntity();
|
||||
videoSource.setId(SnowFlakeUtil.getLongId());
|
||||
videoSource.setScenicId(deviceV2.getScenicId());
|
||||
|
||||
@@ -193,6 +193,52 @@ public class ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旋转图片180度
|
||||
*
|
||||
* @param sourceFile 源图片文件
|
||||
* @param targetFile 目标图片文件
|
||||
* @throws IOException 读取或写入文件失败
|
||||
*/
|
||||
public static void rotateImage180(File sourceFile, File targetFile) throws IOException {
|
||||
BufferedImage sourceImage = null;
|
||||
BufferedImage rotatedImage = null;
|
||||
try {
|
||||
sourceImage = ImageIO.read(sourceFile);
|
||||
if (sourceImage == null) {
|
||||
throw new IOException("无法读取图片文件: " + sourceFile.getPath());
|
||||
}
|
||||
|
||||
int width = sourceImage.getWidth();
|
||||
int height = sourceImage.getHeight();
|
||||
|
||||
// 创建旋转后的图片(宽高不变)
|
||||
rotatedImage = new BufferedImage(width, height, sourceImage.getType());
|
||||
Graphics2D g2d = rotatedImage.createGraphics();
|
||||
|
||||
// 设置旋转变换
|
||||
AffineTransform transform = new AffineTransform();
|
||||
transform.translate(width / 2.0, height / 2.0);
|
||||
transform.rotate(Math.PI);
|
||||
transform.translate(-width / 2.0, -height / 2.0);
|
||||
|
||||
g2d.setTransform(transform);
|
||||
g2d.drawImage(sourceImage, 0, 0, null);
|
||||
g2d.dispose();
|
||||
|
||||
// 保存旋转后的图片
|
||||
ImageIO.write(rotatedImage, "jpg", targetFile);
|
||||
log.info("图片旋转180度成功,尺寸保持: {}x{}", width, height);
|
||||
} finally {
|
||||
if (sourceImage != null) {
|
||||
sourceImage.flush();
|
||||
}
|
||||
if (rotatedImage != null) {
|
||||
rotatedImage.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能裁切图片以填充目标尺寸,支持自动旋转以减少裁切损失
|
||||
*
|
||||
@@ -330,10 +376,6 @@ public class ImageUtils {
|
||||
return source;
|
||||
}
|
||||
|
||||
if (degrees != 270) {
|
||||
throw new IllegalArgumentException("仅支持270度旋转");
|
||||
}
|
||||
|
||||
int width = source.getWidth();
|
||||
int height = source.getHeight();
|
||||
|
||||
@@ -406,6 +448,130 @@ public class ImageUtils {
|
||||
return cropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为图片添加白边框并向上偏移内容
|
||||
* 用于拼图打印场景,避免打印机偏移问题
|
||||
*
|
||||
* @param sourceFile 源图片文件
|
||||
* @param targetFile 目标图片文件
|
||||
* @param horizontalBorder 左右白边框宽度(像素)
|
||||
* @param verticalBorder 上下白边框高度(像素)
|
||||
* @param upwardShift 内容向上偏移的像素数
|
||||
* @throws IOException 读取或写入文件失败
|
||||
*/
|
||||
public static void addBorderAndShiftUp(File sourceFile, File targetFile,
|
||||
int horizontalBorder, int verticalBorder, int upwardShift) throws IOException {
|
||||
BufferedImage sourceImage = null;
|
||||
BufferedImage resultImage = null;
|
||||
try {
|
||||
sourceImage = ImageIO.read(sourceFile);
|
||||
if (sourceImage == null) {
|
||||
throw new IOException("无法读取图片文件: " + sourceFile.getPath());
|
||||
}
|
||||
|
||||
int srcWidth = sourceImage.getWidth();
|
||||
int srcHeight = sourceImage.getHeight();
|
||||
|
||||
// 计算新图片尺寸(原图 + 左右边框 + 上下边框)
|
||||
int newWidth = srcWidth + horizontalBorder * 2;
|
||||
int newHeight = srcHeight + verticalBorder * 2;
|
||||
|
||||
// 创建新图片
|
||||
resultImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = resultImage.createGraphics();
|
||||
|
||||
try {
|
||||
// 填充白色背景
|
||||
g2d.setColor(java.awt.Color.WHITE);
|
||||
g2d.fillRect(0, 0, newWidth, newHeight);
|
||||
|
||||
// 绘制原图到新图中
|
||||
// 原图应该绘制在: x=horizontalBorder, y=verticalBorder-upwardShift 的位置
|
||||
// 这样图片内容会向上偏移upwardShift像素
|
||||
int drawX = horizontalBorder;
|
||||
int drawY = verticalBorder - upwardShift;
|
||||
|
||||
g2d.drawImage(sourceImage, drawX, drawY, null);
|
||||
|
||||
log.info("图片添加白边框并向上偏移: 原始尺寸={}x{}, 边框=(左右{}px,上下{}px), 向上偏移={}px, 结果尺寸={}x{}",
|
||||
srcWidth, srcHeight, horizontalBorder, verticalBorder, upwardShift, newWidth, newHeight);
|
||||
} finally {
|
||||
g2d.dispose();
|
||||
}
|
||||
|
||||
// 保存处理后的图片
|
||||
ImageIO.write(resultImage, "png", targetFile);
|
||||
} finally {
|
||||
if (sourceImage != null) {
|
||||
sourceImage.flush();
|
||||
}
|
||||
if (resultImage != null) {
|
||||
resultImage.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向上偏移图片以避免打印机偏移问题
|
||||
* 舍弃顶部指定像素,整体向上移动,并在底部补充白底
|
||||
*
|
||||
* @param sourceFile 源图片文件
|
||||
* @param targetFile 目标图片文件
|
||||
* @param offsetPixels 向上偏移的像素数(舍弃顶部的像素数,底部补充相同像素的白底)
|
||||
* @throws IOException 读取或写入文件失败
|
||||
* @deprecated 使用 addBorderAndShiftUp 代替
|
||||
*/
|
||||
@Deprecated
|
||||
public static void shiftImageUp(File sourceFile, File targetFile, int offsetPixels) throws IOException {
|
||||
BufferedImage sourceImage = null;
|
||||
BufferedImage shiftedImage = null;
|
||||
try {
|
||||
sourceImage = ImageIO.read(sourceFile);
|
||||
if (sourceImage == null) {
|
||||
throw new IOException("无法读取图片文件: " + sourceFile.getPath());
|
||||
}
|
||||
|
||||
int width = sourceImage.getWidth();
|
||||
int height = sourceImage.getHeight();
|
||||
|
||||
if (offsetPixels <= 0 || offsetPixels >= height) {
|
||||
throw new IllegalArgumentException("偏移像素必须大于0且小于图片高度,当前值: " + offsetPixels + ", 图片高度: " + height);
|
||||
}
|
||||
|
||||
// 创建新图片,保持原始宽度和高度
|
||||
shiftedImage = new BufferedImage(width, height, sourceImage.getType());
|
||||
Graphics2D g2d = shiftedImage.createGraphics();
|
||||
|
||||
try {
|
||||
// 先填充白色背景
|
||||
g2d.setColor(java.awt.Color.WHITE);
|
||||
g2d.fillRect(0, 0, width, height);
|
||||
|
||||
// 从源图的offsetPixels位置开始截取到底部,绘制到目标图的顶部
|
||||
// 源图: 从(0, offsetPixels)到(width, height)的区域
|
||||
// 目标图: 绘制到(0, 0)到(width, height-offsetPixels)的区域
|
||||
g2d.drawImage(sourceImage, 0, 0, width, height - offsetPixels,
|
||||
0, offsetPixels, width, height, null);
|
||||
|
||||
// 底部的offsetPixels像素保持白色(已通过fillRect填充)
|
||||
} finally {
|
||||
g2d.dispose();
|
||||
}
|
||||
|
||||
// 保存处理后的图片
|
||||
ImageIO.write(shiftedImage, "png", targetFile);
|
||||
log.info("图片向上偏移成功,原始尺寸: {}x{}, 偏移: {}px, 结果尺寸: {}x{} (底部补充{}px白底)",
|
||||
width, height, offsetPixels, width, height, offsetPixels);
|
||||
} finally {
|
||||
if (sourceImage != null) {
|
||||
sourceImage.flush();
|
||||
}
|
||||
if (shiftedImage != null) {
|
||||
shiftedImage.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁切策略内部类
|
||||
*/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user