You've already forked FrameTour-BE
Compare commits
40 Commits
4a07f5bba9
...
0ed12af8c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ed12af8c9 | |||
| ecbdec4518 | |||
| bf6b866e67 | |||
| 93f9c1486f | |||
| e87e38be03 | |||
| 85d0fc0996 | |||
| d25d09cb66 | |||
| c40c6a0966 | |||
| 4fc0984994 | |||
| 918ff860c3 | |||
| 8b3bea8bed | |||
| be54bbaa82 | |||
| 68a674ba51 | |||
| 80f8a6b56b | |||
| 973bd73e9a | |||
| 819caab047 | |||
| 00bf4b5a8b | |||
| 6c305f4cd1 | |||
| 82e844a779 | |||
| c3fcfdd633 | |||
| a8156976be | |||
| ce48bd00c9 | |||
| c5df277e6c | |||
| 9a31e71e42 | |||
| e268d236f4 | |||
| 143426db1f | |||
| fcc4b06295 | |||
| f876dc59fa | |||
| 8e6d10ad95 | |||
| 42bf3d3d0a | |||
| 679f2d3a79 | |||
| 3084afc6a7 | |||
| 91626626f4 | |||
| b1cfef278d | |||
| c42474256e | |||
| 63180159d2 | |||
| e647ad75c6 | |||
| 5d5643e7d7 | |||
| dc4091e058 | |||
| a5c815b6ed |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,7 +1,8 @@
|
||||
.idea/
|
||||
logs/
|
||||
target/
|
||||
.serena
|
||||
.*
|
||||
.claude
|
||||
.vscode
|
||||
*.jpg
|
||||
!.gitignore
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.ycwl.basic.biz;
|
||||
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.pc.order.entity.OrderEntity;
|
||||
import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity;
|
||||
|
||||
@@ -12,10 +12,13 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.TimeZone;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* ClickHouse 统计数据查询服务实现
|
||||
@@ -366,12 +369,14 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
||||
sql.append("GROUP BY toStartOfHour(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfHour(s.create_time)");
|
||||
|
||||
return getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
map.put("t", rs.getString("t"));
|
||||
map.put("count", rs.getString("count"));
|
||||
return map;
|
||||
});
|
||||
|
||||
return fillHourSeries(rawData, query.getStartTime(), query.getEndTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -389,12 +394,14 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
||||
sql.append("GROUP BY toStartOfDay(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfDay(s.create_time)");
|
||||
|
||||
return getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
map.put("t", rs.getString("t"));
|
||||
map.put("count", rs.getString("count"));
|
||||
return map;
|
||||
});
|
||||
|
||||
return fillDateSeries(rawData, query.getStartTime(), query.getEndTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -418,12 +425,14 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
||||
sql.append("GROUP BY toStartOfHour(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfHour(s.create_time)");
|
||||
|
||||
return getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
map.put("t", rs.getString("t"));
|
||||
map.put("count", rs.getString("count"));
|
||||
return map;
|
||||
});
|
||||
|
||||
return fillHourSeries(rawData, query.getStartTime(), query.getEndTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -447,11 +456,81 @@ public class ClickHouseStatsQueryServiceImpl implements StatsQueryService {
|
||||
sql.append("GROUP BY toStartOfDay(s.create_time) ");
|
||||
sql.append("ORDER BY toStartOfDay(s.create_time)");
|
||||
|
||||
return getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
List<HashMap<String, String>> rawData = getJdbcTemplate().query(sql.toString(), (rs, rowNum) -> {
|
||||
HashMap<String, String> map = new HashMap<>();
|
||||
map.put("t", rs.getString("t"));
|
||||
map.put("count", rs.getString("count"));
|
||||
return map;
|
||||
});
|
||||
|
||||
return fillDateSeries(rawData, query.getStartTime(), query.getEndTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充小时序列,确保每个小时都有数据(缺失的填充为0)
|
||||
*/
|
||||
private List<HashMap<String, String>> fillHourSeries(List<HashMap<String, String>> rawData, Date startTime, Date endTime) {
|
||||
if (startTime == null || endTime == null) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd HH");
|
||||
LocalDateTime start = startTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().truncatedTo(ChronoUnit.HOURS);
|
||||
LocalDateTime end = endTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().truncatedTo(ChronoUnit.HOURS);
|
||||
|
||||
// 将原始数据转为 Map 以便快速查找
|
||||
Map<String, String> dataMap = rawData.stream()
|
||||
.collect(Collectors.toMap(
|
||||
m -> m.get("t"),
|
||||
m -> m.get("count"),
|
||||
(existing, replacement) -> existing
|
||||
));
|
||||
|
||||
List<HashMap<String, String>> result = new ArrayList<>();
|
||||
LocalDateTime current = start;
|
||||
while (!current.isAfter(end)) {
|
||||
String timeKey = current.format(formatter);
|
||||
HashMap<String, String> item = new HashMap<>();
|
||||
item.put("t", timeKey);
|
||||
item.put("count", dataMap.getOrDefault(timeKey, "0"));
|
||||
result.add(item);
|
||||
current = current.plusHours(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充日期序列,确保每天都有数据(缺失的填充为0)
|
||||
*/
|
||||
private List<HashMap<String, String>> fillDateSeries(List<HashMap<String, String>> rawData, Date startTime, Date endTime) {
|
||||
if (startTime == null || endTime == null) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd");
|
||||
LocalDate start = startTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
LocalDate end = endTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
|
||||
// 将原始数据转为 Map 以便快速查找
|
||||
Map<String, String> dataMap = rawData.stream()
|
||||
.collect(Collectors.toMap(
|
||||
m -> m.get("t"),
|
||||
m -> m.get("count"),
|
||||
(existing, replacement) -> existing
|
||||
));
|
||||
|
||||
List<HashMap<String, String>> result = new ArrayList<>();
|
||||
LocalDate current = start;
|
||||
while (!current.isAfter(end)) {
|
||||
String timeKey = current.format(formatter);
|
||||
HashMap<String, String> item = new HashMap<>();
|
||||
item.put("t", timeKey);
|
||||
item.put("count", dataMap.getOrDefault(timeKey, "0"));
|
||||
result.add(item);
|
||||
current = current.plusDays(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,10 +203,17 @@ public class LyCompatibleController {
|
||||
return response;
|
||||
}
|
||||
List<Map<String, Object>> videoList = collect.get(0).stream().collect(Collectors.groupingBy(ContentPageVO::getTemplateId))
|
||||
.values().stream().map(contentPageVOs -> {
|
||||
ContentPageVO contentPageVO = contentPageVOs.getFirst();
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
.values().stream()
|
||||
.map(contentPageVOs -> {
|
||||
ContentPageVO contentPageVO = contentPageVOs.stream().filter(vo -> vo.getContentId() != null).findFirst().orElse(null);
|
||||
if (contentPageVO == null) {
|
||||
return null;
|
||||
}
|
||||
VideoEntity videoRespVO = videoRepository.getVideo(contentPageVO.getContentId());
|
||||
if (videoRespVO == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("id", videoRespVO.getId().toString());
|
||||
map.put("task_id", videoRespVO.getTaskId().toString());
|
||||
if (videoRespVO.getFaceId() != null) {
|
||||
@@ -220,7 +227,7 @@ public class LyCompatibleController {
|
||||
map.put("title", contentPageVO.getName());
|
||||
map.put("ossurldm", videoRespVO.getVideoUrl());
|
||||
return map;
|
||||
}).collect(Collectors.toList());
|
||||
}).filter(java.util.Objects::nonNull).collect(Collectors.toList());
|
||||
GoodsReqQuery goodsReqQuery = new GoodsReqQuery();
|
||||
goodsReqQuery.setFaceId(faceVO.getId());
|
||||
goodsReqQuery.setSourceType(1);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import com.ycwl.basic.constant.BaseContextHandler;
|
||||
import com.ycwl.basic.model.mobile.coupon.req.ClaimCouponReq;
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
|
||||
import com.ycwl.basic.service.mobile.AppCouponRecordService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/coupon/v1")
|
||||
public class AppCouponController {
|
||||
|
||||
@Autowired
|
||||
private AppCouponRecordService appCouponRecordService;
|
||||
|
||||
/**
|
||||
* 根据memberId、faceId和type查找优惠券记录
|
||||
*/
|
||||
@GetMapping("/record")
|
||||
public ApiResponse<CouponRecordEntity> getCouponRecords(
|
||||
@RequestParam Long faceId,
|
||||
@RequestParam Integer type) {
|
||||
CouponRecordEntity record = appCouponRecordService.queryByMemberIdAndFaceIdAndType(Long.valueOf(BaseContextHandler.getUserId()), faceId, type);
|
||||
return ApiResponse.success(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取优惠券
|
||||
*/
|
||||
@PostMapping("/claim")
|
||||
public ApiResponse<CouponEntity> claimCoupon(@RequestBody ClaimCouponReq request) {
|
||||
request.setMemberId(Long.valueOf(BaseContextHandler.getUserId()));
|
||||
try {
|
||||
CouponEntity coupon = appCouponRecordService.claimCoupon(
|
||||
request.getMemberId(),
|
||||
request.getFaceId(),
|
||||
request.getType()
|
||||
);
|
||||
return ApiResponse.success(coupon);
|
||||
} catch (RuntimeException e) {
|
||||
return ApiResponse.fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.constant.BaseContextHandler;
|
||||
import com.ycwl.basic.model.mobile.weChat.DTO.WeChatUserInfoDTO;
|
||||
import com.ycwl.basic.model.mobile.weChat.DTO.WeChatUserInfoUpdateDTO;
|
||||
import com.ycwl.basic.model.pc.member.resp.MemberRespVO;
|
||||
@@ -67,7 +68,8 @@ public class AppMemberController {
|
||||
// 修改用户信息
|
||||
@PostMapping("/update")
|
||||
public ApiResponse<?> update(@RequestBody WeChatUserInfoUpdateDTO userInfoUpdateDTO) {
|
||||
return memberService.update(userInfoUpdateDTO);
|
||||
Long userId = Long.parseLong(BaseContextHandler.getUserId());
|
||||
return memberService.update(userId, userInfoUpdateDTO);
|
||||
}
|
||||
|
||||
// 新增或修改景区服务通知状态
|
||||
|
||||
@@ -27,6 +27,12 @@ import com.ycwl.basic.order.dto.OrderV2PageRequest;
|
||||
import com.ycwl.basic.order.dto.PaymentParamsRequest;
|
||||
import com.ycwl.basic.order.dto.PaymentParamsResponse;
|
||||
import com.ycwl.basic.order.dto.PaymentCallbackResponse;
|
||||
import com.ycwl.basic.order.exception.DuplicatePurchaseException;
|
||||
import com.ycwl.basic.order.factory.DuplicatePurchaseCheckerFactory;
|
||||
import com.ycwl.basic.order.strategy.DuplicateCheckContext;
|
||||
import com.ycwl.basic.order.strategy.IDuplicatePurchaseChecker;
|
||||
import com.ycwl.basic.product.capability.DuplicateCheckStrategy;
|
||||
import com.ycwl.basic.product.service.IProductTypeCapabilityService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
@@ -57,10 +63,11 @@ public class AppOrderV2Controller {
|
||||
private final TemplateRepository templateRepository;
|
||||
private final VideoRepository videoRepository;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final IProductTypeCapabilityService productTypeCapabilityService;
|
||||
private final DuplicatePurchaseCheckerFactory duplicatePurchaseCheckerFactory;
|
||||
|
||||
/**
|
||||
* 移动端价格计算
|
||||
* 包含权限验证:验证人脸所属景区与当前用户匹配
|
||||
* 集成Redis缓存机制,提升查询性能
|
||||
*/
|
||||
@PostMapping("/calculate")
|
||||
@@ -102,6 +109,12 @@ public class AppOrderV2Controller {
|
||||
Long scenicId = face.getScenicId();
|
||||
|
||||
request.getProducts().forEach(product -> {
|
||||
// 获取商品的重复检查策略
|
||||
DuplicateCheckStrategy strategy = productTypeCapabilityService
|
||||
.getDuplicateCheckStrategy(product.getProductType().name());
|
||||
|
||||
boolean hasPurchasedFlag;
|
||||
|
||||
switch (product.getProductType()) {
|
||||
case VLOG_VIDEO:
|
||||
List<MemberVideoEntity> videoEntities = videoMapper.listRelationByFaceAndTemplate(face.getId(), Long.valueOf(product.getProductId()));
|
||||
@@ -132,6 +145,13 @@ public class AppOrderV2Controller {
|
||||
log.warn("未知的商品类型,跳过重复购买检查: productType={}", product.getProductType());
|
||||
break;
|
||||
}
|
||||
|
||||
// 使用 DuplicatePurchaseChecker 检查是否已购买
|
||||
hasPurchasedFlag = checkIfPurchased(strategy, currentUserId, String.valueOf(scenicId),
|
||||
product.getProductType().name(), product.getProductId(), face.getId());
|
||||
|
||||
// 设置是否已购买标识
|
||||
product.setHasPurchased(hasPurchasedFlag);
|
||||
});
|
||||
|
||||
// 转换为标准价格计算请求
|
||||
@@ -140,6 +160,12 @@ public class AppOrderV2Controller {
|
||||
// 执行价格计算
|
||||
PriceCalculationResult result = priceCalculationService.calculatePrice(standardRequest);
|
||||
|
||||
// 设置是否已购买标识(基于请求中的商品 hasPurchased 判断)
|
||||
// 只要有一个商品 hasPurchased = true,则整体 isPurchased = true
|
||||
boolean isPurchased = request.getProducts().stream()
|
||||
.anyMatch(product -> Boolean.TRUE.equals(product.getHasPurchased()));
|
||||
result.setIsPurchased(isPurchased);
|
||||
|
||||
// 将计算结果缓存到Redis
|
||||
String cacheKey = priceCacheService.cachePriceResult(currentUserId, scenicId, request.getProducts(), result);
|
||||
|
||||
@@ -355,4 +381,55 @@ public class AppOrderV2Controller {
|
||||
public ApiResponse<Boolean> getDownloadableOrder(@PathVariable("orderId") Long orderId) {
|
||||
return ApiResponse.success(!redisTemplate.hasKey("order_content_not_downloadable_" + orderId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查商品是否已购买
|
||||
* 使用 DuplicatePurchaseChecker 通过异常捕获判断
|
||||
*
|
||||
* @param strategy 重复检查策略
|
||||
* @param userId 用户ID
|
||||
* @param scenicId 景区ID
|
||||
* @param productType 商品类型
|
||||
* @param productId 商品ID
|
||||
* @param faceId 人脸ID
|
||||
* @return true-已购买, false-未购买
|
||||
*/
|
||||
private boolean checkIfPurchased(DuplicateCheckStrategy strategy, Long userId, String scenicId,
|
||||
String productType, String productId, Long faceId) {
|
||||
// NO_CHECK 策略表示允许重复购买,直接返回 false
|
||||
if (strategy == DuplicateCheckStrategy.NO_CHECK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取对应的检查器
|
||||
IDuplicatePurchaseChecker checker = duplicatePurchaseCheckerFactory.getChecker(strategy);
|
||||
|
||||
// 构建检查上下文
|
||||
DuplicateCheckContext context = new DuplicateCheckContext();
|
||||
context.setUserId(String.valueOf(userId));
|
||||
context.setScenicId(scenicId);
|
||||
context.setProductType(productType);
|
||||
context.setProductId(productId);
|
||||
context.addParam("faceId", faceId);
|
||||
|
||||
// 执行检查,如果抛出异常则表示已购买
|
||||
checker.check(context);
|
||||
|
||||
// 没有抛出异常,表示未购买
|
||||
return false;
|
||||
|
||||
} catch (DuplicatePurchaseException e) {
|
||||
// 捕获到重复购买异常,表示已购买
|
||||
log.debug("检测到已购买: userId={}, scenicId={}, productType={}, productId={}",
|
||||
userId, scenicId, productType, productId);
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
// 其他异常,记录日志并返回 false(保守处理)
|
||||
log.warn("检查是否已购买时发生异常: userId={}, scenicId={}, productType={}, productId={}, error={}",
|
||||
userId, scenicId, productType, productId, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import com.ycwl.basic.biz.OrderBiz;
|
||||
import com.ycwl.basic.constant.FreeStatus;
|
||||
import com.ycwl.basic.image.watermark.edge.PuzzleDefaultWatermarkTemplateBuilder;
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkEdgeTaskCreator;
|
||||
import com.ycwl.basic.image.watermark.edge.WatermarkRequest;
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.pc.puzzle.entity.MemberPuzzleEntity;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
|
||||
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
|
||||
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
|
||||
import com.ycwl.basic.pricing.dto.ProductItem;
|
||||
import com.ycwl.basic.pricing.enums.ProductType;
|
||||
import com.ycwl.basic.pricing.service.IPriceCalculationService;
|
||||
import com.ycwl.basic.puzzle.edge.task.PuzzleEdgeRenderTaskService;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.MemberPuzzleMapper;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.service.pc.FaceService;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/puzzle/v1")
|
||||
@RequiredArgsConstructor
|
||||
@@ -36,6 +48,10 @@ public class AppPuzzleController {
|
||||
private final IPriceCalculationService iPriceCalculationService;
|
||||
private final PrinterService printerService;
|
||||
private final OrderBiz orderBiz;
|
||||
private final MemberPuzzleMapper memberPuzzleMapper;
|
||||
private final WatermarkEdgeTaskCreator watermarkEdgeTaskCreator;
|
||||
private final FaceService faceService;
|
||||
private final ScenicRepository scenicRepository;
|
||||
|
||||
/**
|
||||
* 根据faceId查询三拼图数量
|
||||
@@ -45,8 +61,9 @@ public class AppPuzzleController {
|
||||
if (faceId == null) {
|
||||
return ApiResponse.fail("faceId不能为空");
|
||||
}
|
||||
int count = puzzleRepository.countRecordsByFaceId(faceId);
|
||||
return ApiResponse.success(count);
|
||||
// 通过关联表查询数量
|
||||
List<MemberPuzzleEntity> relations = memberPuzzleMapper.listByFaceId(faceId);
|
||||
return ApiResponse.success(relations.size());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,9 +74,17 @@ public class AppPuzzleController {
|
||||
if (faceId == null) {
|
||||
return ApiResponse.fail("faceId不能为空");
|
||||
}
|
||||
List<PuzzleGenerationRecordEntity> records = puzzleRepository.getRecordsByFaceId(faceId);
|
||||
List<ContentPageVO> result = records.stream()
|
||||
.map(this::convertToContentPageVO)
|
||||
// 通过关联表查询,获取关联的拼图记录
|
||||
List<MemberPuzzleEntity> relations = memberPuzzleMapper.listByFaceId(faceId);
|
||||
List<ContentPageVO> result = relations.stream()
|
||||
.map(relation -> {
|
||||
PuzzleGenerationRecordEntity record = puzzleRepository.getRecordById(relation.getRecordId());
|
||||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
return convertToContentPageVO(record, relation);
|
||||
})
|
||||
.filter(vo -> vo != null)
|
||||
.collect(Collectors.toList());
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
@@ -76,12 +101,15 @@ public class AppPuzzleController {
|
||||
if (record == null) {
|
||||
return ApiResponse.fail("未找到对应的拼图记录");
|
||||
}
|
||||
ContentPageVO result = convertToContentPageVO(record);
|
||||
// 查询关联记录
|
||||
MemberPuzzleEntity relation = memberPuzzleMapper.getByFaceAndRecord(record.getFaceId(), recordId);
|
||||
ContentPageVO result = convertToContentPageVO(record, relation);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据recordId下载拼图资源
|
||||
* 如果是免费赠送的拼图,会添加水印后返回
|
||||
*/
|
||||
@GetMapping("/download/{recordId}")
|
||||
public ApiResponse<List<String>> download(@PathVariable("recordId") Long recordId) {
|
||||
@@ -96,9 +124,88 @@ public class AppPuzzleController {
|
||||
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
|
||||
return ApiResponse.fail("该拼图记录没有可用的图片URL");
|
||||
}
|
||||
|
||||
// 查询该拼图的关联记录,判断是否为免费赠送
|
||||
Long faceId = record.getFaceId();
|
||||
if (faceId != null) {
|
||||
MemberPuzzleEntity memberPuzzle = memberPuzzleMapper.getByFaceAndRecord(faceId, recordId);
|
||||
if (memberPuzzle != null && FreeStatus.isFree(memberPuzzle.getIsFree())) {
|
||||
// 免费赠送的拼图,需要添加水印
|
||||
String watermarkedUrl = addWatermarkForFreePuzzle(record);
|
||||
if (watermarkedUrl != null) {
|
||||
return ApiResponse.success(Collections.singletonList(watermarkedUrl));
|
||||
}
|
||||
// 如果水印添加失败,记录日志并返回原图
|
||||
log.warn("免费拼图水印添加失败,返回原图: recordId={}", recordId);
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponse.success(Collections.singletonList(resultImageUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为免费赠送的拼图添加水印
|
||||
*
|
||||
* @param record 拼图记录
|
||||
* @return 带水印的图片URL,失败返回null
|
||||
*/
|
||||
private String addWatermarkForFreePuzzle(PuzzleGenerationRecordEntity record) {
|
||||
try {
|
||||
Long faceId = record.getFaceId();
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
log.warn("添加水印失败:未找到人脸信息, faceId={}", faceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取景区信息
|
||||
ScenicEntity scenic = scenicRepository.getScenic(face.getScenicId());
|
||||
String scenicLine = scenic != null ? scenic.getName() : "";
|
||||
|
||||
// 获取二维码URL
|
||||
String qrcodeUrl = faceService.bindWxaCode(faceId);
|
||||
|
||||
// 格式化日期时间
|
||||
String datetimeLine = record.getCreateTime() != null
|
||||
? DateUtil.format(record.getCreateTime(), "yyyy-MM-dd")
|
||||
: "";
|
||||
|
||||
// 构建水印请求
|
||||
WatermarkRequest request = WatermarkRequest.builder()
|
||||
.originalImageUrl(record.getResultImageUrl())
|
||||
.imageWidth(record.getResultWidth() != null ? record.getResultWidth() : 0)
|
||||
.imageHeight(record.getResultHeight() != null ? record.getResultHeight() : 0)
|
||||
.qrcodeUrl(qrcodeUrl)
|
||||
.faceUrl(face.getFaceUrl())
|
||||
.scenicLine(scenicLine)
|
||||
.datetimeLine(datetimeLine)
|
||||
.outputFormat("JPEG")
|
||||
.outputQuality(90)
|
||||
.build();
|
||||
|
||||
// 创建水印任务并等待结果
|
||||
PuzzleEdgeRenderTaskService.TaskWaitResult result = watermarkEdgeTaskCreator.createAndWait(
|
||||
PuzzleDefaultWatermarkTemplateBuilder.STYLE,
|
||||
request,
|
||||
record.getId(),
|
||||
faceId,
|
||||
"free_puzzle_download",
|
||||
30_000L // 30秒超时
|
||||
);
|
||||
|
||||
if (result.isSuccess()) {
|
||||
log.info("免费拼图水印添加成功: recordId={}, url={}", record.getId(), result.getImageUrl());
|
||||
return result.getImageUrl();
|
||||
} else {
|
||||
log.error("免费拼图水印添加失败: recordId={}, error={}", record.getId(), result.getErrorMessage());
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("免费拼图水印添加异常: recordId={}", record.getId(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据recordId查询拼图价格
|
||||
*/
|
||||
@@ -176,8 +283,11 @@ public class AppPuzzleController {
|
||||
|
||||
/**
|
||||
* 将PuzzleGenerationRecordEntity转换为ContentPageVO
|
||||
*
|
||||
* @param record 拼图生成记录
|
||||
* @param relation 会员拼图关联记录,用于获取免费状态
|
||||
*/
|
||||
private ContentPageVO convertToContentPageVO(PuzzleGenerationRecordEntity record) {
|
||||
private ContentPageVO convertToContentPageVO(PuzzleGenerationRecordEntity record, MemberPuzzleEntity relation) {
|
||||
ContentPageVO vo = new ContentPageVO();
|
||||
|
||||
// 内容类型为3(拼图)
|
||||
@@ -213,21 +323,11 @@ public class AppPuzzleController {
|
||||
vo.setIsBuy(1);
|
||||
} else {
|
||||
vo.setIsBuy(0);
|
||||
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
|
||||
ProductItem productItem = new ProductItem();
|
||||
productItem.setProductType(ProductType.PHOTO_LOG);
|
||||
productItem.setProductId(record.getTemplateId().toString());
|
||||
productItem.setPurchaseCount(1);
|
||||
productItem.setScenicId(face.getScenicId().toString());
|
||||
calculationRequest.setProducts(Collections.singletonList(productItem));
|
||||
calculationRequest.setUserId(face.getMemberId());
|
||||
calculationRequest.setFaceId(record.getFaceId());
|
||||
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||
PriceCalculationResult calculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
||||
if (calculationResult.getFinalAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||
vo.setFreeCount(0);
|
||||
} else {
|
||||
// 从关联记录读取免费状态
|
||||
if (relation != null && FreeStatus.isFree(relation.getIsFree())) {
|
||||
vo.setFreeCount(1);
|
||||
} else {
|
||||
vo.setFreeCount(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ScenicTemplateContentVO;
|
||||
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
@@ -85,4 +86,46 @@ public class AppTemplateController {
|
||||
|
||||
return ApiResponse.success(coverUrls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据景区ID获取所有模板内容列表(返回模板基础信息,与 faceId 无关)
|
||||
*
|
||||
* @param scenicId 景区ID
|
||||
* @return 景区模板内容列表
|
||||
*/
|
||||
@GetMapping("/scenic/{scenicId}/contents")
|
||||
@IgnoreToken
|
||||
public ApiResponse<List<ScenicTemplateContentVO>> getScenicTemplateContents(@PathVariable("scenicId") Long scenicId) {
|
||||
if (scenicId == null) {
|
||||
return ApiResponse.fail("景区ID不能为空");
|
||||
}
|
||||
|
||||
List<ScenicTemplateContentVO> contentList = new ArrayList<>();
|
||||
|
||||
// 获取普通模板
|
||||
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(scenicId);
|
||||
for (TemplateRespVO template : templateList) {
|
||||
ScenicTemplateContentVO content = new ScenicTemplateContentVO();
|
||||
content.setGoodsType(0); // 普通模板默认商品类型为 0
|
||||
content.setName(template.getName());
|
||||
content.setGroup(template.getGroup());
|
||||
content.setTemplateId(template.getId());
|
||||
content.setTemplateCoverUrl(template.getCoverUrl());
|
||||
contentList.add(content);
|
||||
}
|
||||
|
||||
// 获取拼图模板
|
||||
List<PuzzleTemplateEntity> puzzleTemplateList = puzzleRepository.listTemplateByScenic(scenicId);
|
||||
for (PuzzleTemplateEntity puzzleTemplate : puzzleTemplateList) {
|
||||
ScenicTemplateContentVO content = new ScenicTemplateContentVO();
|
||||
content.setGoodsType(3); // 拼图模板商品类型为 3
|
||||
content.setName(puzzleTemplate.getName());
|
||||
content.setGroup("氛围拼图"); // 拼图模板固定分组
|
||||
content.setTemplateId(puzzleTemplate.getId());
|
||||
content.setTemplateCoverUrl(puzzleTemplate.getCoverImage());
|
||||
contentList.add(content);
|
||||
}
|
||||
|
||||
return ApiResponse.success(contentList);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.ycwl.basic.controller.pc;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.biz.PriceBiz;
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.coupon.req.CouponQueryReq;
|
||||
import com.ycwl.basic.model.pc.coupon.resp.CouponRespVO;
|
||||
import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
|
||||
import com.ycwl.basic.service.pc.CouponService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/coupon/v1")
|
||||
// 优惠券管理
|
||||
public class CouponController {
|
||||
@Autowired
|
||||
private CouponService couponService;
|
||||
@Autowired
|
||||
private PriceBiz priceBiz;
|
||||
|
||||
@GetMapping("/{scenicId}/goodsList")
|
||||
public ApiResponse<List<GoodsListRespVO>> scenicGoodsList(@PathVariable Long scenicId) {
|
||||
List<GoodsListRespVO> data = priceBiz.listGoodsByScenic(scenicId);
|
||||
data.add(new GoodsListRespVO(-1L, "一口价", -1));
|
||||
return ApiResponse.success(data);
|
||||
}
|
||||
|
||||
// 新增优惠券
|
||||
@PostMapping("/add")
|
||||
public ApiResponse<Integer> add(@RequestBody CouponEntity coupon) {
|
||||
return ApiResponse.success(couponService.add(coupon));
|
||||
}
|
||||
|
||||
// 更新优惠券
|
||||
@PostMapping("/update/{id}")
|
||||
public ApiResponse<Boolean> update(@PathVariable Integer id, @RequestBody CouponEntity coupon) {
|
||||
coupon.setId(id);
|
||||
return ApiResponse.success(couponService.update(coupon));
|
||||
}
|
||||
|
||||
@PutMapping("/updateStatus/{id}")
|
||||
public ApiResponse<Boolean> updateStatus(@PathVariable Integer id) {
|
||||
return ApiResponse.success(couponService.updateStatus(id));
|
||||
}
|
||||
|
||||
// 删除优惠券
|
||||
@DeleteMapping("/delete/{id}")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Integer id) {
|
||||
return ApiResponse.success(couponService.delete(id));
|
||||
}
|
||||
|
||||
// 根据ID查询优惠券
|
||||
@GetMapping("/get/{id}")
|
||||
public ApiResponse<CouponEntity> getById(@PathVariable Integer id) {
|
||||
return ApiResponse.success(couponService.getById(id));
|
||||
}
|
||||
|
||||
// 分页查询优惠券列表
|
||||
@PostMapping("/page")
|
||||
public ApiResponse<PageInfo<CouponRespVO>> list(@RequestBody CouponQueryReq couponQuery) {
|
||||
PageHelper.startPage(couponQuery.getPageNum(), couponQuery.getPageSize());
|
||||
List<CouponRespVO> list = couponService.list(couponQuery);
|
||||
PageInfo<CouponRespVO> pageInfo = new PageInfo<>(list);
|
||||
return ApiResponse.success(pageInfo);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.ycwl.basic.controller.pc;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.model.pc.couponRecord.req.CouponRecordPageQueryReq;
|
||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordPageResp;
|
||||
import com.ycwl.basic.service.pc.CouponRecordService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/coupon/record/v1")
|
||||
public class CouponRecordController {
|
||||
|
||||
@Autowired
|
||||
private CouponRecordService couponRecordService;
|
||||
|
||||
@PostMapping("/page")
|
||||
public ApiResponse<PageInfo<CouponRecordPageResp>> pageQuery(@RequestBody CouponRecordPageQueryReq query) {
|
||||
return couponRecordService.pageQuery(query);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import java.util.Map;
|
||||
*
|
||||
* 布局说明:
|
||||
* - 白色背景
|
||||
* - 顶部90%为原图区域(COVER模式)
|
||||
* - 底部10%为信息区域:
|
||||
* - 顶部100%为原图区域(COVER模式,保持原图完整尺寸)
|
||||
* - 底部扩展10%为信息区域:
|
||||
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
|
||||
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
|
||||
*/
|
||||
@@ -23,7 +23,7 @@ public class PuzzleDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemp
|
||||
public static final String STYLE = "puzzle_default";
|
||||
|
||||
// 布局比例配置
|
||||
private static final double IMAGE_HEIGHT_RATIO = 0.90; // 原图占90%高度
|
||||
private static final double BOTTOM_EXTEND_RATIO = 0.10; // 底部扩展为原图高度的10%
|
||||
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
|
||||
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
|
||||
|
||||
@@ -43,13 +43,15 @@ public class PuzzleDefaultWatermarkTemplateBuilder extends AbstractWatermarkTemp
|
||||
int imageWidth = request.getImageWidth();
|
||||
int imageHeight = request.getImageHeight();
|
||||
|
||||
// 画布尺寸 = 原图尺寸
|
||||
int canvasWidth = imageWidth;
|
||||
int canvasHeight = imageHeight;
|
||||
// 底部扩展区域高度
|
||||
int bottomAreaHeight = (int) (imageHeight * BOTTOM_EXTEND_RATIO);
|
||||
|
||||
// 原图区域占90%高度,底部信息区占10%高度
|
||||
int originalImageHeight = (int) (imageHeight * IMAGE_HEIGHT_RATIO);
|
||||
int bottomAreaHeight = imageHeight - originalImageHeight;
|
||||
// 画布尺寸 = 原图尺寸 + 底部扩展
|
||||
int canvasWidth = imageWidth;
|
||||
int canvasHeight = imageHeight + bottomAreaHeight;
|
||||
|
||||
// 原图区域保持完整高度
|
||||
int originalImageHeight = imageHeight;
|
||||
|
||||
// 创建模板(白色背景)
|
||||
PuzzleTemplateEntity template = createTemplateWithColor(
|
||||
|
||||
@@ -13,8 +13,8 @@ import java.util.Map;
|
||||
* 布局说明:
|
||||
* - 白色背景
|
||||
* - 四周留1%白边
|
||||
* - 内部区域:顶部90%为原图区域(COVER模式)
|
||||
* - 底部10%为信息区域:
|
||||
* - 内部区域:顶部100%为原图区域(COVER模式,保持原图完整尺寸)
|
||||
* - 底部扩展10%为信息区域:
|
||||
* - 左侧(距左5%):二维码(宽高为图片的8%)+ 头像(可选)
|
||||
* - 右侧(距右5%):景区名 + 日期时间(右对齐)
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ public class PuzzlePrintWatermarkTemplateBuilder extends AbstractWatermarkTempla
|
||||
|
||||
// 布局比例配置
|
||||
private static final double BORDER_RATIO = 0.01; // 四周白边为1%
|
||||
private static final double IMAGE_HEIGHT_RATIO = 0.90; // 原图占内容区90%高度
|
||||
private static final double BOTTOM_EXTEND_RATIO = 0.10; // 底部扩展为原图高度的10%
|
||||
private static final double MARGIN_X_RATIO = 0.05; // 左右边距为宽度的5%
|
||||
private static final double QRCODE_SIZE_RATIO = 0.08; // 二维码为图片的8%
|
||||
|
||||
@@ -49,21 +49,23 @@ public class PuzzlePrintWatermarkTemplateBuilder extends AbstractWatermarkTempla
|
||||
int borderX = (int) (imageWidth * BORDER_RATIO);
|
||||
int borderY = (int) (imageHeight * BORDER_RATIO);
|
||||
|
||||
// 画布尺寸 = 原图尺寸 + 四周白边
|
||||
// 底部扩展区域高度
|
||||
int bottomAreaHeight = (int) (imageHeight * BOTTOM_EXTEND_RATIO);
|
||||
|
||||
// 内容区高度 = 原图高度 + 扩展区域(扩展区域在白边内部)
|
||||
int contentHeight = imageHeight + bottomAreaHeight;
|
||||
|
||||
// 画布尺寸 = 内容区尺寸 + 四周白边
|
||||
int canvasWidth = imageWidth + borderX * 2;
|
||||
int canvasHeight = imageHeight + borderY * 2;
|
||||
int canvasHeight = contentHeight + borderY * 2;
|
||||
|
||||
// 内容区起始位置(白边内)
|
||||
int contentStartX = borderX;
|
||||
int contentStartY = borderY;
|
||||
|
||||
// 内容区尺寸 = 原图尺寸
|
||||
// 内容区宽度 = 原图宽度,原图区域保持完整高度
|
||||
int contentWidth = imageWidth;
|
||||
int contentHeight = imageHeight;
|
||||
|
||||
// 原图区域占90%高度,底部信息区占10%高度
|
||||
int originalImageHeight = (int) (contentHeight * IMAGE_HEIGHT_RATIO);
|
||||
int bottomAreaHeight = contentHeight - originalImageHeight;
|
||||
int originalImageHeight = imageHeight;
|
||||
|
||||
// 创建模板(白色背景)
|
||||
PuzzleTemplateEntity template = createTemplateWithColor(
|
||||
@@ -88,7 +90,7 @@ public class PuzzlePrintWatermarkTemplateBuilder extends AbstractWatermarkTempla
|
||||
|
||||
// 2. 计算底部区域元素位置(相对于内容区)
|
||||
int marginX = (int) (contentWidth * MARGIN_X_RATIO);
|
||||
int qrcodeSize = (int) (contentHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
|
||||
int qrcodeSize = (int) (imageHeight * QRCODE_SIZE_RATIO); // 二维码为高度的8%
|
||||
|
||||
// 二维码垂直居中于底部区域
|
||||
int qrcodeX = contentStartX + marginX;
|
||||
|
||||
@@ -1161,6 +1161,228 @@ fallbackService.clearAllFallbackCache("zt-render-worker");
|
||||
- **Active (isActive=1)**: Worker is available for tasks
|
||||
- **Inactive (isActive=0)**: Worker is disabled
|
||||
|
||||
## Render Template Integration (ZT-Render-Worker Microservice)
|
||||
|
||||
### Key Components
|
||||
|
||||
#### Feign Clients
|
||||
- **RenderTemplateV2Client**: Template CRUD operations, segment management
|
||||
|
||||
#### Services
|
||||
- **RenderTemplateIntegrationService**: High-level template operations (with automatic fallback for queries)
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Basic Template Operations
|
||||
```java
|
||||
@Autowired
|
||||
private RenderTemplateIntegrationService templateService;
|
||||
|
||||
// Create template (direct operation, fails immediately on error)
|
||||
CreateTemplateRequest createRequest = new CreateTemplateRequest();
|
||||
createRequest.setScenicId(1001L);
|
||||
createRequest.setName("新年贺卡模板");
|
||||
createRequest.setDescription("用于新年祝福的模板");
|
||||
createRequest.setDefaultDurationMs(10000L);
|
||||
|
||||
OutputSpecDTO outputSpec = new OutputSpecDTO();
|
||||
outputSpec.setWidth(1080);
|
||||
outputSpec.setHeight(1920);
|
||||
outputSpec.setFps(30);
|
||||
createRequest.setOutputSpec(outputSpec);
|
||||
|
||||
TemplateV2DTO template = templateService.createTemplate(createRequest);
|
||||
|
||||
// Get template details (automatically falls back to cache on failure)
|
||||
TemplateV2DTO templateInfo = templateService.getTemplate(templateId);
|
||||
|
||||
// Get template with segments (automatically falls back to cache on failure)
|
||||
TemplateV2WithSegmentsDTO templateWithSegments = templateService.getTemplateWithSegments(templateId);
|
||||
|
||||
// List templates (no fallback for list operations)
|
||||
PageResponse<TemplateV2DTO> templates = templateService.listTemplates(1, 10, scenicId, 1, null);
|
||||
|
||||
// Update template (direct operation, fails immediately on error)
|
||||
UpdateTemplateRequest updateRequest = new UpdateTemplateRequest();
|
||||
updateRequest.setName("更新后的模板名称");
|
||||
templateService.updateTemplate(templateId, updateRequest);
|
||||
|
||||
// Publish template (direct operation, fails immediately on error)
|
||||
templateService.publishTemplate(templateId);
|
||||
|
||||
// Create new version (direct operation, fails immediately on error)
|
||||
TemplateV2DTO newVersion = templateService.createTemplateVersion(templateId);
|
||||
|
||||
// Delete template (direct operation, fails immediately on error)
|
||||
templateService.deleteTemplate(templateId);
|
||||
```
|
||||
|
||||
#### Segment Management
|
||||
```java
|
||||
// Get template segments (automatically falls back to cache on failure)
|
||||
List<TemplateV2SegmentDTO> segments = templateService.getTemplateSegments(templateId);
|
||||
|
||||
// Create segment (direct operation, fails immediately on error)
|
||||
CreateSegmentRequest segmentRequest = new CreateSegmentRequest();
|
||||
segmentRequest.setSegmentIndex(0);
|
||||
segmentRequest.setSegmentType("RENDER");
|
||||
segmentRequest.setSourceType("SLOT");
|
||||
segmentRequest.setSourceRef("slot1");
|
||||
segmentRequest.setDurationMs(2000L);
|
||||
segmentRequest.setTransitionType("fade");
|
||||
segmentRequest.setTransitionMs(500);
|
||||
|
||||
RenderSpecDTO renderSpec = new RenderSpecDTO();
|
||||
renderSpec.setCropEnable(true);
|
||||
renderSpec.setSpeed("1.0");
|
||||
segmentRequest.setRenderSpec(renderSpec);
|
||||
|
||||
TemplateV2SegmentDTO segment = templateService.createSegment(templateId, segmentRequest);
|
||||
|
||||
// Update segment (direct operation, fails immediately on error)
|
||||
UpdateSegmentRequest updateSegmentRequest = new UpdateSegmentRequest();
|
||||
updateSegmentRequest.setDurationMs(3000L);
|
||||
templateService.updateSegment(templateId, segmentId, updateSegmentRequest);
|
||||
|
||||
// Delete segment (direct operation, fails immediately on error)
|
||||
templateService.deleteSegment(templateId, segmentId);
|
||||
|
||||
// Replace all segments (direct operation, fails immediately on error)
|
||||
ReplaceSegmentsRequest replaceRequest = new ReplaceSegmentsRequest();
|
||||
replaceRequest.setSegments(Arrays.asList(segmentRequest1, segmentRequest2));
|
||||
templateService.replaceSegments(templateId, replaceRequest);
|
||||
```
|
||||
|
||||
### Template Status
|
||||
- **0**: Draft - Template is being edited
|
||||
- **1**: Published - Template is live and available for rendering
|
||||
|
||||
### Segment Types
|
||||
- **FIXED**: Fixed asset segment
|
||||
- **RENDER**: Segment that needs to be rendered with user materials
|
||||
|
||||
### Source Types
|
||||
- **ASSET**: Fixed asset resource
|
||||
- **PLACEHOLDER_VIDEO**: Video placeholder slot
|
||||
- **PLACEHOLDER_IMAGE**: Image placeholder slot
|
||||
- **SLOT**: Material slot
|
||||
|
||||
## Render Job Integration (ZT-Render-Worker Microservice)
|
||||
|
||||
### Key Components
|
||||
|
||||
#### Feign Clients
|
||||
- **RenderJobV2Client**: Job creation, status queries, admin operations
|
||||
|
||||
#### Services
|
||||
- **RenderJobIntegrationService**: High-level job operations (with automatic fallback for queries)
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Creating and Managing Render Jobs
|
||||
```java
|
||||
@Autowired
|
||||
private RenderJobIntegrationService jobService;
|
||||
|
||||
// Create preview job (direct operation, fails immediately on error)
|
||||
CreatePreviewRequest previewRequest = new CreatePreviewRequest();
|
||||
previewRequest.setTemplateId(123L);
|
||||
previewRequest.setScenicId(456L);
|
||||
previewRequest.setFaceId(789L);
|
||||
previewRequest.setMemberId(101L);
|
||||
|
||||
// Set materials by slot
|
||||
Map<String, List<MaterialDTO>> materialsBySlot = new HashMap<>();
|
||||
MaterialDTO material = new MaterialDTO();
|
||||
material.setUrl("https://example.com/video.mp4");
|
||||
material.setType("video");
|
||||
material.setDuration(5000L);
|
||||
materialsBySlot.put("slot1", Arrays.asList(material));
|
||||
previewRequest.setMaterialsBySlot(materialsBySlot);
|
||||
|
||||
CreatePreviewResponse previewResponse = jobService.createPreview(previewRequest);
|
||||
log.info("作业创建成功, jobId: {}, playUrl: {}", previewResponse.getJobId(), previewResponse.getPlayUrl());
|
||||
|
||||
// Get job status (automatically falls back to cache on failure)
|
||||
JobStatusResponse status = jobService.getJobStatus(jobId);
|
||||
log.info("作业状态: {}, 进度: {}%", status.getStatus(), status.getProgress());
|
||||
|
||||
// Get playlist info (automatically falls back to cache on failure)
|
||||
PlaylistInfoDTO playlistInfo = jobService.getPlaylistInfo(jobId);
|
||||
log.info("总片段: {}, 已发布: {}", playlistInfo.getSegmentCount(), playlistInfo.getPublishedCount());
|
||||
|
||||
// Get HLS playlist (direct call, returns M3U8 content)
|
||||
String hlsPlaylist = jobService.getHlsPlaylist(jobId);
|
||||
|
||||
// Cancel job (direct operation, fails immediately on error)
|
||||
jobService.cancelJob(jobId);
|
||||
```
|
||||
|
||||
#### Admin Operations
|
||||
```java
|
||||
// List jobs (no fallback for list operations)
|
||||
PageResponse<RenderJobV2DTO> jobs = jobService.listJobs(scenicId, templateId, "RUNNING", "PREVIEW", 1, 20);
|
||||
log.info("查询到 {} 条作业", jobs.getTotal());
|
||||
|
||||
// Get job detail (automatically falls back to cache on failure)
|
||||
RenderJobV2DTO jobDetail = jobService.getJobDetail(jobId);
|
||||
log.info("作业详情: templateId={}, status={}", jobDetail.getTemplateId(), jobDetail.getStatus());
|
||||
|
||||
// Get job segments (automatically falls back to cache on failure)
|
||||
List<RenderJobSegmentV2DTO> jobSegments = jobService.getJobSegments(jobId);
|
||||
for (RenderJobSegmentV2DTO segment : jobSegments) {
|
||||
log.info("片段 {}: status={}, tsUrl={}", segment.getPlanSegmentIndex(), segment.getStatus(), segment.getTsUrl());
|
||||
}
|
||||
```
|
||||
|
||||
### Job Status
|
||||
- **PENDING**: Job is waiting to be processed
|
||||
- **RUNNING**: Job is currently being processed
|
||||
- **SUCCESS**: Job completed successfully
|
||||
- **FAILED**: Job failed
|
||||
- **CANCELED**: Job was canceled
|
||||
|
||||
### Job Types
|
||||
- **PREVIEW**: Preview job (HLS streaming)
|
||||
- **PREVIEW_HLS**: HLS preview job
|
||||
- **FINAL_MP4**: Final MP4 export job
|
||||
|
||||
### Segment Status
|
||||
- **PENDING**: Segment is waiting to be processed
|
||||
- **RENDERING**: Segment is being rendered
|
||||
- **VIDEO_READY**: Video is ready
|
||||
- **PACKAGING**: Segment is being packaged
|
||||
- **TS_READY**: TS file is ready
|
||||
- **PUBLISHED**: Segment is published and available
|
||||
- **FAILED**: Segment processing failed
|
||||
|
||||
### Fallback Cache Management for Templates and Jobs
|
||||
```java
|
||||
@Autowired
|
||||
private IntegrationFallbackService fallbackService;
|
||||
|
||||
// Check fallback cache status for templates
|
||||
boolean hasTemplateCache = fallbackService.hasFallbackCache("zt-render-worker", "template:1001");
|
||||
boolean hasTemplateSegmentsCache = fallbackService.hasFallbackCache("zt-render-worker", "template:segments:1001");
|
||||
|
||||
// Check fallback cache status for jobs
|
||||
boolean hasJobStatusCache = fallbackService.hasFallbackCache("zt-render-worker", "job:status:2001");
|
||||
boolean hasJobDetailCache = fallbackService.hasFallbackCache("zt-render-worker", "job:detail:2001");
|
||||
|
||||
// Get cache statistics
|
||||
IntegrationFallbackService.FallbackCacheStats stats =
|
||||
fallbackService.getFallbackCacheStats("zt-render-worker");
|
||||
log.info("Render fallback cache: {} items, TTL: {} days",
|
||||
stats.getTotalCacheCount(), stats.getFallbackTtlDays());
|
||||
|
||||
// Clear specific cache
|
||||
fallbackService.clearFallbackCache("zt-render-worker", "template:1001");
|
||||
fallbackService.clearFallbackCache("zt-render-worker", "job:status:2001");
|
||||
|
||||
// Clear all render worker caches
|
||||
fallbackService.clearAllFallbackCache("zt-render-worker");
|
||||
```
|
||||
|
||||
## ZT-Message Integration (Kafka Producer)
|
||||
|
||||
### Overview
|
||||
|
||||
@@ -12,6 +12,7 @@ import io.reactivex.rxjava3.core.Flowable;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -23,6 +24,7 @@ import java.util.function.Consumer;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Lazy
|
||||
public class GlmClientImpl implements GlmClient {
|
||||
|
||||
private static final String DEFAULT_MODEL = "glm-4.5-airx";
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ycwl.basic.integration.render.client;
|
||||
|
||||
import com.ycwl.basic.integration.common.response.CommonResponse;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.integration.render.dto.job.*;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 渲染作业V2客户端
|
||||
*/
|
||||
@FeignClient(name = "zt-render-worker", contextId = "render-job-v2", path = "/api/render/v2")
|
||||
public interface RenderJobV2Client {
|
||||
|
||||
// ==================== 小程序侧接口 ====================
|
||||
|
||||
/**
|
||||
* 创建预览作业
|
||||
*/
|
||||
@PostMapping("/preview")
|
||||
CommonResponse<CreatePreviewResponse> createPreview(@RequestBody CreatePreviewRequest request);
|
||||
|
||||
/**
|
||||
* 获取作业状态
|
||||
*/
|
||||
@GetMapping("/jobs/{jobId}")
|
||||
CommonResponse<JobStatusResponse> getJobStatus(@PathVariable("jobId") Long jobId);
|
||||
|
||||
/**
|
||||
* 获取HLS播放列表
|
||||
* 返回M3U8格式的文本内容
|
||||
*/
|
||||
@GetMapping("/jobs/{jobId}/index.m3u8")
|
||||
String getHlsPlaylist(@PathVariable("jobId") Long jobId);
|
||||
|
||||
/**
|
||||
* 获取播放列表信息
|
||||
*/
|
||||
@GetMapping("/jobs/{jobId}/playlist-info")
|
||||
CommonResponse<PlaylistInfoDTO> getPlaylistInfo(@PathVariable("jobId") Long jobId);
|
||||
|
||||
/**
|
||||
* 取消作业
|
||||
*/
|
||||
@PostMapping("/jobs/{jobId}/cancel")
|
||||
CommonResponse<Void> cancelJob(@PathVariable("jobId") Long jobId);
|
||||
|
||||
// ==================== 管理端接口 ====================
|
||||
|
||||
/**
|
||||
* 获取作业列表
|
||||
*/
|
||||
@GetMapping("/admin/jobs")
|
||||
CommonResponse<PageResponse<RenderJobV2DTO>> listJobs(
|
||||
@RequestParam(required = false) Long scenicId,
|
||||
@RequestParam(required = false) Long templateId,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String jobType,
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
@RequestParam(defaultValue = "20") Integer pageSize);
|
||||
|
||||
/**
|
||||
* 获取作业详情
|
||||
*/
|
||||
@GetMapping("/admin/jobs/{jobId}")
|
||||
CommonResponse<RenderJobV2DTO> getJobDetail(@PathVariable("jobId") Long jobId);
|
||||
|
||||
/**
|
||||
* 获取作业片段列表
|
||||
*/
|
||||
@GetMapping("/admin/jobs/{jobId}/segments")
|
||||
CommonResponse<List<RenderJobSegmentV2DTO>> getJobSegments(@PathVariable("jobId") Long jobId);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.ycwl.basic.integration.render.client;
|
||||
|
||||
import com.ycwl.basic.integration.common.response.CommonResponse;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.integration.render.dto.template.*;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 渲染模板V2客户端
|
||||
*/
|
||||
@FeignClient(name = "zt-render-worker", contextId = "render-template-v2", path = "/api/render/template/v2")
|
||||
public interface RenderTemplateV2Client {
|
||||
|
||||
// ==================== Template CRUD Operations ====================
|
||||
|
||||
/**
|
||||
* 创建模板
|
||||
*/
|
||||
@PostMapping
|
||||
CommonResponse<TemplateV2DTO> createTemplate(@RequestBody CreateTemplateRequest request);
|
||||
|
||||
/**
|
||||
* 获取模板列表
|
||||
*/
|
||||
@GetMapping
|
||||
CommonResponse<PageResponse<TemplateV2DTO>> listTemplates(
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
@RequestParam(defaultValue = "10") Integer pageSize,
|
||||
@RequestParam(required = false) Long scenicId,
|
||||
@RequestParam(required = false) Integer status,
|
||||
@RequestParam(required = false) String name);
|
||||
|
||||
/**
|
||||
* 获取模板详情
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
CommonResponse<TemplateV2DTO> getTemplate(@PathVariable("id") Long id);
|
||||
|
||||
/**
|
||||
* 获取模板及其片段
|
||||
*/
|
||||
@GetMapping("/{id}/with-segments")
|
||||
CommonResponse<TemplateV2WithSegmentsDTO> getTemplateWithSegments(@PathVariable("id") Long id);
|
||||
|
||||
/**
|
||||
* 更新模板
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
CommonResponse<Void> updateTemplate(@PathVariable("id") Long id,
|
||||
@RequestBody UpdateTemplateRequest request);
|
||||
|
||||
/**
|
||||
* 删除模板
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
CommonResponse<Void> deleteTemplate(@PathVariable("id") Long id);
|
||||
|
||||
// ==================== Template Operations ====================
|
||||
|
||||
/**
|
||||
* 发布模板
|
||||
*/
|
||||
@PostMapping("/{id}/publish")
|
||||
CommonResponse<Void> publishTemplate(@PathVariable("id") Long id);
|
||||
|
||||
/**
|
||||
* 创建新版本
|
||||
*/
|
||||
@PostMapping("/{id}/version")
|
||||
CommonResponse<TemplateV2DTO> createTemplateVersion(@PathVariable("id") Long id);
|
||||
|
||||
// ==================== Segment Management ====================
|
||||
|
||||
/**
|
||||
* 获取模板片段列表
|
||||
*/
|
||||
@GetMapping("/{id}/segments")
|
||||
CommonResponse<List<TemplateV2SegmentDTO>> getTemplateSegments(@PathVariable("id") Long id);
|
||||
|
||||
/**
|
||||
* 创建片段
|
||||
*/
|
||||
@PostMapping("/{id}/segments")
|
||||
CommonResponse<TemplateV2SegmentDTO> createSegment(@PathVariable("id") Long id,
|
||||
@RequestBody CreateSegmentRequest request);
|
||||
|
||||
/**
|
||||
* 更新片段
|
||||
*/
|
||||
@PutMapping("/{id}/segments/{segmentId}")
|
||||
CommonResponse<Void> updateSegment(@PathVariable("id") Long id,
|
||||
@PathVariable("segmentId") Long segmentId,
|
||||
@RequestBody UpdateSegmentRequest request);
|
||||
|
||||
/**
|
||||
* 删除片段
|
||||
*/
|
||||
@DeleteMapping("/{id}/segments/{segmentId}")
|
||||
CommonResponse<Void> deleteSegment(@PathVariable("id") Long id,
|
||||
@PathVariable("segmentId") Long segmentId);
|
||||
|
||||
/**
|
||||
* 替换所有片段
|
||||
*/
|
||||
@PostMapping("/{id}/segments/replace")
|
||||
CommonResponse<Void> replaceSegments(@PathVariable("id") Long id,
|
||||
@RequestBody ReplaceSegmentsRequest request);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ycwl.basic.integration.render.dto.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 音频参数DTO
|
||||
*/
|
||||
@Data
|
||||
public class AudioSpecDTO {
|
||||
|
||||
/**
|
||||
* 音频素材URL
|
||||
*/
|
||||
private String audioUrl;
|
||||
|
||||
/**
|
||||
* 音量 (0.0-1.0)
|
||||
*/
|
||||
private Double volume;
|
||||
|
||||
/**
|
||||
* 淡入时长(毫秒)
|
||||
*/
|
||||
private Integer fadeInMs;
|
||||
|
||||
/**
|
||||
* 淡出时长(毫秒)
|
||||
*/
|
||||
private Integer fadeOutMs;
|
||||
|
||||
/**
|
||||
* 音频开始位置(毫秒)
|
||||
*/
|
||||
private Integer startMs;
|
||||
|
||||
/**
|
||||
* 延迟播放(毫秒)
|
||||
*/
|
||||
private Integer delayMs;
|
||||
|
||||
/**
|
||||
* 是否循环
|
||||
*/
|
||||
private Boolean loopEnable;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.ycwl.basic.integration.render.dto.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 输出规格DTO
|
||||
*/
|
||||
@Data
|
||||
public class OutputSpecDTO {
|
||||
|
||||
/**
|
||||
* 宽度
|
||||
*/
|
||||
private Integer width;
|
||||
|
||||
/**
|
||||
* 高度
|
||||
*/
|
||||
private Integer height;
|
||||
|
||||
/**
|
||||
* 帧率
|
||||
*/
|
||||
private Integer fps;
|
||||
|
||||
/**
|
||||
* 比特率
|
||||
*/
|
||||
private Integer bitrate;
|
||||
|
||||
/**
|
||||
* 视频编解码器 (默认h264)
|
||||
*/
|
||||
private String codec;
|
||||
|
||||
/**
|
||||
* 音频编解码器 (默认aac)
|
||||
*/
|
||||
private String audioCodec;
|
||||
|
||||
/**
|
||||
* 采样率 (默认48000)
|
||||
*/
|
||||
private Integer sampleRate;
|
||||
|
||||
/**
|
||||
* 声道数 (默认2)
|
||||
*/
|
||||
private Integer channels;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ycwl.basic.integration.render.dto.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 渲染参数DTO
|
||||
*/
|
||||
@Data
|
||||
public class RenderSpecDTO {
|
||||
|
||||
/**
|
||||
* 是否启用人脸裁切
|
||||
*/
|
||||
private Boolean cropEnable;
|
||||
|
||||
/**
|
||||
* 裁切后大小
|
||||
*/
|
||||
private String cropSize;
|
||||
|
||||
/**
|
||||
* 倍速
|
||||
*/
|
||||
private String speed;
|
||||
|
||||
/**
|
||||
* 调色LUT文件URL
|
||||
*/
|
||||
private String lutUrl;
|
||||
|
||||
/**
|
||||
* 叠加蒙版URL
|
||||
*/
|
||||
private String overlayUrl;
|
||||
|
||||
/**
|
||||
* 特效配置
|
||||
*/
|
||||
private String effects;
|
||||
|
||||
/**
|
||||
* 是否缩放裁切
|
||||
*/
|
||||
private Boolean zoomCut;
|
||||
|
||||
/**
|
||||
* 竖屏切割位置
|
||||
*/
|
||||
private String videoCrop;
|
||||
|
||||
/**
|
||||
* 人脸位置参数
|
||||
*/
|
||||
private String facePos;
|
||||
|
||||
/**
|
||||
* 转场效果
|
||||
*/
|
||||
private String transitions;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 创建预览请求
|
||||
*/
|
||||
@Data
|
||||
public class CreatePreviewRequest {
|
||||
|
||||
/**
|
||||
* 模板ID (必填)
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 景区ID (必填)
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 人脸ID (可选)
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 会员ID (可选)
|
||||
*/
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 素材槽映射 (可选)
|
||||
* key: 槽位键
|
||||
* value: 素材列表
|
||||
*/
|
||||
private Map<String, List<MaterialDTO>> materialsBySlot;
|
||||
|
||||
/**
|
||||
* 自定义输出规格 (可选)
|
||||
*/
|
||||
private OutputSpecDTO outputSpec;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 创建预览响应
|
||||
*/
|
||||
@Data
|
||||
public class CreatePreviewResponse {
|
||||
|
||||
/**
|
||||
* 作业ID
|
||||
*/
|
||||
private Long jobId;
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 播放URL
|
||||
*/
|
||||
private String playUrl;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 作业状态响应
|
||||
*/
|
||||
@Data
|
||||
public class JobStatusResponse {
|
||||
|
||||
/**
|
||||
* 作业ID
|
||||
*/
|
||||
private Long jobId;
|
||||
|
||||
/**
|
||||
* 状态 (PENDING, RUNNING, SUCCESS, FAILED, CANCELED)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 进度 (0.0 - 100.0)
|
||||
*/
|
||||
private Double progress;
|
||||
|
||||
/**
|
||||
* 总片段数
|
||||
*/
|
||||
private Integer segmentCount;
|
||||
|
||||
/**
|
||||
* 已发布片段数
|
||||
*/
|
||||
private Integer publishedCount;
|
||||
|
||||
/**
|
||||
* 播放URL
|
||||
*/
|
||||
private String playUrl;
|
||||
|
||||
/**
|
||||
* MP4下载URL
|
||||
*/
|
||||
private String mp4Url;
|
||||
|
||||
/**
|
||||
* 错误码
|
||||
*/
|
||||
private String errorCode;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private String errorMessage;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 素材信息DTO
|
||||
*/
|
||||
@Data
|
||||
public class MaterialDTO {
|
||||
|
||||
/**
|
||||
* 素材URL
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 类型 (video, image)
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 时长(毫秒)
|
||||
*/
|
||||
private Long duration;
|
||||
|
||||
/**
|
||||
* 人脸分数
|
||||
*/
|
||||
private String score;
|
||||
|
||||
/**
|
||||
* 人脸位置
|
||||
*/
|
||||
private String facePos;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 播放列表信息DTO
|
||||
*/
|
||||
@Data
|
||||
public class PlaylistInfoDTO {
|
||||
|
||||
/**
|
||||
* 总片段数
|
||||
*/
|
||||
private Integer segmentCount;
|
||||
|
||||
/**
|
||||
* 进度 (0.0 - 100.0)
|
||||
*/
|
||||
private Double progress;
|
||||
|
||||
/**
|
||||
* 已发布片段数
|
||||
*/
|
||||
private Integer publishedCount;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
|
||||
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 渲染作业片段V2 DTO
|
||||
*/
|
||||
@Data
|
||||
public class RenderJobSegmentV2DTO {
|
||||
|
||||
/**
|
||||
* 片段ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 作业ID
|
||||
*/
|
||||
private Long jobId;
|
||||
|
||||
/**
|
||||
* 模板片段索引
|
||||
*/
|
||||
private Integer templateSegmentIndex;
|
||||
|
||||
/**
|
||||
* 计划片段索引
|
||||
*/
|
||||
private Integer planSegmentIndex;
|
||||
|
||||
/**
|
||||
* 开始时间(毫秒)
|
||||
*/
|
||||
private Long startTimeMs;
|
||||
|
||||
/**
|
||||
* 时长(毫秒)
|
||||
*/
|
||||
private Long durationMs;
|
||||
|
||||
/**
|
||||
* 片段类型 (RENDER, FIXED)
|
||||
*/
|
||||
private String segmentType;
|
||||
|
||||
/**
|
||||
* 状态 (PENDING, RENDERING, VIDEO_READY, PACKAGING, TS_READY, PUBLISHED, FAILED)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 素材来源类型
|
||||
*/
|
||||
private String sourceType;
|
||||
|
||||
/**
|
||||
* 素材来源引用
|
||||
*/
|
||||
private String sourceRef;
|
||||
|
||||
/**
|
||||
* 绑定素材URL
|
||||
*/
|
||||
private String boundMaterialUrl;
|
||||
|
||||
/**
|
||||
* 渲染参数JSON
|
||||
*/
|
||||
@JsonProperty("renderSpecJson")
|
||||
private RenderSpecDTO renderSpecJson;
|
||||
|
||||
/**
|
||||
* 音频参数JSON
|
||||
*/
|
||||
@JsonProperty("audioSpecJson")
|
||||
private AudioSpecDTO audioSpecJson;
|
||||
|
||||
/**
|
||||
* 视频URL
|
||||
*/
|
||||
private String videoUrl;
|
||||
|
||||
/**
|
||||
* TS文件URL
|
||||
*/
|
||||
private String tsUrl;
|
||||
|
||||
/**
|
||||
* TS时长(秒)
|
||||
*/
|
||||
private Double tsDuration;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonProperty("createTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonProperty("updateTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.ycwl.basic.integration.render.dto.job;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 渲染作业V2 DTO
|
||||
*/
|
||||
@Data
|
||||
public class RenderJobV2DTO {
|
||||
|
||||
/**
|
||||
* 作业ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 作业类型 (PREVIEW, PREVIEW_HLS, FINAL_MP4)
|
||||
*/
|
||||
private String jobType;
|
||||
|
||||
/**
|
||||
* 状态 (PENDING, RUNNING, SUCCESS, FAILED, CANCELED)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 模板ID
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 模板版本
|
||||
*/
|
||||
private Integer templateVersion;
|
||||
|
||||
/**
|
||||
* 人脸ID
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 会员ID
|
||||
*/
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 总时长(毫秒)
|
||||
*/
|
||||
private Long totalDurationMs;
|
||||
|
||||
/**
|
||||
* 输出规格JSON
|
||||
*/
|
||||
@JsonProperty("outputSpecJson")
|
||||
private Map<String, Object> outputSpecJson;
|
||||
|
||||
/**
|
||||
* 渲染计划JSON
|
||||
*/
|
||||
private String planJson;
|
||||
|
||||
/**
|
||||
* 总片段数
|
||||
*/
|
||||
private Integer segmentCount;
|
||||
|
||||
/**
|
||||
* 已发布片段数
|
||||
*/
|
||||
private Integer publishedCount;
|
||||
|
||||
/**
|
||||
* 已完成片段数
|
||||
*/
|
||||
private Integer completedCount;
|
||||
|
||||
/**
|
||||
* M3U8播放地址
|
||||
*/
|
||||
private String m3u8Url;
|
||||
|
||||
/**
|
||||
* 音频URL
|
||||
*/
|
||||
private String audioUrl;
|
||||
|
||||
/**
|
||||
* MP4下载地址
|
||||
*/
|
||||
private String mp4Url;
|
||||
|
||||
/**
|
||||
* 错误码
|
||||
*/
|
||||
private String errorCode;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
private String errorMessage;
|
||||
|
||||
/**
|
||||
* 幂等键
|
||||
*/
|
||||
private String idempotencyKey;
|
||||
|
||||
/**
|
||||
* 开始时间
|
||||
*/
|
||||
@JsonProperty("startTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date startTime;
|
||||
|
||||
/**
|
||||
* 完成时间
|
||||
*/
|
||||
@JsonProperty("finishTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date finishTime;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonProperty("createTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonProperty("updateTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
|
||||
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 创建片段请求
|
||||
*/
|
||||
@Data
|
||||
public class CreateSegmentRequest {
|
||||
|
||||
/**
|
||||
* 片段索引
|
||||
*/
|
||||
private Integer segmentIndex;
|
||||
|
||||
/**
|
||||
* 片段类型 (RENDER, FIXED)
|
||||
*/
|
||||
private String segmentType;
|
||||
|
||||
/**
|
||||
* 是否可缓存
|
||||
*/
|
||||
private Boolean cacheable;
|
||||
|
||||
/**
|
||||
* 位置是否固定
|
||||
*/
|
||||
private Boolean positionFixed;
|
||||
|
||||
/**
|
||||
* 素材来源类型 (ASSET, PLACEHOLDER_VIDEO, PLACEHOLDER_IMAGE, SLOT)
|
||||
*/
|
||||
private String sourceType;
|
||||
|
||||
/**
|
||||
* 素材来源引用
|
||||
*/
|
||||
private String sourceRef;
|
||||
|
||||
/**
|
||||
* 时长(毫秒)
|
||||
*/
|
||||
private Long durationMs;
|
||||
|
||||
/**
|
||||
* 转场类型
|
||||
*/
|
||||
private String transitionType;
|
||||
|
||||
/**
|
||||
* 转场时长(毫秒)
|
||||
*/
|
||||
private Integer transitionMs;
|
||||
|
||||
/**
|
||||
* 素材槽位键
|
||||
*/
|
||||
private String slotKey;
|
||||
|
||||
/**
|
||||
* 条件表达式
|
||||
*/
|
||||
private Map<String, Object> onlyIfExpr;
|
||||
|
||||
/**
|
||||
* 渲染参数
|
||||
*/
|
||||
private RenderSpecDTO renderSpec;
|
||||
|
||||
/**
|
||||
* 音频参数
|
||||
*/
|
||||
private AudioSpecDTO audioSpec;
|
||||
|
||||
/**
|
||||
* 扩展属性
|
||||
*/
|
||||
private Map<String, Object> extendedProps;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 创建模板请求
|
||||
*/
|
||||
@Data
|
||||
public class CreateTemplateRequest {
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 模板名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 缩略图URL
|
||||
*/
|
||||
private String thumbnailUrl;
|
||||
|
||||
/**
|
||||
* 默认时长(毫秒)
|
||||
*/
|
||||
private Long defaultDurationMs;
|
||||
|
||||
/**
|
||||
* 输出规格
|
||||
*/
|
||||
private OutputSpecDTO outputSpec;
|
||||
|
||||
/**
|
||||
* 背景音乐URL
|
||||
*/
|
||||
private String bgmUrl;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 替换所有片段请求
|
||||
*/
|
||||
@Data
|
||||
public class ReplaceSegmentsRequest {
|
||||
|
||||
/**
|
||||
* 片段列表
|
||||
*/
|
||||
private List<CreateSegmentRequest> segments;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 渲染模板V2 DTO
|
||||
*/
|
||||
@Data
|
||||
public class TemplateV2DTO {
|
||||
|
||||
/**
|
||||
* 模板ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 模板名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
private Integer version;
|
||||
|
||||
/**
|
||||
* 状态 (0-草稿, 1-已发布)
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 缩略图URL
|
||||
*/
|
||||
private String thumbnailUrl;
|
||||
|
||||
/**
|
||||
* 默认时长(毫秒)
|
||||
*/
|
||||
private Long defaultDurationMs;
|
||||
|
||||
/**
|
||||
* 输出规格
|
||||
*/
|
||||
private OutputSpecDTO outputSpec;
|
||||
|
||||
/**
|
||||
* 输出宽度
|
||||
*/
|
||||
private Integer outputWidth;
|
||||
|
||||
/**
|
||||
* 输出高度
|
||||
*/
|
||||
private Integer outputHeight;
|
||||
|
||||
/**
|
||||
* 输出帧率
|
||||
*/
|
||||
private Integer outputFps;
|
||||
|
||||
/**
|
||||
* 背景音乐URL
|
||||
*/
|
||||
private String bgmUrl;
|
||||
|
||||
/**
|
||||
* 背景音乐是否固定
|
||||
*/
|
||||
private Boolean bgmFixed;
|
||||
|
||||
/**
|
||||
* 封面URL
|
||||
*/
|
||||
private String coverUrl;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonProperty("createTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonProperty("updateTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date updateTime;
|
||||
|
||||
/**
|
||||
* 删除标记
|
||||
*/
|
||||
private Integer deleted;
|
||||
|
||||
/**
|
||||
* 删除时间
|
||||
*/
|
||||
@JsonProperty("deletedAt")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date deletedAt;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
|
||||
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 模板片段V2 DTO
|
||||
*/
|
||||
@Data
|
||||
public class TemplateV2SegmentDTO {
|
||||
|
||||
/**
|
||||
* 片段ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 模板ID
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 片段索引
|
||||
*/
|
||||
private Integer segmentIndex;
|
||||
|
||||
/**
|
||||
* 片段类型 (RENDER, FIXED)
|
||||
*/
|
||||
private String segmentType;
|
||||
|
||||
/**
|
||||
* 是否可缓存
|
||||
*/
|
||||
private Boolean cacheable;
|
||||
|
||||
/**
|
||||
* 位置是否固定
|
||||
*/
|
||||
private Boolean positionFixed;
|
||||
|
||||
/**
|
||||
* 时长(毫秒)
|
||||
*/
|
||||
private Long durationMs;
|
||||
|
||||
/**
|
||||
* 转场类型
|
||||
*/
|
||||
private String transitionType;
|
||||
|
||||
/**
|
||||
* 转场时长(毫秒)
|
||||
*/
|
||||
private Integer transitionMs;
|
||||
|
||||
/**
|
||||
* 素材槽位键
|
||||
*/
|
||||
private String slotKey;
|
||||
|
||||
/**
|
||||
* 条件表达式JSON
|
||||
*/
|
||||
@JsonProperty("onlyIfExprJson")
|
||||
private Map<String, Object> onlyIfExprJson;
|
||||
|
||||
/**
|
||||
* 素材来源类型 (ASSET, PLACEHOLDER_VIDEO, PLACEHOLDER_IMAGE, SLOT)
|
||||
*/
|
||||
private String sourceType;
|
||||
|
||||
/**
|
||||
* 素材来源引用
|
||||
*/
|
||||
private String sourceRef;
|
||||
|
||||
/**
|
||||
* 渲染参数JSON
|
||||
*/
|
||||
@JsonProperty("renderSpecJson")
|
||||
private RenderSpecDTO renderSpecJson;
|
||||
|
||||
/**
|
||||
* 音频参数JSON
|
||||
*/
|
||||
@JsonProperty("audioSpecJson")
|
||||
private AudioSpecDTO audioSpecJson;
|
||||
|
||||
/**
|
||||
* 扩展属性JSON
|
||||
*/
|
||||
@JsonProperty("extendedPropsJson")
|
||||
private Map<String, Object> extendedPropsJson;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonProperty("createTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@JsonProperty("updateTime")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 模板及其片段响应DTO
|
||||
*/
|
||||
@Data
|
||||
public class TemplateV2WithSegmentsDTO {
|
||||
|
||||
/**
|
||||
* 模板信息
|
||||
*/
|
||||
private TemplateV2DTO template;
|
||||
|
||||
/**
|
||||
* 片段列表
|
||||
*/
|
||||
private List<TemplateV2SegmentDTO> segments;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.ycwl.basic.integration.render.dto.common.AudioSpecDTO;
|
||||
import com.ycwl.basic.integration.render.dto.common.RenderSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 更新片段请求
|
||||
*/
|
||||
@Data
|
||||
public class UpdateSegmentRequest {
|
||||
|
||||
/**
|
||||
* 片段索引
|
||||
*/
|
||||
private Integer segmentIndex;
|
||||
|
||||
/**
|
||||
* 片段类型 (RENDER, FIXED)
|
||||
*/
|
||||
private String segmentType;
|
||||
|
||||
/**
|
||||
* 是否可缓存
|
||||
*/
|
||||
private Boolean cacheable;
|
||||
|
||||
/**
|
||||
* 素材来源类型 (ASSET, PLACEHOLDER_VIDEO, PLACEHOLDER_IMAGE, SLOT)
|
||||
*/
|
||||
private String sourceType;
|
||||
|
||||
/**
|
||||
* 素材来源引用
|
||||
*/
|
||||
private String sourceRef;
|
||||
|
||||
/**
|
||||
* 时长(毫秒)
|
||||
*/
|
||||
private Long durationMs;
|
||||
|
||||
/**
|
||||
* 转场类型
|
||||
*/
|
||||
private String transitionType;
|
||||
|
||||
/**
|
||||
* 转场时长(毫秒)
|
||||
*/
|
||||
private Integer transitionMs;
|
||||
|
||||
/**
|
||||
* 素材槽位键
|
||||
*/
|
||||
private String slotKey;
|
||||
|
||||
/**
|
||||
* 条件表达式
|
||||
*/
|
||||
private Map<String, Object> onlyIfExpr;
|
||||
|
||||
/**
|
||||
* 渲染参数
|
||||
*/
|
||||
private RenderSpecDTO renderSpec;
|
||||
|
||||
/**
|
||||
* 音频参数
|
||||
*/
|
||||
private AudioSpecDTO audioSpec;
|
||||
|
||||
/**
|
||||
* 扩展属性
|
||||
*/
|
||||
private Map<String, Object> extendedProps;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ycwl.basic.integration.render.dto.template;
|
||||
|
||||
import com.ycwl.basic.integration.render.dto.common.OutputSpecDTO;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 更新模板请求
|
||||
*/
|
||||
@Data
|
||||
public class UpdateTemplateRequest {
|
||||
|
||||
/**
|
||||
* 模板名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 缩略图URL
|
||||
*/
|
||||
private String thumbnailUrl;
|
||||
|
||||
/**
|
||||
* 默认时长(毫秒)
|
||||
*/
|
||||
private Long defaultDurationMs;
|
||||
|
||||
/**
|
||||
* 输出规格
|
||||
*/
|
||||
private OutputSpecDTO outputSpec;
|
||||
|
||||
/**
|
||||
* 背景音乐URL
|
||||
*/
|
||||
private String bgmUrl;
|
||||
|
||||
/**
|
||||
* 背景音乐是否固定
|
||||
*/
|
||||
private Boolean bgmFixed;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.ycwl.basic.integration.render.service;
|
||||
|
||||
import com.ycwl.basic.integration.common.exception.IntegrationException;
|
||||
import com.ycwl.basic.integration.common.response.CommonResponse;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
|
||||
import com.ycwl.basic.integration.render.client.RenderJobV2Client;
|
||||
import com.ycwl.basic.integration.render.dto.job.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 渲染作业集成服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RenderJobIntegrationService {
|
||||
|
||||
private final RenderJobV2Client renderJobV2Client;
|
||||
private final IntegrationFallbackService fallbackService;
|
||||
|
||||
private static final String SERVICE_NAME = "zt-render-worker";
|
||||
|
||||
// ==================== 小程序侧接口 ====================
|
||||
|
||||
/**
|
||||
* 创建预览作业(直接调用,不降级)
|
||||
*/
|
||||
public CreatePreviewResponse createPreview(CreatePreviewRequest request) {
|
||||
log.debug("创建预览作业, templateId: {}, scenicId: {}", request.getTemplateId(), request.getScenicId());
|
||||
CommonResponse<CreatePreviewResponse> response = renderJobV2Client.createPreview(request);
|
||||
return handleResponse(response, "创建预览作业失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作业状态(带降级)
|
||||
*/
|
||||
public JobStatusResponse getJobStatus(Long jobId) {
|
||||
log.debug("获取作业状态, jobId: {}", jobId);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"job:status:" + jobId,
|
||||
() -> {
|
||||
CommonResponse<JobStatusResponse> response = renderJobV2Client.getJobStatus(jobId);
|
||||
return handleResponse(response, "获取作业状态失败");
|
||||
},
|
||||
JobStatusResponse.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取HLS播放列表
|
||||
* 返回M3U8格式的文本内容(不降级)
|
||||
*/
|
||||
public String getHlsPlaylist(Long jobId) {
|
||||
log.debug("获取HLS播放列表, jobId: {}", jobId);
|
||||
try {
|
||||
return renderJobV2Client.getHlsPlaylist(jobId);
|
||||
} catch (Exception e) {
|
||||
log.error("获取HLS播放列表失败, jobId: {}", jobId, e);
|
||||
throw new IntegrationException(5000, "获取HLS播放列表失败: " + e.getMessage(), SERVICE_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取播放列表信息(带降级)
|
||||
*/
|
||||
public PlaylistInfoDTO getPlaylistInfo(Long jobId) {
|
||||
log.debug("获取播放列表信息, jobId: {}", jobId);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"job:playlist-info:" + jobId,
|
||||
() -> {
|
||||
CommonResponse<PlaylistInfoDTO> response = renderJobV2Client.getPlaylistInfo(jobId);
|
||||
return handleResponse(response, "获取播放列表信息失败");
|
||||
},
|
||||
PlaylistInfoDTO.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消作业(直接调用,不降级)
|
||||
*/
|
||||
public void cancelJob(Long jobId) {
|
||||
log.debug("取消作业, jobId: {}", jobId);
|
||||
CommonResponse<Void> response = renderJobV2Client.cancelJob(jobId);
|
||||
handleVoidResponse(response, "取消作业失败");
|
||||
}
|
||||
|
||||
// ==================== 管理端接口 ====================
|
||||
|
||||
/**
|
||||
* 获取作业列表(不降级)
|
||||
*/
|
||||
public PageResponse<RenderJobV2DTO> listJobs(Long scenicId, Long templateId, String status,
|
||||
String jobType, Integer page, Integer pageSize) {
|
||||
log.debug("查询作业列表, scenicId: {}, templateId: {}, status: {}, jobType: {}, page: {}, pageSize: {}",
|
||||
scenicId, templateId, status, jobType, page, pageSize);
|
||||
CommonResponse<PageResponse<RenderJobV2DTO>> response =
|
||||
renderJobV2Client.listJobs(scenicId, templateId, status, jobType, page, pageSize);
|
||||
return handleResponse(response, "查询作业列表失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作业详情(带降级)
|
||||
*/
|
||||
public RenderJobV2DTO getJobDetail(Long jobId) {
|
||||
log.debug("获取作业详情, jobId: {}", jobId);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"job:detail:" + jobId,
|
||||
() -> {
|
||||
CommonResponse<RenderJobV2DTO> response = renderJobV2Client.getJobDetail(jobId);
|
||||
return handleResponse(response, "获取作业详情失败");
|
||||
},
|
||||
RenderJobV2DTO.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作业片段列表(带降级)
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<RenderJobSegmentV2DTO> getJobSegments(Long jobId) {
|
||||
log.debug("获取作业片段列表, jobId: {}", jobId);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"job:segments:" + jobId,
|
||||
() -> {
|
||||
CommonResponse<List<RenderJobSegmentV2DTO>> response =
|
||||
renderJobV2Client.getJobSegments(jobId);
|
||||
return handleResponse(response, "获取作业片段列表失败");
|
||||
},
|
||||
(Class<List<RenderJobSegmentV2DTO>>) (Class<?>) List.class
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* 处理通用响应
|
||||
*/
|
||||
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
|
||||
if (response == null || !response.getSuccess()) {
|
||||
String msg = response != null && response.getMessage() != null ?
|
||||
response.getMessage() : errorMessage;
|
||||
Integer code = response != null ? response.getCode() : 5000;
|
||||
throw new IntegrationException(code, msg, SERVICE_NAME);
|
||||
}
|
||||
return response.getData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理空响应
|
||||
*/
|
||||
private void handleVoidResponse(CommonResponse<Void> response, String errorMessage) {
|
||||
if (response == null || !response.getSuccess()) {
|
||||
String msg = response != null && response.getMessage() != null ?
|
||||
response.getMessage() : errorMessage;
|
||||
Integer code = response != null ? response.getCode() : 5000;
|
||||
throw new IntegrationException(code, msg, SERVICE_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.ycwl.basic.integration.render.service;
|
||||
|
||||
import com.ycwl.basic.integration.common.exception.IntegrationException;
|
||||
import com.ycwl.basic.integration.common.response.CommonResponse;
|
||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
|
||||
import com.ycwl.basic.integration.render.client.RenderTemplateV2Client;
|
||||
import com.ycwl.basic.integration.render.dto.template.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 渲染模板集成服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RenderTemplateIntegrationService {
|
||||
|
||||
private final RenderTemplateV2Client renderTemplateV2Client;
|
||||
private final IntegrationFallbackService fallbackService;
|
||||
|
||||
private static final String SERVICE_NAME = "zt-render-worker";
|
||||
|
||||
// ==================== Template CRUD Operations ====================
|
||||
|
||||
/**
|
||||
* 创建模板(直接调用,不降级)
|
||||
*/
|
||||
public TemplateV2DTO createTemplate(CreateTemplateRequest request) {
|
||||
log.debug("创建渲染模板, scenicId: {}, name: {}", request.getScenicId(), request.getName());
|
||||
CommonResponse<TemplateV2DTO> response = renderTemplateV2Client.createTemplate(request);
|
||||
return handleResponse(response, "创建渲染模板失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板列表(不降级)
|
||||
*/
|
||||
public PageResponse<TemplateV2DTO> listTemplates(Integer page, Integer pageSize, Long scenicId,
|
||||
Integer status, String name) {
|
||||
log.debug("查询渲染模板列表, page: {}, pageSize: {}, scenicId: {}, status: {}, name: {}",
|
||||
page, pageSize, scenicId, status, name);
|
||||
CommonResponse<PageResponse<TemplateV2DTO>> response =
|
||||
renderTemplateV2Client.listTemplates(page, pageSize, scenicId, status, name);
|
||||
return handleResponse(response, "查询渲染模板列表失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板详情(带降级)
|
||||
*/
|
||||
public TemplateV2DTO getTemplate(Long id) {
|
||||
log.debug("获取渲染模板信息, id: {}", id);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"template:" + id,
|
||||
() -> {
|
||||
CommonResponse<TemplateV2DTO> response = renderTemplateV2Client.getTemplate(id);
|
||||
return handleResponse(response, "获取渲染模板信息失败");
|
||||
},
|
||||
TemplateV2DTO.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板及其片段(带降级)
|
||||
*/
|
||||
public TemplateV2WithSegmentsDTO getTemplateWithSegments(Long id) {
|
||||
log.debug("获取渲染模板及片段, id: {}", id);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"template:with-segments:" + id,
|
||||
() -> {
|
||||
CommonResponse<TemplateV2WithSegmentsDTO> response =
|
||||
renderTemplateV2Client.getTemplateWithSegments(id);
|
||||
return handleResponse(response, "获取渲染模板及片段失败");
|
||||
},
|
||||
TemplateV2WithSegmentsDTO.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板(直接调用,不降级)
|
||||
*/
|
||||
public void updateTemplate(Long id, UpdateTemplateRequest request) {
|
||||
log.debug("更新渲染模板, id: {}, name: {}", id, request.getName());
|
||||
CommonResponse<Void> response = renderTemplateV2Client.updateTemplate(id, request);
|
||||
handleVoidResponse(response, "更新渲染模板失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模板(直接调用,不降级)
|
||||
*/
|
||||
public void deleteTemplate(Long id) {
|
||||
log.debug("删除渲染模板, id: {}", id);
|
||||
CommonResponse<Void> response = renderTemplateV2Client.deleteTemplate(id);
|
||||
handleVoidResponse(response, "删除渲染模板失败");
|
||||
}
|
||||
|
||||
// ==================== Template Operations ====================
|
||||
|
||||
/**
|
||||
* 发布模板(直接调用,不降级)
|
||||
*/
|
||||
public void publishTemplate(Long id) {
|
||||
log.debug("发布渲染模板, id: {}", id);
|
||||
CommonResponse<Void> response = renderTemplateV2Client.publishTemplate(id);
|
||||
handleVoidResponse(response, "发布渲染模板失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新版本(直接调用,不降级)
|
||||
*/
|
||||
public TemplateV2DTO createTemplateVersion(Long id) {
|
||||
log.debug("创建渲染模板新版本, id: {}", id);
|
||||
CommonResponse<TemplateV2DTO> response = renderTemplateV2Client.createTemplateVersion(id);
|
||||
return handleResponse(response, "创建渲染模板新版本失败");
|
||||
}
|
||||
|
||||
// ==================== Segment Management ====================
|
||||
|
||||
/**
|
||||
* 获取模板片段列表(带降级)
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<TemplateV2SegmentDTO> getTemplateSegments(Long id) {
|
||||
log.debug("获取渲染模板片段列表, templateId: {}", id);
|
||||
return fallbackService.executeWithFallback(
|
||||
SERVICE_NAME,
|
||||
"template:segments:" + id,
|
||||
() -> {
|
||||
CommonResponse<List<TemplateV2SegmentDTO>> response =
|
||||
renderTemplateV2Client.getTemplateSegments(id);
|
||||
return handleResponse(response, "获取渲染模板片段列表失败");
|
||||
},
|
||||
(Class<List<TemplateV2SegmentDTO>>) (Class<?>) List.class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建片段(直接调用,不降级)
|
||||
*/
|
||||
public TemplateV2SegmentDTO createSegment(Long templateId, CreateSegmentRequest request) {
|
||||
log.debug("创建模板片段, templateId: {}, segmentIndex: {}", templateId, request.getSegmentIndex());
|
||||
CommonResponse<TemplateV2SegmentDTO> response =
|
||||
renderTemplateV2Client.createSegment(templateId, request);
|
||||
return handleResponse(response, "创建模板片段失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新片段(直接调用,不降级)
|
||||
*/
|
||||
public void updateSegment(Long templateId, Long segmentId, UpdateSegmentRequest request) {
|
||||
log.debug("更新模板片段, templateId: {}, segmentId: {}", templateId, segmentId);
|
||||
CommonResponse<Void> response =
|
||||
renderTemplateV2Client.updateSegment(templateId, segmentId, request);
|
||||
handleVoidResponse(response, "更新模板片段失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除片段(直接调用,不降级)
|
||||
*/
|
||||
public void deleteSegment(Long templateId, Long segmentId) {
|
||||
log.debug("删除模板片段, templateId: {}, segmentId: {}", templateId, segmentId);
|
||||
CommonResponse<Void> response = renderTemplateV2Client.deleteSegment(templateId, segmentId);
|
||||
handleVoidResponse(response, "删除模板片段失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换所有片段(直接调用,不降级)
|
||||
*/
|
||||
public void replaceSegments(Long templateId, ReplaceSegmentsRequest request) {
|
||||
log.debug("替换所有模板片段, templateId: {}, segmentCount: {}",
|
||||
templateId, request.getSegments() != null ? request.getSegments().size() : 0);
|
||||
CommonResponse<Void> response = renderTemplateV2Client.replaceSegments(templateId, request);
|
||||
handleVoidResponse(response, "替换所有模板片段失败");
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* 处理通用响应
|
||||
*/
|
||||
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
|
||||
if (response == null || !response.getSuccess()) {
|
||||
String msg = response != null && response.getMessage() != null ?
|
||||
response.getMessage() : errorMessage;
|
||||
Integer code = response != null ? response.getCode() : 5000;
|
||||
throw new IntegrationException(code, msg, SERVICE_NAME);
|
||||
}
|
||||
return response.getData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理空响应
|
||||
*/
|
||||
private void handleVoidResponse(CommonResponse<Void> response, String errorMessage) {
|
||||
if (response == null || !response.getSuccess()) {
|
||||
String msg = response != null && response.getMessage() != null ?
|
||||
response.getMessage() : errorMessage;
|
||||
Integer code = response != null ? response.getCode() : 5000;
|
||||
throw new IntegrationException(code, msg, SERVICE_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.coupon.req.CouponQueryReq;
|
||||
import com.ycwl.basic.model.pc.coupon.resp.CouponRespVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CouponMapper {
|
||||
List<CouponRespVO> selectByQuery(CouponQueryReq query);
|
||||
|
||||
int updateStatus(Integer id);
|
||||
|
||||
CouponEntity getById(Integer couponId);
|
||||
|
||||
int insert(CouponEntity coupon);
|
||||
|
||||
int updateById(CouponEntity coupon);
|
||||
|
||||
int deleteById(Integer id);
|
||||
|
||||
List<CouponEntity> selectList();
|
||||
|
||||
CouponEntity selectById(Integer id);
|
||||
|
||||
CouponEntity selectByScenicIdAndTypeAndStatus(Long scenicId, Integer type, Integer status);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.ycwl.basic.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
|
||||
import com.ycwl.basic.model.pc.couponRecord.req.CouponRecordPageQueryReq;
|
||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordPageResp;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CouponRecordMapper extends BaseMapper<CouponRecordEntity> {
|
||||
List<CouponRecordEntity> queryByUserWithGoodsId(Long scenicId, Long memberId, String goodsId);
|
||||
|
||||
List<CouponRecordEntity> queryByMemberIdAndFaceId(Long memberId, Long faceId);
|
||||
|
||||
CouponRecordEntity queryByMemberIdAndFaceIdAndType(Long memberId, Long faceId, Integer type);
|
||||
|
||||
List<CouponRecordPageResp> selectByPageQuery(CouponRecordPageQueryReq query);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ycwl.basic.model.mobile.scenic.content;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 景区模板内容响应对象
|
||||
* 用于返回景区内的模板基础信息(与 faceId 无关)
|
||||
*/
|
||||
@Data
|
||||
public class ScenicTemplateContentVO {
|
||||
/**
|
||||
* 商品类型
|
||||
*/
|
||||
private Integer goodsType;
|
||||
|
||||
/**
|
||||
* 模板名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 模板分组
|
||||
*/
|
||||
private String group;
|
||||
|
||||
/**
|
||||
* 模板ID
|
||||
*/
|
||||
private Long templateId;
|
||||
|
||||
/**
|
||||
* 模板封面URL
|
||||
*/
|
||||
private String templateCoverUrl;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.coupon.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("coupon")
|
||||
public class CouponEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Integer id;
|
||||
private Long scenicId;
|
||||
|
||||
// 新增优惠券名称字段
|
||||
private String name;
|
||||
|
||||
// 优惠券描述
|
||||
private String description;
|
||||
|
||||
// 倒计时字段(仅用于展示)
|
||||
private String countdown;
|
||||
|
||||
// 广播字段,仅用于展示
|
||||
private String broadcast;
|
||||
|
||||
/**
|
||||
* 优惠券类别,0:普通优惠券;1:第一次推送;2:第二次;3:第三次
|
||||
*/
|
||||
private Integer type;
|
||||
/**
|
||||
* 价格配置ID,逗号分隔字符串
|
||||
*/
|
||||
private String configIds;
|
||||
/**
|
||||
* 0降价,1打折
|
||||
*/
|
||||
private Integer discountType;
|
||||
private BigDecimal discountPrice;
|
||||
/**
|
||||
* 状态:0不开启;1开启
|
||||
*/
|
||||
private Integer status;
|
||||
private Date createAt;
|
||||
|
||||
private Integer deleted;
|
||||
private Date deletedAt;
|
||||
|
||||
public BigDecimal calculateDiscountPrice(BigDecimal originalPrice) {
|
||||
if (originalPrice == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
if (discountType == 0) {
|
||||
return discountPrice;
|
||||
} else {
|
||||
return originalPrice.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_DOWN).multiply(discountPrice);
|
||||
}
|
||||
}
|
||||
public BigDecimal calculateDiscountPrice(String originalPrice) {
|
||||
if (originalPrice == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
BigDecimal priceObj = new BigDecimal(originalPrice);
|
||||
if (discountType == 0) {
|
||||
return discountPrice;
|
||||
} else {
|
||||
return priceObj.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_DOWN).multiply(discountPrice);
|
||||
}
|
||||
}
|
||||
public String calculateDiscountedPrice(String originalPrice) {
|
||||
if (originalPrice == null) {
|
||||
return "0.00";
|
||||
}
|
||||
BigDecimal priceObj = new BigDecimal(originalPrice);
|
||||
if (discountType == 0) {
|
||||
return priceObj.subtract(discountPrice).setScale(2, RoundingMode.HALF_DOWN).toString();
|
||||
} else {
|
||||
return priceObj.subtract(priceObj.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_DOWN).multiply(discountPrice)).setScale(2, RoundingMode.HALF_DOWN).toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.coupon.req;
|
||||
|
||||
import lombok.Data;
|
||||
import com.ycwl.basic.model.common.BaseQueryParameterReq;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
// 优惠券查询请求参数
|
||||
public class CouponQueryReq extends BaseQueryParameterReq {
|
||||
// 景区ID
|
||||
private Long scenicId;
|
||||
private String name;
|
||||
|
||||
// 优惠券类型:0普通/1首次推送/2二次/3三次
|
||||
private Integer type;
|
||||
|
||||
// 折扣类型:0降价/1打折
|
||||
private Integer discountType;
|
||||
|
||||
// 状态:0关闭/1开启
|
||||
private Integer status;
|
||||
|
||||
// 创建时间起始
|
||||
private Date createAtStart;
|
||||
|
||||
// 创建时间结束
|
||||
private Date createAtEnd;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.coupon.resp;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class CouponRespVO {
|
||||
private Integer id;
|
||||
private Long scenicId;
|
||||
private String scenicName;
|
||||
|
||||
// 新增优惠券名称字段
|
||||
private String name;
|
||||
|
||||
// 优惠券描述
|
||||
private String description;
|
||||
|
||||
// 倒计时字段(仅用于展示)
|
||||
private String countdown;
|
||||
|
||||
// 通知展示字段,仅用于展示
|
||||
private String broadcast;
|
||||
|
||||
/**
|
||||
* 优惠券类别,0:普通优惠券;1:第一次推送;2:第二次;3:第三次
|
||||
*/
|
||||
private Integer type;
|
||||
/**
|
||||
* 价格配置ID,逗号分隔字符串
|
||||
*/
|
||||
private String configIds;
|
||||
/**
|
||||
* 0降价,1打折
|
||||
*/
|
||||
private Integer discountType;
|
||||
private BigDecimal discountPrice;
|
||||
/**
|
||||
* 状态:0不开启;1开启
|
||||
*/
|
||||
private Integer status;
|
||||
private Date createAt;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.couponRecord.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("coupon_record")
|
||||
public class CouponRecordEntity {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Integer id;
|
||||
private Integer couponId;
|
||||
private Long memberId;
|
||||
private Long faceId;
|
||||
private Integer status;
|
||||
private Date createTime;
|
||||
private Date usedTime;
|
||||
private Long usedOrderId;
|
||||
|
||||
private Integer deleted;
|
||||
private Date deletedAt;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.couponRecord.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CouponRecordPageQueryReq {
|
||||
private Integer pageNum = 1;
|
||||
private Integer pageSize = 10;
|
||||
private Long scenicId;
|
||||
private String couponName;
|
||||
private Integer couponType;
|
||||
private Integer status;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.couponRecord.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CouponRecordUserQueryReq {
|
||||
private Long scenicId;
|
||||
private Long memberId;
|
||||
private Long faceId;
|
||||
private Integer couponType;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.couponRecord.resp;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class CouponRecordPageResp {
|
||||
private Integer id;
|
||||
private Integer couponId;
|
||||
private String couponName;
|
||||
private Integer couponType;
|
||||
private String couponTypeName;
|
||||
private Long scenicId;
|
||||
private String scenicName;
|
||||
private Long memberId;
|
||||
private Long faceId;
|
||||
private Integer status;
|
||||
private String statusName;
|
||||
private Date createTime;
|
||||
private Date usedTime;
|
||||
private Long usedOrderId;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.ycwl.basic.model.pc.couponRecord.resp;
|
||||
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class CouponRecordQueryResp {
|
||||
private boolean exist = false;
|
||||
private Integer id;
|
||||
private Long scenicId;
|
||||
private Integer couponId;
|
||||
private Long memberId;
|
||||
private Long faceId;
|
||||
private Integer status;
|
||||
private Date createTime;
|
||||
private Date usedTime;
|
||||
private Long usedOrderId;
|
||||
private CouponEntity coupon;
|
||||
|
||||
public boolean isUsable() {
|
||||
return Integer.valueOf(0).equals(status);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ycwl.basic.model.pc.notify.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
@@ -64,6 +65,13 @@ public class WechatSubscribeTemplateConfigEntity {
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 去重窗口(秒),0表示永久去重,小于0表示不去重,大于0表示窗口期去重
|
||||
* 来自 wechat_subscribe_event_template 表,仅在事件触发时有效
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private Integer dedupSeconds;
|
||||
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
|
||||
@@ -10,6 +10,11 @@ import java.math.BigDecimal;
|
||||
@Data
|
||||
public class RefundRequest {
|
||||
|
||||
/**
|
||||
* 退款单号(幂等键,必填)
|
||||
*/
|
||||
private String refundNo;
|
||||
|
||||
/**
|
||||
* 订单ID
|
||||
*/
|
||||
|
||||
@@ -102,9 +102,11 @@ public interface IOrderService {
|
||||
*
|
||||
* @param orderId 订单ID
|
||||
* @param refundStatus 退款状态
|
||||
* @param refundNo 退款单号(幂等键)
|
||||
* @param paymentRefundId 支付平台退款单号
|
||||
* @return 更新结果
|
||||
*/
|
||||
boolean updateRefundStatus(Long orderId, RefundStatus refundStatus);
|
||||
boolean updateRefundStatus(Long orderId, RefundStatus refundStatus, String refundNo, String paymentRefundId);
|
||||
|
||||
/**
|
||||
* 创建退款记录
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.service.pc.ScenicService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -264,18 +265,29 @@ public class OrderServiceImpl implements IOrderService {
|
||||
|
||||
@Override
|
||||
public boolean updateOrderStatus(Long orderId, String orderStatus, String paymentStatus) {
|
||||
OrderV2 order = new OrderV2();
|
||||
order.setId(orderId);
|
||||
order.setOrderStatus(OrderStatus.fromCode(orderStatus));
|
||||
order.setPaymentStatus(PaymentStatus.fromCode(paymentStatus));
|
||||
order.setUpdateTime(new Date());
|
||||
|
||||
if ("PAID".equals(paymentStatus)) {
|
||||
order.setPayTime(new Date());
|
||||
OrderV2 currentOrder = orderV2Mapper.selectById(orderId);
|
||||
if (currentOrder == null) {
|
||||
log.warn("订单不存在: orderId={}", orderId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ("COMPLETED".equals(orderStatus)) {
|
||||
order.setCompleteTime(new Date());
|
||||
OrderStatus targetOrderStatus = OrderStatus.fromCode(orderStatus);
|
||||
PaymentStatus targetPaymentStatus = PaymentStatus.fromCode(paymentStatus);
|
||||
validateOrderStatusTransition(currentOrder, targetOrderStatus, targetPaymentStatus);
|
||||
|
||||
if (currentOrder.getOrderStatus() == targetOrderStatus
|
||||
&& currentOrder.getPaymentStatus() == targetPaymentStatus) {
|
||||
return true;
|
||||
}
|
||||
|
||||
OrderV2 order = new OrderV2();
|
||||
order.setId(orderId);
|
||||
order.setOrderStatus(targetOrderStatus);
|
||||
order.setPaymentStatus(targetPaymentStatus);
|
||||
order.setUpdateTime(new Date());
|
||||
|
||||
if (targetPaymentStatus == PaymentStatus.PAID && currentOrder.getPayTime() == null) {
|
||||
order.setPayTime(new Date());
|
||||
}
|
||||
|
||||
return orderV2Mapper.updateById(order) > 0;
|
||||
@@ -355,28 +367,30 @@ public class OrderServiceImpl implements IOrderService {
|
||||
return false;
|
||||
}
|
||||
|
||||
PaymentStatus oldPaymentStatus = currentOrder.getPaymentStatus();
|
||||
PaymentStatus newPaymentStatus = PaymentStatus.fromCode(paymentStatus);
|
||||
PaymentStatus targetPaymentStatus = PaymentStatus.fromCode(paymentStatus);
|
||||
validatePaymentStatusTransition(currentOrder, targetPaymentStatus);
|
||||
|
||||
PaymentStatus oldPaymentStatus = currentOrder.getPaymentStatus();
|
||||
if (oldPaymentStatus == targetPaymentStatus) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 更新订单支付状态
|
||||
OrderV2 order = new OrderV2();
|
||||
order.setId(orderId);
|
||||
order.setPaymentStatus(newPaymentStatus);
|
||||
order.setPaymentStatus(targetPaymentStatus);
|
||||
order.setOrderStatus(OrderStatus.PAID);
|
||||
order.setUpdateTime(new Date());
|
||||
|
||||
if ("PAID".equals(paymentStatus)) {
|
||||
if (currentOrder.getPayTime() == null) {
|
||||
order.setPayTime(new Date());
|
||||
// 支付完成后,订单状态也需要更新
|
||||
order.setOrderStatus(OrderStatus.PAID);
|
||||
}
|
||||
|
||||
boolean success = orderV2Mapper.updateById(order) > 0;
|
||||
|
||||
// 发布支付状态变更事件
|
||||
if (success && !oldPaymentStatus.equals(newPaymentStatus)) {
|
||||
if (success) {
|
||||
PaymentStatusChangeEvent event = new PaymentStatusChangeEvent(
|
||||
orderId, currentOrder.getOrderNo(),
|
||||
oldPaymentStatus, newPaymentStatus,
|
||||
oldPaymentStatus, targetPaymentStatus,
|
||||
"支付状态更新", null
|
||||
);
|
||||
orderEventManager.publishPaymentStatusChangeEvent(event);
|
||||
@@ -387,36 +401,61 @@ public class OrderServiceImpl implements IOrderService {
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public boolean updateRefundStatus(Long orderId, RefundStatus refundStatus) {
|
||||
// 先查询当前订单状态
|
||||
public boolean updateRefundStatus(Long orderId, RefundStatus refundStatus, String refundNo, String paymentRefundId) {
|
||||
OrderV2 currentOrder = orderV2Mapper.selectById(orderId);
|
||||
if (currentOrder == null) {
|
||||
log.warn("订单不存在: orderId={}", orderId);
|
||||
return false;
|
||||
}
|
||||
|
||||
RefundStatus oldRefundStatus = currentOrder.getRefundStatus();
|
||||
|
||||
// 更新订单退款状态
|
||||
OrderV2 order = new OrderV2();
|
||||
order.setId(orderId);
|
||||
order.setRefundStatus(refundStatus);
|
||||
order.setUpdateTime(new Date());
|
||||
|
||||
// 根据退款状态更新订单状态
|
||||
if (RefundStatus.FULL_REFUND.equals(refundStatus)) {
|
||||
order.setOrderStatus(OrderStatus.REFUNDED);
|
||||
} else if (RefundStatus.REFUND_PROCESSING.equals(refundStatus)) {
|
||||
order.setOrderStatus(OrderStatus.REFUNDING);
|
||||
if (refundStatus == null) {
|
||||
throw new BaseException("退款状态不能为空");
|
||||
}
|
||||
|
||||
boolean success = orderV2Mapper.updateById(order) > 0;
|
||||
validateRefundStatusTransition(currentOrder, refundStatus);
|
||||
|
||||
// 发布退款状态变更事件
|
||||
if (success && !oldRefundStatus.equals(refundStatus)) {
|
||||
OrderRefundV2 refundRecord = resolveRefundRecord(orderId, refundNo);
|
||||
String resolvedRefundNo = refundRecord.getRefundNo();
|
||||
if (StringUtils.isBlank(resolvedRefundNo)) {
|
||||
throw new BaseException("退款单号不能为空");
|
||||
}
|
||||
|
||||
RefundStatus oldRefundStatus = currentOrder.getRefundStatus();
|
||||
boolean refundStatusChanged = oldRefundStatus != refundStatus;
|
||||
boolean paymentRefundIdNeedUpdate = StringUtils.isNotBlank(paymentRefundId)
|
||||
&& StringUtils.isBlank(refundRecord.getPaymentRefundId());
|
||||
|
||||
if (!refundStatusChanged && !paymentRefundIdNeedUpdate
|
||||
&& isOrderRefundStateConsistent(currentOrder, refundStatus)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (refundStatusChanged || paymentRefundIdNeedUpdate) {
|
||||
OrderRefundV2 updateRefund = new OrderRefundV2();
|
||||
updateRefund.setId(refundRecord.getId());
|
||||
updateRefund.setRefundStatus(refundStatus);
|
||||
if (paymentRefundIdNeedUpdate) {
|
||||
updateRefund.setPaymentRefundId(paymentRefundId);
|
||||
}
|
||||
updateRefund.setUpdateTime(new Date());
|
||||
orderRefundMapper.updateById(updateRefund);
|
||||
}
|
||||
|
||||
boolean needUpdateOrder = refundStatusChanged || !isOrderRefundStateConsistent(currentOrder, refundStatus);
|
||||
boolean success = true;
|
||||
if (needUpdateOrder) {
|
||||
OrderV2 order = new OrderV2();
|
||||
order.setId(orderId);
|
||||
order.setRefundStatus(refundStatus);
|
||||
applyOrderRefundState(order, refundStatus);
|
||||
order.setUpdateTime(new Date());
|
||||
success = orderV2Mapper.updateById(order) > 0;
|
||||
}
|
||||
|
||||
if (success && refundStatusChanged) {
|
||||
RefundStatusChangeEvent event = new RefundStatusChangeEvent(
|
||||
orderId, currentOrder.getOrderNo(), null, null,
|
||||
oldRefundStatus, refundStatus, null,
|
||||
orderId, currentOrder.getOrderNo(), refundRecord.getId(), resolvedRefundNo,
|
||||
oldRefundStatus, refundStatus, refundRecord.getRefundAmount(),
|
||||
"退款状态更新", null
|
||||
);
|
||||
orderEventManager.publishRefundStatusChangeEvent(event);
|
||||
@@ -430,17 +469,50 @@ public class OrderServiceImpl implements IOrderService {
|
||||
public Long createRefundRecord(RefundRequest request) {
|
||||
Date now = new Date();
|
||||
|
||||
// 生成退款单号
|
||||
String refundNo = generateRefundNo();
|
||||
if (request == null) {
|
||||
throw new BaseException("退款请求不能为空");
|
||||
}
|
||||
if (request.getOrderId() == null) {
|
||||
throw new BaseException("订单ID不能为空");
|
||||
}
|
||||
if (StringUtils.isBlank(request.getRefundNo())) {
|
||||
throw new BaseException("退款单号不能为空");
|
||||
}
|
||||
if (StringUtils.isBlank(request.getRefundType())) {
|
||||
throw new BaseException("退款类型不能为空");
|
||||
}
|
||||
|
||||
OrderV2 order = orderV2Mapper.selectById(request.getOrderId());
|
||||
if (order == null) {
|
||||
throw new BaseException("订单不存在");
|
||||
}
|
||||
if (order.getOrderStatus() == OrderStatus.CANCELLED || order.getPaymentStatus() == PaymentStatus.UNPAID) {
|
||||
throw new BaseException("未支付或已取消订单不允许退款");
|
||||
}
|
||||
if (order.getRefundStatus() == RefundStatus.FULL_REFUND) {
|
||||
throw new BaseException("订单已完成退款");
|
||||
}
|
||||
|
||||
String refundNo = request.getRefundNo();
|
||||
OrderRefundV2 existingRefund = findRefundByNo(refundNo);
|
||||
if (existingRefund != null) {
|
||||
if (!request.getOrderId().equals(existingRefund.getOrderId())) {
|
||||
throw new BaseException("退款单号已存在");
|
||||
}
|
||||
RefundStatus existingStatus = existingRefund.getRefundStatus() != null
|
||||
? existingRefund.getRefundStatus()
|
||||
: RefundStatus.REFUND_PROCESSING;
|
||||
updateRefundStatus(request.getOrderId(), existingStatus, refundNo, existingRefund.getPaymentRefundId());
|
||||
return existingRefund.getId();
|
||||
}
|
||||
|
||||
// 创建退款记录
|
||||
OrderRefundV2 refund = new OrderRefundV2();
|
||||
refund.setOrderId(request.getOrderId());
|
||||
refund.setRefundNo(refundNo);
|
||||
refund.setRefundType(RefundType.fromCode(request.getRefundType()));
|
||||
refund.setRefundAmount(request.getRefundAmount());
|
||||
refund.setRefundFee(request.getRefundFee());
|
||||
refund.setRefundStatus(com.ycwl.basic.order.enums.RefundStatus.REFUND_PROCESSING);
|
||||
refund.setRefundStatus(RefundStatus.REFUND_PROCESSING);
|
||||
refund.setRefundReason(request.getRefundReason());
|
||||
refund.setRefundDescription(request.getRefundDescription());
|
||||
refund.setOperatorRemarks(request.getOperatorRemarks());
|
||||
@@ -451,8 +523,7 @@ public class OrderServiceImpl implements IOrderService {
|
||||
|
||||
orderRefundMapper.insert(refund);
|
||||
|
||||
// 更新订单退款状态
|
||||
updateRefundStatus(request.getOrderId(), com.ycwl.basic.order.enums.RefundStatus.REFUND_PROCESSING);
|
||||
updateRefundStatus(request.getOrderId(), RefundStatus.REFUND_PROCESSING, refundNo, null);
|
||||
|
||||
log.info("退款记录创建成功: refundId={}, refundNo={}, orderId={}, refundAmount={}",
|
||||
refund.getId(), refundNo, request.getOrderId(), request.getRefundAmount());
|
||||
@@ -468,6 +539,163 @@ public class OrderServiceImpl implements IOrderService {
|
||||
|
||||
// ====== 私有辅助方法 ======
|
||||
|
||||
private void validateOrderStatusTransition(OrderV2 currentOrder, OrderStatus targetOrderStatus,
|
||||
PaymentStatus targetPaymentStatus) {
|
||||
if (currentOrder == null || targetOrderStatus == null || targetPaymentStatus == null) {
|
||||
throw new BaseException("订单状态参数不能为空");
|
||||
}
|
||||
OrderStatus currentOrderStatus = currentOrder.getOrderStatus();
|
||||
PaymentStatus currentPaymentStatus = currentOrder.getPaymentStatus();
|
||||
if (currentOrderStatus == null || currentPaymentStatus == null) {
|
||||
throw new BaseException("订单状态异常");
|
||||
}
|
||||
if (currentOrderStatus == targetOrderStatus && currentPaymentStatus == targetPaymentStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean allowed = false;
|
||||
if (currentOrderStatus == OrderStatus.PENDING_PAYMENT) {
|
||||
allowed = (targetOrderStatus == OrderStatus.PAID && targetPaymentStatus == PaymentStatus.PAID)
|
||||
|| (targetOrderStatus == OrderStatus.CANCELLED && targetPaymentStatus == PaymentStatus.UNPAID);
|
||||
} else if (currentOrderStatus == OrderStatus.PAID) {
|
||||
allowed = (targetOrderStatus == OrderStatus.REFUNDING && targetPaymentStatus == PaymentStatus.PAID)
|
||||
|| (targetOrderStatus == OrderStatus.REFUNDED && targetPaymentStatus == PaymentStatus.REFUNDED);
|
||||
} else if (currentOrderStatus == OrderStatus.REFUNDING) {
|
||||
allowed = targetOrderStatus == OrderStatus.REFUNDED && targetPaymentStatus == PaymentStatus.REFUNDED;
|
||||
} else if (currentOrderStatus == OrderStatus.CANCELLED) {
|
||||
allowed = targetOrderStatus == OrderStatus.CANCELLED && targetPaymentStatus == PaymentStatus.UNPAID;
|
||||
} else if (currentOrderStatus == OrderStatus.REFUNDED) {
|
||||
allowed = targetOrderStatus == OrderStatus.REFUNDED && targetPaymentStatus == PaymentStatus.REFUNDED;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
throw new BaseException("订单状态流转非法");
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePaymentStatusTransition(OrderV2 currentOrder, PaymentStatus targetPaymentStatus) {
|
||||
if (currentOrder == null || targetPaymentStatus == null) {
|
||||
throw new BaseException("支付状态参数不能为空");
|
||||
}
|
||||
if (currentOrder.getOrderStatus() == null || currentOrder.getPaymentStatus() == null) {
|
||||
throw new BaseException("订单状态异常");
|
||||
}
|
||||
if (targetPaymentStatus != PaymentStatus.PAID) {
|
||||
throw new BaseException("支付状态仅允许更新为已支付");
|
||||
}
|
||||
if (currentOrder.getOrderStatus() == OrderStatus.CANCELLED) {
|
||||
throw new BaseException("已取消订单不允许支付");
|
||||
}
|
||||
if (currentOrder.getPaymentStatus() == PaymentStatus.PAID) {
|
||||
if (currentOrder.getOrderStatus() == OrderStatus.REFUNDED) {
|
||||
throw new BaseException("订单状态不允许支付");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentOrder.getPaymentStatus() != PaymentStatus.UNPAID
|
||||
|| currentOrder.getOrderStatus() != OrderStatus.PENDING_PAYMENT) {
|
||||
throw new BaseException("订单状态不允许支付");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRefundStatusTransition(OrderV2 currentOrder, RefundStatus targetRefundStatus) {
|
||||
if (currentOrder == null || targetRefundStatus == null) {
|
||||
throw new BaseException("退款状态参数不能为空");
|
||||
}
|
||||
if (currentOrder.getOrderStatus() == null || currentOrder.getPaymentStatus() == null) {
|
||||
throw new BaseException("订单状态异常");
|
||||
}
|
||||
if (currentOrder.getOrderStatus() == OrderStatus.CANCELLED) {
|
||||
throw new BaseException("已取消订单不允许退款");
|
||||
}
|
||||
if (currentOrder.getPaymentStatus() == PaymentStatus.UNPAID) {
|
||||
throw new BaseException("未支付订单不允许退款");
|
||||
}
|
||||
|
||||
RefundStatus currentRefundStatus = currentOrder.getRefundStatus();
|
||||
if (currentRefundStatus == null) {
|
||||
throw new BaseException("订单退款状态异常");
|
||||
}
|
||||
if (currentRefundStatus == targetRefundStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean allowed = false;
|
||||
if (currentRefundStatus == RefundStatus.NO_REFUND) {
|
||||
allowed = targetRefundStatus == RefundStatus.REFUND_PROCESSING
|
||||
|| targetRefundStatus == RefundStatus.PARTIAL_REFUND
|
||||
|| targetRefundStatus == RefundStatus.FULL_REFUND;
|
||||
} else if (currentRefundStatus == RefundStatus.REFUND_PROCESSING) {
|
||||
allowed = targetRefundStatus == RefundStatus.PARTIAL_REFUND
|
||||
|| targetRefundStatus == RefundStatus.FULL_REFUND;
|
||||
} else if (currentRefundStatus == RefundStatus.PARTIAL_REFUND) {
|
||||
allowed = targetRefundStatus == RefundStatus.FULL_REFUND;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
throw new BaseException("退款状态流转非法");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isOrderRefundStateConsistent(OrderV2 order, RefundStatus refundStatus) {
|
||||
if (order == null || refundStatus == null) {
|
||||
return false;
|
||||
}
|
||||
if (refundStatus == RefundStatus.REFUND_PROCESSING || refundStatus == RefundStatus.PARTIAL_REFUND) {
|
||||
return order.getOrderStatus() == OrderStatus.REFUNDING
|
||||
&& order.getPaymentStatus() == PaymentStatus.PAID;
|
||||
}
|
||||
if (refundStatus == RefundStatus.FULL_REFUND) {
|
||||
return order.getOrderStatus() == OrderStatus.REFUNDED
|
||||
&& order.getPaymentStatus() == PaymentStatus.REFUNDED;
|
||||
}
|
||||
return order.getRefundStatus() == refundStatus;
|
||||
}
|
||||
|
||||
private void applyOrderRefundState(OrderV2 order, RefundStatus refundStatus) {
|
||||
if (order == null || refundStatus == null) {
|
||||
return;
|
||||
}
|
||||
if (refundStatus == RefundStatus.REFUND_PROCESSING || refundStatus == RefundStatus.PARTIAL_REFUND) {
|
||||
order.setOrderStatus(OrderStatus.REFUNDING);
|
||||
order.setPaymentStatus(PaymentStatus.PAID);
|
||||
} else if (refundStatus == RefundStatus.FULL_REFUND) {
|
||||
order.setOrderStatus(OrderStatus.REFUNDED);
|
||||
order.setPaymentStatus(PaymentStatus.REFUNDED);
|
||||
}
|
||||
}
|
||||
|
||||
private OrderRefundV2 resolveRefundRecord(Long orderId, String refundNo) {
|
||||
if (StringUtils.isNotBlank(refundNo)) {
|
||||
OrderRefundV2 refundRecord = findRefundByNo(refundNo);
|
||||
if (refundRecord == null) {
|
||||
throw new BaseException("退款单不存在");
|
||||
}
|
||||
if (!orderId.equals(refundRecord.getOrderId())) {
|
||||
throw new BaseException("退款单不存在");
|
||||
}
|
||||
return refundRecord;
|
||||
}
|
||||
|
||||
QueryWrapper<OrderRefundV2> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("order_id", orderId).orderByDesc("create_time");
|
||||
List<OrderRefundV2> refunds = orderRefundMapper.selectList(queryWrapper);
|
||||
if (refunds.isEmpty()) {
|
||||
throw new BaseException("退款单不存在");
|
||||
}
|
||||
log.warn("退款单号缺失,使用最新退款记录: orderId={}", orderId);
|
||||
return refunds.get(0);
|
||||
}
|
||||
|
||||
private OrderRefundV2 findRefundByNo(String refundNo) {
|
||||
if (StringUtils.isBlank(refundNo)) {
|
||||
return null;
|
||||
}
|
||||
QueryWrapper<OrderRefundV2> queryWrapper = new QueryWrapper<>();
|
||||
queryWrapper.eq("refund_no", refundNo);
|
||||
return orderRefundMapper.selectOne(queryWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单查询条件
|
||||
*/
|
||||
@@ -624,8 +852,9 @@ public class OrderServiceImpl implements IOrderService {
|
||||
}
|
||||
|
||||
// 3. 验证订单状态
|
||||
if (order.getPaymentStatus() != PaymentStatus.UNPAID) {
|
||||
throw new RuntimeException("订单状态不允许支付");
|
||||
if (order.getOrderStatus() != OrderStatus.PENDING_PAYMENT
|
||||
|| order.getPaymentStatus() != PaymentStatus.UNPAID) {
|
||||
throw new BaseException("订单状态不允许支付");
|
||||
}
|
||||
|
||||
// 4. 获取用户openId(从订单中获取)
|
||||
@@ -713,31 +942,15 @@ public class OrderServiceImpl implements IOrderService {
|
||||
if (callbackResponse.isPay()) {
|
||||
// 支付成功
|
||||
statusChangeType = "PAID";
|
||||
updatePaymentStatus(order.getId(), PaymentStatus.PAID.name());
|
||||
|
||||
// 触发支付成功事件
|
||||
orderEventManager.publishPaymentStatusChangeEvent(
|
||||
new PaymentStatusChangeEvent(order.getId(), order.getOrderNo(),
|
||||
PaymentStatus.UNPAID, PaymentStatus.PAID, "支付回调成功", null)
|
||||
);
|
||||
|
||||
updatePaymentStatus(order.getId(), PaymentStatus.PAID.getCode());
|
||||
} else if (callbackResponse.isCancel()) {
|
||||
// 支付取消 - 这种情况下支付状态保持未支付,不需要特别处理
|
||||
// 支付取消
|
||||
statusChangeType = "CANCELLED";
|
||||
log.info("支付被取消,支付状态保持未支付: orderId={}", order.getId());
|
||||
|
||||
updateOrderStatus(order.getId(), OrderStatus.CANCELLED.getCode(), PaymentStatus.UNPAID.getCode());
|
||||
} else if (callbackResponse.isRefund()) {
|
||||
// 退款
|
||||
statusChangeType = "REFUNDED";
|
||||
updatePaymentStatus(order.getId(), PaymentStatus.REFUNDED.name());
|
||||
updateRefundStatus(order.getId(), RefundStatus.FULL_REFUND);
|
||||
|
||||
// 触发退款事件
|
||||
orderEventManager.publishRefundStatusChangeEvent(
|
||||
new RefundStatusChangeEvent(order.getId(), order.getOrderNo(), null, null,
|
||||
RefundStatus.NO_REFUND, RefundStatus.FULL_REFUND,
|
||||
order.getFinalAmount(), "支付回调退款", null)
|
||||
);
|
||||
updateRefundStatus(order.getId(), RefundStatus.FULL_REFUND, null, callbackResponse.getTransactionId());
|
||||
}
|
||||
|
||||
log.info("支付回调处理成功: orderId={}, orderNo={}, statusChangeType={}",
|
||||
|
||||
@@ -82,6 +82,7 @@ com.ycwl.basic.pricing/
|
||||
#### API端点
|
||||
- `POST /api/pricing/calculate` — 执行价格计算(预览模式默认开启)
|
||||
- `GET /api/pricing/coupons/my-coupons` — 查询用户可用优惠券
|
||||
- `POST /api/pricing/upgrade-check` — 升单检测:综合已购与待购商品,判断是否命中一口价或打包优惠
|
||||
|
||||
#### 计算流程
|
||||
```java
|
||||
@@ -588,22 +589,147 @@ public class PriceCalculationResult {
|
||||
- `GET /api/pricing/admin/one-price/scenic/{scenicId}` — 按景区查询启用配置
|
||||
- `GET /api/pricing/admin/one-price/check/{scenicId}` — 景区是否适用一口价
|
||||
|
||||
## 升单检测功能 (Upgrade Detection)
|
||||
|
||||
### 1. 功能概述
|
||||
|
||||
升单检测功能是最新新增的功能,用于综合已购商品与待购商品,判断是否满足一口价或打包购买优惠条件,为用户提供购买建议。
|
||||
|
||||
### 2. 核心接口
|
||||
|
||||
#### IPriceCalculationService 升单检测方法
|
||||
```java
|
||||
/**
|
||||
* 升单检测:综合已购与待购商品,判断是否命中一口价或打包优惠
|
||||
* @param request 升单检测请求
|
||||
* @return 升单检测结果
|
||||
*/
|
||||
UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request);
|
||||
```
|
||||
|
||||
#### API 端点
|
||||
- `POST /api/pricing/upgrade-check` — 升单检测接口
|
||||
|
||||
### 3. 检测逻辑
|
||||
|
||||
#### 请求参数 (UpgradeCheckRequest)
|
||||
- `scenicId`: 景区ID
|
||||
- `purchasedProducts`: 已购商品列表
|
||||
- `intendingProducts`: 待购商品列表
|
||||
- `paidAmount`: 已支付金额(内部代码传入,前端不必传;**必填**,为空直接报错)
|
||||
|
||||
#### 检测流程
|
||||
1. **商品规范化**: 对已购和待购商品进行规范化处理
|
||||
2. **价格汇总**: 分别计算已购和待购商品的总价格,并合并已支付金额
|
||||
3. **一口价评估**: 判断合并商品是否满足一口价条件
|
||||
4. **打包优惠评估**: 检测是否满足打包购买优惠条件
|
||||
5. **结果汇总**: 生成升单检测结果和建议(补差价 <= 0 视为不可升单)
|
||||
|
||||
#### 响应结果 (UpgradeCheckResult)
|
||||
- `summary`: 价格汇总信息(包含已支付金额)
|
||||
- `onePriceResult`: 一口价检测结果(含补差价金额)
|
||||
- `bundleDiscountResult`: 打包优惠检测结果(含补差价金额)
|
||||
- `bestUpgradeType`: 最优升单类型(ONE_PRICE / BUNDLE_DISCOUNT)
|
||||
- `bestPayableAmount`: 最低补差价金额
|
||||
|
||||
### 4. 业务价值
|
||||
|
||||
#### 用户体验提升
|
||||
- 为用户提供购买建议,提高客单价
|
||||
- 自动检测最优购买组合
|
||||
- 清晰展示升单优惠金额
|
||||
|
||||
#### 销售支持
|
||||
- 促进多商品销售
|
||||
- 提高打包购买和一口价利用率
|
||||
- 增加用户购买决策信心
|
||||
|
||||
### 5. 使用场景
|
||||
|
||||
#### 典型场景
|
||||
- 用户已购买照片,建议加购视频享受打包优惠
|
||||
- 用户购买多件商品,建议升级为一口价套餐
|
||||
- 用户购买数量接近打包优惠门槛,建议增加数量
|
||||
|
||||
#### 实现细节
|
||||
```java
|
||||
// 升单检测核心逻辑
|
||||
@Override
|
||||
public UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request) {
|
||||
// 1. 参数验证
|
||||
if (request == null) {
|
||||
throw new PriceCalculationException("升单检测请求不能为空");
|
||||
}
|
||||
|
||||
// 2. 商品规范化
|
||||
List<ProductItem> purchased = normalizeProducts(request.getPurchasedProducts());
|
||||
List<ProductItem> intending = normalizeProducts(request.getIntendingProducts());
|
||||
|
||||
// 3. 合并商品列表
|
||||
List<ProductItem> allProducts = new ArrayList<>();
|
||||
allProducts.addAll(purchased);
|
||||
allProducts.addAll(intending);
|
||||
|
||||
// 4. 价格计算
|
||||
PriceDetails purchasedPrice = calculateProductsPriceWithOriginal(purchased);
|
||||
PriceDetails intendingPrice = calculateProductsPriceWithOriginal(intending);
|
||||
PriceDetails totalPrice = calculateProductsPrice(allProducts);
|
||||
|
||||
// 5. 优惠评估
|
||||
UpgradeOnePriceResult onePriceResult = evaluateOnePrice(request.getScenicId(), allProducts, totalPrice);
|
||||
UpgradeBundleDiscountResult bundleResult = evaluateBundleDiscount(request.getScenicId(), allProducts, totalPrice);
|
||||
|
||||
// 6. 结果汇总
|
||||
return buildUpgradeResult(purchasedPrice, intendingPrice, onePriceResult, bundleResult);
|
||||
}
|
||||
```
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 1. 单元测试
|
||||
建议覆盖:
|
||||
- 价格计算核心流程与边界
|
||||
- 优惠券/券码/一口价适用性与叠加规则
|
||||
- 异常场景与异常处理器
|
||||
### 单元测试类型
|
||||
- **服务层测试**:每个服务类都有对应测试类
|
||||
- `PriceBundleServiceTest` - 套餐价格计算测试
|
||||
- `ReusableVoucherServiceTest` - 可重复使用券码测试
|
||||
- `VoucherTimeRangeTest` - 券码时间范围功能测试
|
||||
- `VoucherPrintServiceCodeGenerationTest` - 券码生成测试
|
||||
- **实体映射测试**:验证数据库映射和JSON序列化
|
||||
- `PriceBundleConfigStructureTest` - 实体结构测试
|
||||
- `PriceBundleConfigJsonTest` - JSON序列化测试
|
||||
- `CouponSwitchFieldsMappingTest` - 字段映射测试
|
||||
- **类型处理器测试**:验证自定义TypeHandler
|
||||
- `BundleProductListTypeHandlerTest` - 套餐商品列表序列化测试
|
||||
- **配置验证测试**:验证系统配置完整性
|
||||
- `DefaultConfigValidationTest` - 验证所有ProductType的default配置
|
||||
- `CodeGenerationStandaloneTest` - 独立代码生成测试
|
||||
|
||||
### 2. 集成测试
|
||||
- 数据库读写与分页
|
||||
- JSON 序列化/反序列化(TypeHandler)
|
||||
- API 端点的入参/出参校验
|
||||
### 测试执行命令
|
||||
```bash
|
||||
# 运行单个测试类
|
||||
mvn test -Dtest=VoucherTimeRangeTest
|
||||
mvn test -Dtest=ReusableVoucherServiceTest
|
||||
mvn test -Dtest=BundleProductListTypeHandlerTest
|
||||
|
||||
### 3. 配置校验
|
||||
- 校验各 ProductType 的默认配置完整性
|
||||
- 关键枚举与配置代码路径的兼容性
|
||||
# 运行整个pricing模块测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.*Test"
|
||||
|
||||
# 运行特定分类的测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.service.*Test" # 服务层测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.handler.*Test" # TypeHandler测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.entity.*Test" # 实体测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.mapper.*Test" # Mapper测试
|
||||
|
||||
# 运行带详细报告的测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.*Test" -Dsurefire.printSummary=true
|
||||
```
|
||||
|
||||
### 重点测试场景
|
||||
- **价格计算核心流程**:验证统一优惠检测和组合逻辑
|
||||
- **可重复使用券码**:验证多次使用、时间间隔、用户限制逻辑
|
||||
- **时间范围控制**:验证券码有效期开始和结束时间
|
||||
- **优惠叠加规则**:验证券码、优惠券、一口价的叠加逻辑
|
||||
- **JSON序列化**:验证复杂对象在数据库中的存储和读取
|
||||
- **分页功能**:验证PageHelper和MyBatis-Plus分页集成
|
||||
- **异常处理**:验证业务异常和全局异常处理器
|
||||
|
||||
## 数据库设计
|
||||
|
||||
@@ -665,11 +791,20 @@ CREATE INDEX idx_print_face_scenic ON voucher_print_record(face_id, scenic_id);
|
||||
- 使用数据完整性检查 SQL 验证统计数据准确性
|
||||
- **优惠券领取记录表查询优化** (v1.0.0): 为 `(user_id, coupon_id)` 添加复合索引以加速用户领取次数统计
|
||||
|
||||
### 关键架构变更
|
||||
|
||||
#### 最近重要更新 (2025-09-18)
|
||||
1. **新增升单检测功能** - 添加了`/api/pricing/upgrade-check`接口,支持已购和待购商品的优惠组合检测
|
||||
2. **新增打包购买优惠功能** - 实现了多商品组合优惠策略,优先级100(仅次于一口价)
|
||||
3. **优惠优先级调整** - 确立了"一口价 > 打包购买 > 券码 > 优惠券"的优先级顺序
|
||||
4. **PrinterServiceImpl重构** - 移除对PriceRepository的依赖,统一使用IPriceCalculationService
|
||||
|
||||
## 兼容性与注意事项
|
||||
|
||||
- 本模块使用 PageHelper(优惠券相关)与 MyBatis‑Plus(券码/一口价等)并存,请根据对应 Service/Mapper 选择分页与查询方式。
|
||||
- 优惠优先级及叠加规则以各 Provider 与业务配置为准,避免在外层重复实现优先级判断逻辑。
|
||||
- 若扩展新的优惠类型,务必实现 `IDiscountProvider` 并在 `IDiscountDetectionService` 中完成注册(当前实现通过组件扫描自动注册并排序)。
|
||||
- 升单检测功能依赖完整的价格计算和优惠检测服务,确保相关依赖正常注入。
|
||||
- **优惠券数量管理** (v1.0.0): 现有代码已调整为领取时更新 `claimedQuantity`,使用时更新 `usedQuantity`。如业务需求不同,请调整 `CouponServiceImpl.claimCoupon()` 和 `CouponServiceImpl.useCoupon()` 逻辑。
|
||||
|
||||
## 版本更新记录
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.ycwl.basic.pricing.controller;
|
||||
|
||||
import com.ycwl.basic.constant.BaseContextHandler;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.pricing.dto.*;
|
||||
import com.ycwl.basic.pricing.dto.resp.UserCouponResp;
|
||||
import com.ycwl.basic.pricing.service.ICouponService;
|
||||
import com.ycwl.basic.pricing.service.IPriceCalculationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -39,16 +41,47 @@ public class PriceCalculationController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户可用优惠券
|
||||
* 升单检测:判断是否命中一口价或打包优惠
|
||||
*/
|
||||
@PostMapping("/upgrade-check")
|
||||
public ApiResponse<UpgradeCheckResult> upgradeCheck(@RequestBody UpgradeCheckRequest request) {
|
||||
log.info("升单检测请求: scenicId={}, purchased={}, intending={}",
|
||||
request.getScenicId(),
|
||||
request.getPurchasedProducts() != null ? request.getPurchasedProducts().size() : 0,
|
||||
request.getIntendingProducts() != null ? request.getIntendingProducts().size() : 0);
|
||||
|
||||
UpgradeCheckResult result = priceCalculationService.checkUpgrade(request);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户可用优惠券(包含领取记录信息)
|
||||
*/
|
||||
@GetMapping("/coupons/my-coupons")
|
||||
public ApiResponse<List<CouponInfo>> getUserCoupons(@RequestParam Long userId) {
|
||||
log.info("查询用户可用优惠券: userId={}", userId);
|
||||
public ApiResponse<List<UserCouponResp>> getUserCoupons() {
|
||||
Long userId = getUserId();
|
||||
if (userId == null) {
|
||||
return ApiResponse.fail("用户未登录");
|
||||
}
|
||||
|
||||
List<CouponInfo> coupons = couponService.getUserAvailableCoupons(userId);
|
||||
|
||||
log.info("用户可用优惠券数量: {}", coupons.size());
|
||||
List<UserCouponResp> coupons = couponService.getUserAvailableCoupons(userId);
|
||||
|
||||
return ApiResponse.success(coupons);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户ID
|
||||
*/
|
||||
private Long getUserId() {
|
||||
try {
|
||||
String userIdStr = BaseContextHandler.getUserId();
|
||||
if (userIdStr == null || userIdStr.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return Long.valueOf(userIdStr);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("无法解析用户ID: {}", BaseContextHandler.getUserId());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,13 +60,7 @@ public class SceneCouponClaimController {
|
||||
|
||||
List<CouponClaimResult> results = sceneCouponService.claimCoupons(req, userId);
|
||||
|
||||
// 判断整体结果
|
||||
boolean hasSuccess = results.stream().anyMatch(CouponClaimResult::isSuccess);
|
||||
if (!hasSuccess && !results.isEmpty()) {
|
||||
// 全部失败,返回第一个错误信息
|
||||
return ApiResponse.fail(results.get(0).getErrorMessage());
|
||||
}
|
||||
|
||||
// 无论成功或失败,都返回完整结果列表(包含已领取的券信息)
|
||||
return ApiResponse.success(results);
|
||||
} catch (Exception e) {
|
||||
log.error("场景优惠券|领取失败 req={}", req, e);
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.ycwl.basic.pricing.entity.PriceCouponConfig;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 优惠券领取结果DTO
|
||||
@@ -58,6 +59,11 @@ public class CouponClaimResult {
|
||||
private String scenicId;
|
||||
private PriceCouponConfig coupon;
|
||||
|
||||
/**
|
||||
* 已领取的记录列表(领取失败时返回,帮助前端展示用户已有的券)
|
||||
*/
|
||||
private List<PriceCouponClaimRecord> claimedRecords;
|
||||
|
||||
/**
|
||||
* 创建成功结果
|
||||
*/
|
||||
@@ -85,6 +91,23 @@ public class CouponClaimResult {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败结果(带已领取的券列表)
|
||||
*/
|
||||
public static CouponClaimResult failureWithClaimedRecords(String errorCode, String errorMessage,
|
||||
List<PriceCouponClaimRecord> claimedRecords,
|
||||
PriceCouponConfig coupon) {
|
||||
CouponClaimResult result = new CouponClaimResult();
|
||||
result.success = false;
|
||||
result.errorCode = errorCode;
|
||||
result.errorMessage = errorMessage;
|
||||
result.claimedRecords = claimedRecords;
|
||||
result.coupon = coupon;
|
||||
result.couponId = coupon != null ? coupon.getId() : null;
|
||||
result.couponName = coupon != null ? coupon.getCouponName() : null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败结果(仅错误消息)
|
||||
*/
|
||||
|
||||
@@ -55,4 +55,15 @@ public class PriceCalculationResult {
|
||||
* 商品明细列表
|
||||
*/
|
||||
private List<ProductItem> productDetails;
|
||||
|
||||
/**
|
||||
* 是否已购买标识(结合商品重复购买策略判断)
|
||||
* true: 至少有一个不允许重复购买的商品(DuplicateCheckStrategy != NO_CHECK)的 quantity > 0
|
||||
* false: 所有不允许重复购买的商品的 quantity 都为 0 或 null
|
||||
*
|
||||
* 说明:
|
||||
* - 对于允许重复购买的商品(如打印类,策略为 NO_CHECK),即使 quantity > 0 也不影响此标识
|
||||
* - 对于需要检查的商品(UNIQUE_RESOURCE、PARENT_RESOURCE),quantity > 0 表示已购买
|
||||
*/
|
||||
private Boolean isPurchased;
|
||||
}
|
||||
@@ -56,4 +56,12 @@ public class ProductItem {
|
||||
* 商品属性Key列表(服务端计算填充,客户端传入会被忽略)
|
||||
*/
|
||||
private List<String> attributeKeys;
|
||||
|
||||
/**
|
||||
* 是否已购买(服务端填充)
|
||||
* 结合 DuplicateCheckStrategy 判断:
|
||||
* - NO_CHECK: 始终为 false(允许重复购买)
|
||||
* - 其他策略: 基于用户已有资源判断
|
||||
*/
|
||||
private Boolean hasPurchased;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ycwl.basic.pricing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 升单检测打包优惠结果
|
||||
*/
|
||||
@Data
|
||||
public class UpgradeBundleDiscountResult {
|
||||
|
||||
/**
|
||||
* 是否命中打包优惠
|
||||
*/
|
||||
private boolean applicable;
|
||||
|
||||
/**
|
||||
* 打包配置ID
|
||||
*/
|
||||
private Long bundleConfigId;
|
||||
|
||||
/**
|
||||
* 打包优惠名称
|
||||
*/
|
||||
private String bundleName;
|
||||
|
||||
/**
|
||||
* 打包优惠描述
|
||||
*/
|
||||
private String bundleDescription;
|
||||
|
||||
/**
|
||||
* 优惠类型
|
||||
*/
|
||||
private String discountType;
|
||||
|
||||
/**
|
||||
* 优惠值
|
||||
*/
|
||||
private BigDecimal discountValue;
|
||||
|
||||
/**
|
||||
* 实际优惠金额
|
||||
*/
|
||||
private BigDecimal discountAmount;
|
||||
|
||||
/**
|
||||
* 满足条件的最少数量
|
||||
*/
|
||||
private Integer minQuantity;
|
||||
|
||||
/**
|
||||
* 满足条件的最少金额
|
||||
*/
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/**
|
||||
* 使用优惠后的补差价金额(已支付金额已扣减)
|
||||
*/
|
||||
private BigDecimal estimatedFinalAmount;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ycwl.basic.pricing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 升单检测请求
|
||||
*/
|
||||
@Data
|
||||
public class UpgradeCheckRequest {
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 用户faceId
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 已购买商品列表
|
||||
*/
|
||||
private List<ProductItem> purchasedProducts;
|
||||
|
||||
/**
|
||||
* 准备购买的商品列表
|
||||
*/
|
||||
private List<ProductItem> intendingProducts;
|
||||
|
||||
/**
|
||||
* 已支付金额(内部代码传入,前端不必传)
|
||||
*/
|
||||
private BigDecimal paidAmount;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ycwl.basic.pricing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 升单检测结果
|
||||
*/
|
||||
@Data
|
||||
public class UpgradeCheckResult {
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 用户faceId
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 价格汇总信息
|
||||
*/
|
||||
private UpgradePriceSummary priceSummary;
|
||||
|
||||
/**
|
||||
* 一口价检测结果
|
||||
*/
|
||||
private UpgradeOnePriceResult onePriceResult;
|
||||
|
||||
/**
|
||||
* 打包优惠检测结果
|
||||
*/
|
||||
private UpgradeBundleDiscountResult bundleDiscountResult;
|
||||
|
||||
/**
|
||||
* 最优升单类型(ONE_PRICE / BUNDLE_DISCOUNT)
|
||||
*/
|
||||
private String bestUpgradeType;
|
||||
|
||||
/**
|
||||
* 最低补差价金额
|
||||
*/
|
||||
private BigDecimal bestPayableAmount;
|
||||
|
||||
/**
|
||||
* 已购买商品明细(带计算价)
|
||||
*/
|
||||
private List<ProductItem> purchasedProducts;
|
||||
|
||||
/**
|
||||
* 计划购买商品明细(带计算价)
|
||||
*/
|
||||
private List<ProductItem> intendingProducts;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ycwl.basic.pricing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 升单检测一口价结果
|
||||
*/
|
||||
@Data
|
||||
public class UpgradeOnePriceResult {
|
||||
|
||||
/**
|
||||
* 是否命中一口价规则
|
||||
*/
|
||||
private boolean applicable;
|
||||
|
||||
/**
|
||||
* 一口价配置ID
|
||||
*/
|
||||
private Long bundleConfigId;
|
||||
|
||||
/**
|
||||
* 一口价名称
|
||||
*/
|
||||
private String bundleName;
|
||||
|
||||
/**
|
||||
* 一口价描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 适用景区ID
|
||||
*/
|
||||
private String scenicId;
|
||||
|
||||
/**
|
||||
* 一口价金额
|
||||
*/
|
||||
private BigDecimal bundlePrice;
|
||||
|
||||
/**
|
||||
* 优惠金额(合并小计 - 一口价金额)
|
||||
*/
|
||||
private BigDecimal discountAmount;
|
||||
|
||||
/**
|
||||
* 使用一口价后的补差价金额(已支付金额已扣减)
|
||||
*/
|
||||
private BigDecimal estimatedFinalAmount;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ycwl.basic.pricing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 升单检测价格汇总
|
||||
*/
|
||||
@Data
|
||||
public class UpgradePriceSummary {
|
||||
|
||||
/**
|
||||
* 已购买原价合计
|
||||
*/
|
||||
private BigDecimal purchasedOriginalAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 已购买小计金额
|
||||
*/
|
||||
private BigDecimal purchasedSubtotalAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 已支付金额(用于升单补差)
|
||||
*/
|
||||
private BigDecimal paidAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 计划购买原价合计
|
||||
*/
|
||||
private BigDecimal intendingOriginalAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 计划购买小计金额
|
||||
*/
|
||||
private BigDecimal intendingSubtotalAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 合并后的原价合计
|
||||
*/
|
||||
private BigDecimal combinedOriginalAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 合并后的小计金额
|
||||
*/
|
||||
private BigDecimal combinedSubtotalAmount = BigDecimal.ZERO;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.ycwl.basic.pricing.dto.resp;
|
||||
|
||||
import com.ycwl.basic.pricing.enums.CouponStatus;
|
||||
import com.ycwl.basic.pricing.enums.CouponType;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 用户优惠券响应DTO(包含领取记录+优惠券配置)
|
||||
*/
|
||||
@Data
|
||||
public class UserCouponResp {
|
||||
|
||||
// ==================== 领取记录信息 ====================
|
||||
|
||||
/**
|
||||
* 领取记录ID
|
||||
*/
|
||||
private Long claimRecordId;
|
||||
|
||||
/**
|
||||
* 领取时间
|
||||
*/
|
||||
private Date claimTime;
|
||||
|
||||
/**
|
||||
* 过期时间(根据领取时间+领取后有效期计算)
|
||||
*/
|
||||
private Date expireTime;
|
||||
|
||||
/**
|
||||
* 优惠券状态
|
||||
*/
|
||||
private CouponStatus status;
|
||||
|
||||
/**
|
||||
* 领取时的景区ID
|
||||
*/
|
||||
private String claimScenicId;
|
||||
|
||||
// ==================== 优惠券配置信息 ====================
|
||||
|
||||
/**
|
||||
* 优惠券ID
|
||||
*/
|
||||
private Long couponId;
|
||||
|
||||
/**
|
||||
* 优惠券名称
|
||||
*/
|
||||
private String couponName;
|
||||
|
||||
/**
|
||||
* 优惠类型
|
||||
*/
|
||||
private CouponType couponType;
|
||||
|
||||
/**
|
||||
* 优惠值(百分比时为折扣比例,固定金额时为具体金额)
|
||||
*/
|
||||
private BigDecimal discountValue;
|
||||
|
||||
/**
|
||||
* 最小使用金额(门槛)
|
||||
*/
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/**
|
||||
* 最大优惠金额
|
||||
*/
|
||||
private BigDecimal maxDiscount;
|
||||
|
||||
/**
|
||||
* 适用景区ID(NULL表示不限景区)
|
||||
*/
|
||||
private String scenicId;
|
||||
|
||||
/**
|
||||
* 优惠券全局有效期开始时间
|
||||
*/
|
||||
private Date validFrom;
|
||||
|
||||
/**
|
||||
* 优惠券全局有效期结束时间
|
||||
*/
|
||||
private Date validUntil;
|
||||
}
|
||||
@@ -34,6 +34,12 @@ public class PriceCouponClaimRecord {
|
||||
*/
|
||||
private Date claimTime;
|
||||
|
||||
/**
|
||||
* 过期时间(根据领取时间+领取后有效期计算)
|
||||
*/
|
||||
@TableField("expire_time")
|
||||
private Date expireTime;
|
||||
|
||||
/**
|
||||
* 使用时间
|
||||
*/
|
||||
|
||||
@@ -86,6 +86,11 @@ public class PriceCouponConfig {
|
||||
*/
|
||||
private Date validUntil;
|
||||
|
||||
/**
|
||||
* 领取后有效天数(NULL表示不限制,仅使用全局有效期)
|
||||
*/
|
||||
private Integer validDaysAfterClaim;
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
*/
|
||||
|
||||
@@ -25,7 +25,8 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper<PriceCouponClai
|
||||
"FROM price_coupon_claim_record r " +
|
||||
"JOIN price_coupon_config c ON r.coupon_id = c.id " +
|
||||
"WHERE r.user_id = #{userId} AND r.status = 'CLAIMED' " +
|
||||
"AND c.is_active = 1 AND c.valid_from <= NOW() AND c.valid_until > NOW()")
|
||||
"AND c.is_active = 1 AND c.valid_from <= NOW() AND c.valid_until > NOW() " +
|
||||
"AND (r.expire_time IS NULL OR r.expire_time > NOW())")
|
||||
List<PriceCouponClaimRecord> selectUserAvailableCoupons(Long userId);
|
||||
|
||||
/**
|
||||
@@ -33,7 +34,7 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper<PriceCouponClai
|
||||
*/
|
||||
@Select("SELECT * FROM price_coupon_claim_record " +
|
||||
"WHERE user_id = #{userId} AND coupon_id = #{couponId}")
|
||||
PriceCouponClaimRecord selectUserCouponRecord(@Param("userId") Long userId,
|
||||
List<PriceCouponClaimRecord> selectUserCouponRecords(@Param("userId") Long userId,
|
||||
@Param("couponId") Long couponId);
|
||||
|
||||
/**
|
||||
@@ -58,8 +59,8 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper<PriceCouponClai
|
||||
/**
|
||||
* 插入优惠券领用记录
|
||||
*/
|
||||
@Insert("INSERT INTO price_coupon_claim_record (coupon_id, user_id, claim_time, status, scenic_id, create_time, update_time) " +
|
||||
"VALUES (#{couponId}, #{userId}, NOW(), #{status}, #{scenicId}, NOW(), NOW())")
|
||||
@Insert("INSERT INTO price_coupon_claim_record (coupon_id, user_id, claim_time, expire_time, status, scenic_id, create_time, update_time) " +
|
||||
"VALUES (#{couponId}, #{userId}, NOW(), #{expireTime}, #{status}, #{scenicId}, NOW(), NOW())")
|
||||
int insertClaimRecord(PriceCouponClaimRecord record);
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.ycwl.basic.pricing.dto.CouponUseResult;
|
||||
import com.ycwl.basic.pricing.dto.CouponClaimRequest;
|
||||
import com.ycwl.basic.pricing.dto.CouponClaimResult;
|
||||
import com.ycwl.basic.pricing.dto.ProductItem;
|
||||
import com.ycwl.basic.pricing.dto.resp.UserCouponResp;
|
||||
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -55,12 +56,12 @@ public interface ICouponService {
|
||||
CouponUseResult useCoupon(CouponUseRequest request);
|
||||
|
||||
/**
|
||||
* 查询用户可用优惠券
|
||||
* 查询用户可用优惠券(包含领取记录信息)
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 可用优惠券列表
|
||||
* @return 用户优惠券列表(包含领取记录+优惠券配置)
|
||||
*/
|
||||
List<CouponInfo> getUserAvailableCoupons(Long userId);
|
||||
List<UserCouponResp> getUserAvailableCoupons(Long userId);
|
||||
|
||||
/**
|
||||
* 领取优惠券(内部调用方法)
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
|
||||
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
|
||||
import com.ycwl.basic.pricing.dto.UpgradeCheckRequest;
|
||||
import com.ycwl.basic.pricing.dto.UpgradeCheckResult;
|
||||
|
||||
/**
|
||||
* 价格计算服务接口
|
||||
@@ -15,4 +17,12 @@ public interface IPriceCalculationService {
|
||||
* @return 价格计算结果
|
||||
*/
|
||||
PriceCalculationResult calculatePrice(PriceCalculationRequest request);
|
||||
|
||||
/**
|
||||
* 升单检测:综合已购与待购商品,判断是否命中一口价或打包优惠
|
||||
*
|
||||
* @param request 升单检测请求
|
||||
* @return 检测结果
|
||||
*/
|
||||
UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request);
|
||||
}
|
||||
@@ -54,12 +54,14 @@ public class AutoCouponServiceImpl implements IAutoCouponService {
|
||||
for (Long couponId : couponIds) {
|
||||
try {
|
||||
// 检查用户是否已领取过该券(领券即消耗首次资格)
|
||||
PriceCouponClaimRecord existingRecord = couponClaimRecordMapper.selectUserCouponRecord(
|
||||
List<PriceCouponClaimRecord> existingRecords = couponClaimRecordMapper.selectUserCouponRecords(
|
||||
memberId,
|
||||
couponId
|
||||
);
|
||||
|
||||
if (existingRecord != null) {
|
||||
if (existingRecords != null && !existingRecords.isEmpty()) {
|
||||
// 只要有记录(无论状态),都算已领取过
|
||||
PriceCouponClaimRecord existingRecord = existingRecords.get(0);
|
||||
log.debug("用户已领取过优惠券,跳过: memberId={}, couponId={}, claimTime={}",
|
||||
memberId, couponId, existingRecord.getClaimTime());
|
||||
skipCount++;
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ycwl.basic.pricing.service.impl;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ycwl.basic.pricing.dto.*;
|
||||
import com.ycwl.basic.pricing.dto.resp.UserCouponResp;
|
||||
import com.ycwl.basic.pricing.entity.PriceCouponClaimRecord;
|
||||
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
|
||||
import com.ycwl.basic.pricing.enums.CouponStatus;
|
||||
@@ -223,15 +224,23 @@ public class CouponServiceImpl implements ICouponService {
|
||||
@Override
|
||||
@Transactional
|
||||
public CouponUseResult useCoupon(CouponUseRequest request) {
|
||||
PriceCouponClaimRecord record = couponClaimRecordMapper.selectUserCouponRecord(
|
||||
List<PriceCouponClaimRecord> records = couponClaimRecordMapper.selectUserCouponRecords(
|
||||
request.getUserId(), request.getCouponId());
|
||||
|
||||
if (record == null) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
throw new CouponInvalidException("用户未拥有该优惠券");
|
||||
}
|
||||
|
||||
if (record.getStatus() != CouponStatus.CLAIMED) {
|
||||
throw new CouponInvalidException("优惠券状态无效: " + record.getStatus());
|
||||
// 查找一张可用的优惠券(状态为CLAIMED)
|
||||
PriceCouponClaimRecord record = records.stream()
|
||||
.filter(r -> r.getStatus() == CouponStatus.CLAIMED)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (record == null) {
|
||||
// 如果没有可用的,抛出异常。为了错误信息准确,可以检查最后一张的状态
|
||||
CouponStatus lastStatus = records.getFirst().getStatus();
|
||||
throw new CouponInvalidException("优惠券状态无效: " + lastStatus);
|
||||
}
|
||||
|
||||
int updateCount = couponConfigMapper.incrementUsedQuantity(request.getCouponId());
|
||||
@@ -266,20 +275,44 @@ public class CouponServiceImpl implements ICouponService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CouponInfo> getUserAvailableCoupons(Long userId) {
|
||||
public List<UserCouponResp> getUserAvailableCoupons(Long userId) {
|
||||
List<PriceCouponClaimRecord> records = couponClaimRecordMapper.selectUserAvailableCoupons(userId);
|
||||
List<CouponInfo> coupons = new ArrayList<>();
|
||||
List<UserCouponResp> coupons = new ArrayList<>();
|
||||
|
||||
for (PriceCouponClaimRecord record : records) {
|
||||
PriceCouponConfig config = couponConfigMapper.selectById(record.getCouponId());
|
||||
if (config != null) {
|
||||
coupons.add(buildCouponInfo(config, null));
|
||||
coupons.add(buildUserCouponResp(record, config));
|
||||
}
|
||||
}
|
||||
|
||||
return coupons;
|
||||
}
|
||||
|
||||
private UserCouponResp buildUserCouponResp(PriceCouponClaimRecord record, PriceCouponConfig config) {
|
||||
UserCouponResp resp = new UserCouponResp();
|
||||
|
||||
// 领取记录信息
|
||||
resp.setClaimRecordId(record.getId());
|
||||
resp.setClaimTime(record.getClaimTime());
|
||||
resp.setExpireTime(record.getExpireTime());
|
||||
resp.setStatus(record.getStatus());
|
||||
resp.setClaimScenicId(record.getScenicId());
|
||||
|
||||
// 优惠券配置信息
|
||||
resp.setCouponId(config.getId());
|
||||
resp.setCouponName(config.getCouponName());
|
||||
resp.setCouponType(config.getCouponType());
|
||||
resp.setDiscountValue(config.getDiscountValue());
|
||||
resp.setMinAmount(config.getMinAmount());
|
||||
resp.setMaxDiscount(config.getMaxDiscount());
|
||||
resp.setScenicId(config.getScenicId());
|
||||
resp.setValidFrom(config.getValidFrom());
|
||||
resp.setValidUntil(config.getValidUntil());
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
private CouponInfo buildCouponInfo(PriceCouponConfig coupon, BigDecimal actualDiscountAmount) {
|
||||
CouponInfo info = new CouponInfo();
|
||||
info.setCouponId(coupon.getId());
|
||||
@@ -338,9 +371,14 @@ public class CouponServiceImpl implements ICouponService {
|
||||
request.getUserId(), request.getCouponId());
|
||||
// countUserCouponClaims 使用 FOR UPDATE + 复合索引,确保并发下的计数准确
|
||||
if (userClaimCount >= coupon.getUserClaimLimit()) {
|
||||
return CouponClaimResult.failure(
|
||||
// 查询用户已领取的记录,返回给前端展示
|
||||
List<PriceCouponClaimRecord> claimedRecords = couponClaimRecordMapper.selectUserCouponRecords(
|
||||
request.getUserId(), request.getCouponId());
|
||||
return CouponClaimResult.failureWithClaimedRecords(
|
||||
CouponClaimResult.ERROR_CLAIM_LIMIT_REACHED,
|
||||
"您已达到该优惠券的领取上限(" + coupon.getUserClaimLimit() + "张)");
|
||||
"您已达到该优惠券的领取上限(" + coupon.getUserClaimLimit() + "张)",
|
||||
claimedRecords,
|
||||
coupon);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +388,15 @@ public class CouponServiceImpl implements ICouponService {
|
||||
claimRecord.setCouponId(request.getCouponId());
|
||||
claimRecord.setUserId(request.getUserId());
|
||||
claimRecord.setClaimTime(claimTime);
|
||||
|
||||
// 如果配置了领取后有效天数,计算过期时间
|
||||
if (coupon.getValidDaysAfterClaim() != null && coupon.getValidDaysAfterClaim() > 0) {
|
||||
java.util.Calendar calendar = java.util.Calendar.getInstance();
|
||||
calendar.setTime(claimTime);
|
||||
calendar.add(java.util.Calendar.DAY_OF_MONTH, coupon.getValidDaysAfterClaim());
|
||||
claimRecord.setExpireTime(calendar.getTime());
|
||||
}
|
||||
|
||||
claimRecord.setStatus(CouponStatus.CLAIMED);
|
||||
claimRecord.setScenicId(request.getScenicId());
|
||||
claimRecord.setCreateTime(claimTime);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ycwl.basic.pricing.service.impl;
|
||||
|
||||
import com.ycwl.basic.pricing.dto.*;
|
||||
import com.ycwl.basic.pricing.entity.PriceBundleConfig;
|
||||
import com.ycwl.basic.pricing.entity.PriceProductConfig;
|
||||
import com.ycwl.basic.pricing.entity.PriceTierConfig;
|
||||
import com.ycwl.basic.pricing.enums.ProductType;
|
||||
@@ -32,10 +33,14 @@ import java.util.Set;
|
||||
public class PriceCalculationServiceImpl implements IPriceCalculationService {
|
||||
|
||||
private static final String CAPABILITY_METADATA_ATTRIBUTE_KEYS = "pricingAttributeKeys";
|
||||
private static final int AMOUNT_SCALE = 2;
|
||||
private static final String UPGRADE_TYPE_ONE_PRICE = "ONE_PRICE";
|
||||
private static final String UPGRADE_TYPE_BUNDLE_DISCOUNT = "BUNDLE_DISCOUNT";
|
||||
|
||||
private final IProductConfigService productConfigService;
|
||||
private final ICouponService couponService;
|
||||
private final IPriceBundleService bundleService;
|
||||
private final IBundleDiscountService bundleDiscountService;
|
||||
private final IDiscountDetectionService discountDetectionService;
|
||||
private final IVoucherService voucherService;
|
||||
private final IProductTypeCapabilityService productTypeCapabilityService;
|
||||
@@ -159,6 +164,54 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request) {
|
||||
if (request == null) {
|
||||
throw new PriceCalculationException("升单检测请求不能为空");
|
||||
}
|
||||
|
||||
List<ProductItem> purchasedProducts = cloneProducts(request.getPurchasedProducts());
|
||||
List<ProductItem> intendingProducts = cloneProducts(request.getIntendingProducts());
|
||||
|
||||
if (purchasedProducts.isEmpty() && intendingProducts.isEmpty()) {
|
||||
throw new PriceCalculationException("已购和待购商品列表不能同时为空");
|
||||
}
|
||||
|
||||
normalizeProducts(purchasedProducts);
|
||||
normalizeProducts(intendingProducts);
|
||||
|
||||
Long scenicId = request.getScenicId();
|
||||
PriceDetails purchasedDetails = purchasedProducts.isEmpty()
|
||||
? new PriceDetails(BigDecimal.ZERO, BigDecimal.ZERO)
|
||||
: calculateProductsPriceWithOriginal(purchasedProducts, scenicId);
|
||||
PriceDetails intendingDetails = intendingProducts.isEmpty()
|
||||
? new PriceDetails(BigDecimal.ZERO, BigDecimal.ZERO)
|
||||
: calculateProductsPriceWithOriginal(intendingProducts, scenicId);
|
||||
|
||||
List<ProductItem> combinedProducts = new ArrayList<>();
|
||||
combinedProducts.addAll(purchasedProducts);
|
||||
combinedProducts.addAll(intendingProducts);
|
||||
PriceDetails combinedDetails = calculateProductsPriceWithOriginal(combinedProducts, scenicId);
|
||||
|
||||
BigDecimal paidAmount = resolvePaidAmount(request);
|
||||
BigDecimal currentTotalAmount = calculateCurrentTotalAmount(paidAmount, intendingDetails);
|
||||
|
||||
UpgradePriceSummary priceSummary = buildPriceSummary(purchasedDetails, intendingDetails, combinedDetails, paidAmount);
|
||||
UpgradeOnePriceResult onePriceResult = evaluateOnePrice(scenicId, combinedProducts, combinedDetails, paidAmount, currentTotalAmount);
|
||||
UpgradeBundleDiscountResult bundleDiscountResult = evaluateBundleDiscount(scenicId, combinedProducts, combinedDetails, paidAmount, currentTotalAmount);
|
||||
|
||||
UpgradeCheckResult result = new UpgradeCheckResult();
|
||||
result.setScenicId(scenicId);
|
||||
result.setFaceId(request.getFaceId());
|
||||
result.setPriceSummary(priceSummary);
|
||||
result.setOnePriceResult(onePriceResult);
|
||||
result.setBundleDiscountResult(bundleDiscountResult);
|
||||
fillBestUpgrade(result, onePriceResult, bundleDiscountResult);
|
||||
result.setPurchasedProducts(purchasedProducts);
|
||||
result.setIntendingProducts(intendingProducts);
|
||||
return result;
|
||||
}
|
||||
|
||||
private BigDecimal calculateProductsPrice(List<ProductItem> products) {
|
||||
BigDecimal totalAmount = BigDecimal.ZERO;
|
||||
|
||||
@@ -390,6 +443,229 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
|
||||
return new ProductPriceInfo(actualPrice, originalPrice);
|
||||
}
|
||||
|
||||
private UpgradePriceSummary buildPriceSummary(PriceDetails purchased, PriceDetails intending, PriceDetails combined, BigDecimal paidAmount) {
|
||||
UpgradePriceSummary summary = new UpgradePriceSummary();
|
||||
summary.setPurchasedOriginalAmount(purchased.getOriginalTotalAmount());
|
||||
summary.setPurchasedSubtotalAmount(purchased.getTotalAmount());
|
||||
summary.setIntendingOriginalAmount(intending.getOriginalTotalAmount());
|
||||
summary.setIntendingSubtotalAmount(intending.getTotalAmount());
|
||||
summary.setCombinedOriginalAmount(combined.getOriginalTotalAmount());
|
||||
summary.setCombinedSubtotalAmount(combined.getTotalAmount());
|
||||
summary.setPaidAmount(paidAmount);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private UpgradeOnePriceResult evaluateOnePrice(Long scenicId,
|
||||
List<ProductItem> combinedProducts,
|
||||
PriceDetails combinedDetails,
|
||||
BigDecimal paidAmount,
|
||||
BigDecimal currentTotalAmount) {
|
||||
UpgradeOnePriceResult result = new UpgradeOnePriceResult();
|
||||
result.setApplicable(false);
|
||||
|
||||
PriceBundleConfig bundleConfig = bundleService.getBundleConfig(combinedProducts);
|
||||
if (bundleConfig == null || !matchesScenic(bundleConfig.getScenicId(), scenicId)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
BigDecimal bundlePrice = bundleConfig.getBundlePrice() != null
|
||||
? bundleConfig.getBundlePrice()
|
||||
: combinedDetails.getTotalAmount();
|
||||
BigDecimal normalizedBundlePrice = normalizeAmount(bundlePrice);
|
||||
BigDecimal normalizedCurrentTotal = normalizeAmount(currentTotalAmount);
|
||||
BigDecimal normalizedPaidAmount = normalizeAmount(paidAmount);
|
||||
|
||||
if (!isUpgradeBeneficial(normalizedCurrentTotal, normalizedBundlePrice)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
BigDecimal discountAmount = normalizedCurrentTotal.subtract(normalizedBundlePrice);
|
||||
BigDecimal supplementAmount = calculateSupplementAmount(normalizedBundlePrice, normalizedPaidAmount);
|
||||
if (supplementAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.setApplicable(true);
|
||||
result.setBundleConfigId(bundleConfig.getId());
|
||||
result.setBundleName(bundleConfig.getBundleName());
|
||||
result.setDescription(bundleConfig.getDescription());
|
||||
result.setScenicId(bundleConfig.getScenicId());
|
||||
result.setBundlePrice(normalizedBundlePrice);
|
||||
result.setDiscountAmount(discountAmount);
|
||||
result.setEstimatedFinalAmount(supplementAmount);
|
||||
return result;
|
||||
}
|
||||
|
||||
private UpgradeBundleDiscountResult evaluateBundleDiscount(Long scenicId,
|
||||
List<ProductItem> combinedProducts,
|
||||
PriceDetails combinedDetails,
|
||||
BigDecimal paidAmount,
|
||||
BigDecimal currentTotalAmount) {
|
||||
UpgradeBundleDiscountResult result = new UpgradeBundleDiscountResult();
|
||||
result.setApplicable(false);
|
||||
|
||||
BundleDiscountInfo bestDiscount = bundleDiscountService.getBestBundleDiscount(combinedProducts, scenicId);
|
||||
if (bestDiscount == null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
BigDecimal discountAmount = bestDiscount.getActualDiscountAmount();
|
||||
if (discountAmount == null || discountAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
discountAmount = bundleDiscountService.calculateBundleDiscount(bestDiscount, combinedProducts);
|
||||
}
|
||||
if (discountAmount == null || discountAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
BigDecimal normalizedPaidAmount = normalizeAmount(paidAmount);
|
||||
BigDecimal normalizedCurrentTotal = normalizeAmount(currentTotalAmount);
|
||||
BigDecimal normalizedDiscount = normalizeAmount(discountAmount);
|
||||
BigDecimal targetTotal = combinedDetails.getTotalAmount().subtract(normalizedDiscount);
|
||||
if (targetTotal.compareTo(BigDecimal.ZERO) < 0) {
|
||||
targetTotal = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
if (!isUpgradeBeneficial(normalizedCurrentTotal, targetTotal)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
BigDecimal supplementAmount = calculateSupplementAmount(targetTotal, normalizedPaidAmount);
|
||||
if (supplementAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.setApplicable(true);
|
||||
result.setBundleConfigId(bestDiscount.getBundleConfigId());
|
||||
result.setBundleName(bestDiscount.getBundleName());
|
||||
result.setBundleDescription(bestDiscount.getBundleDescription());
|
||||
result.setDiscountType(bestDiscount.getDiscountType());
|
||||
result.setDiscountValue(bestDiscount.getDiscountValue());
|
||||
result.setDiscountAmount(normalizedDiscount);
|
||||
result.setMinQuantity(bestDiscount.getMinQuantity());
|
||||
result.setMinAmount(bestDiscount.getMinAmount());
|
||||
result.setEstimatedFinalAmount(supplementAmount);
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ProductItem> cloneProducts(List<ProductItem> source) {
|
||||
List<ProductItem> copies = new ArrayList<>();
|
||||
if (source == null) {
|
||||
return copies;
|
||||
}
|
||||
for (ProductItem item : source) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
copies.add(cloneProductItem(item));
|
||||
}
|
||||
return copies;
|
||||
}
|
||||
|
||||
private ProductItem cloneProductItem(ProductItem source) {
|
||||
ProductItem copy = new ProductItem();
|
||||
copy.setProductType(source.getProductType());
|
||||
copy.setProductId(source.getProductId());
|
||||
copy.setQuantity(source.getQuantity());
|
||||
copy.setPurchaseCount(source.getPurchaseCount());
|
||||
copy.setOriginalPrice(source.getOriginalPrice());
|
||||
copy.setUnitPrice(source.getUnitPrice());
|
||||
copy.setSubtotal(source.getSubtotal());
|
||||
copy.setScenicId(source.getScenicId());
|
||||
return copy;
|
||||
}
|
||||
|
||||
private void normalizeProducts(List<ProductItem> products) {
|
||||
for (ProductItem product : products) {
|
||||
if (product.getProductType() == null) {
|
||||
throw new PriceCalculationException("商品类型不能为空");
|
||||
}
|
||||
if (product.getProductId() == null) {
|
||||
throw new PriceCalculationException("商品ID不能为空");
|
||||
}
|
||||
if (product.getPurchaseCount() == null) {
|
||||
product.setPurchaseCount(1);
|
||||
}
|
||||
if (product.getQuantity() == null) {
|
||||
product.setQuantity(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean matchesScenic(String configScenicId, Long scenicId) {
|
||||
if (scenicId == null) {
|
||||
return true;
|
||||
}
|
||||
if (configScenicId == null || configScenicId.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return configScenicId.equals(String.valueOf(scenicId));
|
||||
}
|
||||
|
||||
private BigDecimal resolvePaidAmount(UpgradeCheckRequest request) {
|
||||
BigDecimal paidAmount = request != null ? request.getPaidAmount() : null;
|
||||
if (paidAmount == null) {
|
||||
throw new PriceCalculationException("已支付金额不能为空");
|
||||
}
|
||||
return normalizeAmount(paidAmount);
|
||||
}
|
||||
|
||||
private BigDecimal calculateCurrentTotalAmount(BigDecimal paidAmount, PriceDetails intendingDetails) {
|
||||
BigDecimal intendingAmount = intendingDetails != null ? intendingDetails.getTotalAmount() : BigDecimal.ZERO;
|
||||
BigDecimal normalizedPaidAmount = normalizeAmount(paidAmount);
|
||||
BigDecimal normalizedIntendingAmount = normalizeAmount(intendingAmount);
|
||||
return normalizedPaidAmount.add(normalizedIntendingAmount).setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private BigDecimal calculateSupplementAmount(BigDecimal targetTotalAmount, BigDecimal paidAmount) {
|
||||
BigDecimal normalizedTarget = normalizeAmount(targetTotalAmount);
|
||||
BigDecimal normalizedPaid = normalizeAmount(paidAmount);
|
||||
BigDecimal supplementAmount = normalizedTarget.subtract(normalizedPaid);
|
||||
if (supplementAmount.compareTo(BigDecimal.ZERO) < 0) {
|
||||
supplementAmount = BigDecimal.ZERO;
|
||||
}
|
||||
return supplementAmount.setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private boolean isUpgradeBeneficial(BigDecimal currentTotalAmount, BigDecimal targetTotalAmount) {
|
||||
BigDecimal normalizedCurrent = normalizeAmount(currentTotalAmount);
|
||||
BigDecimal normalizedTarget = normalizeAmount(targetTotalAmount);
|
||||
return normalizedCurrent.compareTo(normalizedTarget) > 0;
|
||||
}
|
||||
|
||||
private BigDecimal normalizeAmount(BigDecimal amount) {
|
||||
if (amount == null) {
|
||||
return BigDecimal.ZERO.setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
|
||||
}
|
||||
BigDecimal normalized = amount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : amount;
|
||||
return normalized.setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private void fillBestUpgrade(UpgradeCheckResult result,
|
||||
UpgradeOnePriceResult onePriceResult,
|
||||
UpgradeBundleDiscountResult bundleDiscountResult) {
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
BigDecimal bestPayableAmount = null;
|
||||
String bestUpgradeType = null;
|
||||
|
||||
if (onePriceResult != null && onePriceResult.isApplicable()) {
|
||||
bestPayableAmount = onePriceResult.getEstimatedFinalAmount();
|
||||
bestUpgradeType = UPGRADE_TYPE_ONE_PRICE;
|
||||
}
|
||||
if (bundleDiscountResult != null && bundleDiscountResult.isApplicable()) {
|
||||
BigDecimal bundlePayableAmount = bundleDiscountResult.getEstimatedFinalAmount();
|
||||
if (bestPayableAmount == null || (bundlePayableAmount != null
|
||||
&& bundlePayableAmount.compareTo(bestPayableAmount) < 0)) {
|
||||
bestPayableAmount = bundlePayableAmount;
|
||||
bestUpgradeType = UPGRADE_TYPE_BUNDLE_DISCOUNT;
|
||||
}
|
||||
}
|
||||
|
||||
result.setBestPayableAmount(bestPayableAmount);
|
||||
result.setBestUpgradeType(bestUpgradeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算优惠(券码 + 优惠券)
|
||||
*/
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.base;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.ycwl.basic.puzzle.element.enums.ElementType;
|
||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.awt.*;
|
||||
|
||||
/**
|
||||
* 元素抽象基类
|
||||
* 定义所有Element的通用行为和属性
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
@Data
|
||||
public abstract class BaseElement {
|
||||
|
||||
/**
|
||||
* 元素ID
|
||||
*/
|
||||
protected Long id;
|
||||
|
||||
/**
|
||||
* 元素类型
|
||||
*/
|
||||
protected ElementType elementType;
|
||||
|
||||
/**
|
||||
* 元素标识(用于动态数据映射)
|
||||
*/
|
||||
protected String elementKey;
|
||||
|
||||
/**
|
||||
* 元素名称(便于管理识别)
|
||||
*/
|
||||
protected String elementName;
|
||||
|
||||
/**
|
||||
* 位置信息
|
||||
*/
|
||||
protected Position position;
|
||||
|
||||
/**
|
||||
* JSON配置字符串(原始)
|
||||
*/
|
||||
protected String configJson;
|
||||
|
||||
/**
|
||||
* 解析后的配置对象(子类特定)
|
||||
*/
|
||||
protected ElementConfig config;
|
||||
|
||||
// ========== 抽象方法(子类必须实现) ==========
|
||||
|
||||
/**
|
||||
* 加载并解析JSON配置
|
||||
* 子类需要将configJson解析为具体的Config对象
|
||||
*
|
||||
* @param configJson JSON配置字符串
|
||||
*/
|
||||
public abstract void loadConfig(String configJson);
|
||||
|
||||
/**
|
||||
* 验证元素配置是否合法
|
||||
*
|
||||
* @throws ElementValidationException 配置不合法时抛出
|
||||
*/
|
||||
public abstract void validate() throws ElementValidationException;
|
||||
|
||||
/**
|
||||
* 渲染元素到画布
|
||||
* 这是元素的核心方法,负责将元素绘制到Graphics2D上
|
||||
*
|
||||
* @param context 渲染上下文
|
||||
*/
|
||||
public abstract void render(RenderContext context);
|
||||
|
||||
/**
|
||||
* 获取配置的JSON Schema或说明
|
||||
*
|
||||
* @return 配置说明
|
||||
*/
|
||||
public abstract String getConfigSchema();
|
||||
|
||||
// ========== 通用方法 ==========
|
||||
|
||||
/**
|
||||
* 初始化元素(加载配置并验证)
|
||||
* 在创建Element实例后必须调用此方法
|
||||
*/
|
||||
public void initialize() {
|
||||
if (StrUtil.isNotBlank(configJson)) {
|
||||
loadConfig(configJson);
|
||||
}
|
||||
validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用透明度
|
||||
* 如果元素有透明度设置,则应用到Graphics2D上
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
* @return 原始的Composite对象(用于恢复)
|
||||
*/
|
||||
protected Composite applyOpacity(Graphics2D g2d) {
|
||||
Composite originalComposite = g2d.getComposite();
|
||||
if (position != null && position.hasOpacity()) {
|
||||
g2d.setComposite(AlphaComposite.getInstance(
|
||||
AlphaComposite.SRC_OVER,
|
||||
position.getOpacityFloat()
|
||||
));
|
||||
}
|
||||
return originalComposite;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复透明度
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
* @param originalComposite 原始的Composite对象
|
||||
*/
|
||||
protected void restoreOpacity(Graphics2D g2d, Composite originalComposite) {
|
||||
if (originalComposite != null) {
|
||||
g2d.setComposite(originalComposite);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用旋转
|
||||
* 如果元素有旋转设置,则应用到Graphics2D上
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
*/
|
||||
protected void applyRotation(Graphics2D g2d) {
|
||||
if (position != null && position.hasRotation()) {
|
||||
// 以元素中心点为旋转中心
|
||||
int centerX = position.getX() + position.getWidth() / 2;
|
||||
int centerY = position.getY() + position.getHeight() / 2;
|
||||
g2d.rotate(position.getRotationRadians(), centerX, centerY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析颜色字符串(支持hex格式)
|
||||
*
|
||||
* @param colorStr 颜色字符串(如#FFFFFF)
|
||||
* @return Color对象
|
||||
*/
|
||||
protected Color parseColor(String colorStr) {
|
||||
if (StrUtil.isBlank(colorStr)) {
|
||||
return Color.BLACK;
|
||||
}
|
||||
try {
|
||||
// 移除#号
|
||||
String hex = colorStr.startsWith("#") ? colorStr.substring(1) : colorStr;
|
||||
// 解析RGB
|
||||
return new Color(
|
||||
Integer.valueOf(hex.substring(0, 2), 16),
|
||||
Integer.valueOf(hex.substring(2, 4), 16),
|
||||
Integer.valueOf(hex.substring(4, 6), 16)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.warn("颜色解析失败: {}, 使用默认黑色", colorStr);
|
||||
return Color.BLACK;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解析JSON配置
|
||||
*
|
||||
* @param configJson JSON字符串
|
||||
* @param configClass 配置类
|
||||
* @param <T> 配置类型
|
||||
* @return 配置对象
|
||||
*/
|
||||
protected <T extends ElementConfig> T parseConfig(String configJson, Class<T> configClass) {
|
||||
try {
|
||||
if (StrUtil.isBlank(configJson)) {
|
||||
// 返回默认实例
|
||||
return configClass.getDeclaredConstructor().newInstance();
|
||||
}
|
||||
return JacksonUtil.fromJson(configJson, configClass);
|
||||
} catch (Exception e) {
|
||||
throw new ElementValidationException(
|
||||
elementType != null ? elementType.getCode() : "UNKNOWN",
|
||||
elementKey,
|
||||
"JSON配置解析失败: " + e.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用高质量渲染
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
*/
|
||||
protected void enableHighQualityRendering(Graphics2D g2d) {
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("Element[type=%s, key=%s, name=%s, position=%s]",
|
||||
elementType != null ? elementType.getCode() : "null",
|
||||
elementKey,
|
||||
elementName,
|
||||
position);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.base;
|
||||
|
||||
/**
|
||||
* 元素配置接口
|
||||
* 所有Element的配置类都需要实现此接口
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
public interface ElementConfig {
|
||||
|
||||
/**
|
||||
* 获取配置说明(JSON Schema或描述)
|
||||
*
|
||||
* @return 配置说明
|
||||
*/
|
||||
default String getConfigSchema() {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置是否合法
|
||||
* 子类应该重写此方法实现自己的验证逻辑
|
||||
*
|
||||
* @throws IllegalArgumentException 配置不合法时抛出
|
||||
*/
|
||||
default void validate() {
|
||||
// 默认不做验证
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.base;
|
||||
|
||||
import com.ycwl.basic.puzzle.element.enums.ElementType;
|
||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 元素工厂类
|
||||
* 负责根据类型创建Element实例
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
public class ElementFactory {
|
||||
|
||||
/**
|
||||
* Element类型注册表
|
||||
* key: ElementType枚举
|
||||
* value: Element实现类的Class对象
|
||||
*/
|
||||
private static final Map<ElementType, Class<? extends BaseElement>> ELEMENT_REGISTRY = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 构造器缓存(性能优化)
|
||||
* key: Element实现类
|
||||
* value: 无参构造器
|
||||
*/
|
||||
private static final Map<Class<? extends BaseElement>, Constructor<? extends BaseElement>> CONSTRUCTOR_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 注册Element类型
|
||||
*
|
||||
* @param type 元素类型
|
||||
* @param elementClass Element实现类
|
||||
*/
|
||||
public static void register(ElementType type, Class<? extends BaseElement> elementClass) {
|
||||
if (type == null || elementClass == null) {
|
||||
throw new IllegalArgumentException("注册参数不能为null");
|
||||
}
|
||||
ELEMENT_REGISTRY.put(type, elementClass);
|
||||
log.info("注册Element类型: {} -> {}", type.getCode(), elementClass.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据Entity创建Element实例
|
||||
*
|
||||
* @param entity PuzzleElementEntity
|
||||
* @return Element实例
|
||||
*/
|
||||
public static BaseElement create(PuzzleElementEntity entity) {
|
||||
if (entity == null) {
|
||||
throw new IllegalArgumentException("Entity不能为null");
|
||||
}
|
||||
|
||||
// 解析元素类型
|
||||
ElementType type;
|
||||
try {
|
||||
type = ElementType.fromCode(entity.getElementType());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ElementValidationException(
|
||||
entity.getElementType(),
|
||||
entity.getElementKey(),
|
||||
"未知的元素类型: " + entity.getElementType()
|
||||
);
|
||||
}
|
||||
|
||||
// 检查类型是否已实现
|
||||
if (!type.isImplemented()) {
|
||||
throw new ElementValidationException(
|
||||
type.getCode(),
|
||||
entity.getElementKey(),
|
||||
"元素类型尚未实现: " + type.getName()
|
||||
);
|
||||
}
|
||||
|
||||
// 获取Element实现类
|
||||
Class<? extends BaseElement> elementClass = ELEMENT_REGISTRY.get(type);
|
||||
if (elementClass == null) {
|
||||
throw new ElementValidationException(
|
||||
type.getCode(),
|
||||
entity.getElementKey(),
|
||||
"元素类型未注册: " + type.getCode()
|
||||
);
|
||||
}
|
||||
|
||||
// 创建Element实例
|
||||
BaseElement element = createInstance(elementClass);
|
||||
|
||||
// 填充基本属性
|
||||
element.setId(entity.getId());
|
||||
element.setElementType(type);
|
||||
element.setElementKey(entity.getElementKey());
|
||||
element.setElementName(entity.getElementName());
|
||||
element.setConfigJson(entity.getConfig());
|
||||
|
||||
// 填充位置信息
|
||||
Position position = new Position(
|
||||
entity.getXPosition(),
|
||||
entity.getYPosition(),
|
||||
entity.getWidth(),
|
||||
entity.getHeight(),
|
||||
entity.getZIndex(),
|
||||
entity.getRotation(),
|
||||
entity.getOpacity()
|
||||
);
|
||||
element.setPosition(position);
|
||||
|
||||
// 初始化(加载配置并验证)
|
||||
element.initialize();
|
||||
|
||||
log.debug("创建Element成功: type={}, key={}", type.getCode(), entity.getElementKey());
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Element实例(使用反射)
|
||||
*
|
||||
* @param elementClass Element类
|
||||
* @return Element实例
|
||||
*/
|
||||
private static BaseElement createInstance(Class<? extends BaseElement> elementClass) {
|
||||
try {
|
||||
// 从缓存获取构造器
|
||||
Constructor<? extends BaseElement> constructor = CONSTRUCTOR_CACHE.get(elementClass);
|
||||
if (constructor == null) {
|
||||
constructor = elementClass.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
CONSTRUCTOR_CACHE.put(elementClass, constructor);
|
||||
}
|
||||
return constructor.newInstance();
|
||||
} catch (Exception e) {
|
||||
throw new ElementValidationException(
|
||||
"Element实例创建失败: " + elementClass.getName() + ", 原因: " + e.getMessage(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已注册的Element类型列表
|
||||
*
|
||||
* @return Element类型列表
|
||||
*/
|
||||
public static Map<ElementType, Class<? extends BaseElement>> getRegisteredTypes() {
|
||||
return new ConcurrentHashMap<>(ELEMENT_REGISTRY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查类型是否已注册
|
||||
*
|
||||
* @param type 元素类型
|
||||
* @return true-已注册,false-未注册
|
||||
*/
|
||||
public static boolean isRegistered(ElementType type) {
|
||||
return ELEMENT_REGISTRY.containsKey(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空注册表(主要用于测试)
|
||||
*/
|
||||
public static void clearRegistry() {
|
||||
ELEMENT_REGISTRY.clear();
|
||||
CONSTRUCTOR_CACHE.clear();
|
||||
log.warn("Element注册表已清空");
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.base;
|
||||
|
||||
import com.ycwl.basic.puzzle.element.enums.ElementType;
|
||||
import com.ycwl.basic.puzzle.element.impl.ImageElement;
|
||||
import com.ycwl.basic.puzzle.element.impl.TextElement;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* Element注册器
|
||||
* 在Spring容器初始化时自动注册所有Element类型
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ElementRegistrar {
|
||||
|
||||
@PostConstruct
|
||||
public void registerElements() {
|
||||
log.info("开始注册Element类型...");
|
||||
|
||||
// 注册文字元素
|
||||
ElementFactory.register(ElementType.TEXT, TextElement.class);
|
||||
|
||||
// 注册图片元素
|
||||
ElementFactory.register(ElementType.IMAGE, ImageElement.class);
|
||||
|
||||
// 未来扩展的Element类型在这里注册
|
||||
// ElementFactory.register(ElementType.QRCODE, QRCodeElement.class);
|
||||
// ElementFactory.register(ElementType.GRADIENT, GradientElement.class);
|
||||
// ElementFactory.register(ElementType.SHAPE, ShapeElement.class);
|
||||
// ElementFactory.register(ElementType.DYNAMIC_IMAGE, DynamicImageElement.class);
|
||||
|
||||
log.info("Element类型注册完成,共注册{}种类型", ElementFactory.getRegisteredTypes().size());
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.base;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 元素位置信息
|
||||
* 封装所有与位置、大小、变换相关的属性
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Position {
|
||||
|
||||
/**
|
||||
* X坐标(相对于画布左上角,像素)
|
||||
*/
|
||||
private Integer x;
|
||||
|
||||
/**
|
||||
* Y坐标(相对于画布左上角,像素)
|
||||
*/
|
||||
private Integer y;
|
||||
|
||||
/**
|
||||
* 宽度(像素)
|
||||
*/
|
||||
private Integer width;
|
||||
|
||||
/**
|
||||
* 高度(像素)
|
||||
*/
|
||||
private Integer height;
|
||||
|
||||
/**
|
||||
* 层级(数值越大越靠上,决定绘制顺序)
|
||||
*/
|
||||
private Integer zIndex;
|
||||
|
||||
/**
|
||||
* 旋转角度(0-360度,顺时针)
|
||||
*/
|
||||
private Integer rotation;
|
||||
|
||||
/**
|
||||
* 不透明度(0-100,100为完全不透明)
|
||||
*/
|
||||
private Integer opacity;
|
||||
|
||||
/**
|
||||
* 获取不透明度的浮点数表示(0.0-1.0)
|
||||
*
|
||||
* @return 不透明度(0.0-1.0)
|
||||
*/
|
||||
public float getOpacityFloat() {
|
||||
if (opacity == null) {
|
||||
return 1.0f;
|
||||
}
|
||||
return Math.max(0, Math.min(100, opacity)) / 100.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旋转角度的弧度值
|
||||
*
|
||||
* @return 弧度值
|
||||
*/
|
||||
public double getRotationRadians() {
|
||||
if (rotation == null || rotation == 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.toRadians(rotation);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要旋转
|
||||
*
|
||||
* @return true-需要旋转,false-不需要
|
||||
*/
|
||||
public boolean hasRotation() {
|
||||
return rotation != null && rotation != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有透明度
|
||||
*
|
||||
* @return true-有透明度,false-完全不透明
|
||||
*/
|
||||
public boolean hasOpacity() {
|
||||
return opacity != null && opacity < 100;
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.config;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.ycwl.basic.puzzle.element.base.ElementConfig;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 图片元素配置
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Data
|
||||
public class ImageConfig implements ElementConfig {
|
||||
|
||||
/**
|
||||
* 默认图片URL
|
||||
*/
|
||||
private String defaultImageUrl;
|
||||
|
||||
/**
|
||||
* 图片适配模式
|
||||
* CONTAIN - 等比缩放适应(保持宽高比,可能留白)
|
||||
* COVER - 等比缩放填充(保持宽高比,可能裁剪)
|
||||
* FILL - 拉伸填充(不保持宽高比,可能变形)
|
||||
* SCALE_DOWN - 缩小适应(类似CONTAIN,但不放大)
|
||||
*/
|
||||
private String imageFitMode = "FILL";
|
||||
|
||||
/**
|
||||
* 圆角半径(像素,0为直角)
|
||||
* 注意:当 borderRadius >= min(width, height) / 2 时,效果为圆形
|
||||
*/
|
||||
private Integer borderRadius = 0;
|
||||
|
||||
/**
|
||||
* 叠加图片配置
|
||||
* 用于在主图上叠加另一张图片(如二维码中心的头像)
|
||||
*/
|
||||
private OverlayImageConfig overlayImage;
|
||||
|
||||
/**
|
||||
* 叠加图片配置类
|
||||
*/
|
||||
@Data
|
||||
public static class OverlayImageConfig {
|
||||
/**
|
||||
* 叠加图片的数据源 key(从 dynamicData 获取 URL)
|
||||
* 例如:faceAvatar
|
||||
*/
|
||||
private String imageKey;
|
||||
|
||||
/**
|
||||
* 叠加图片的默认 URL(当 dynamicData 中无对应值时使用)
|
||||
*/
|
||||
private String defaultImageUrl;
|
||||
|
||||
/**
|
||||
* 叠加图片宽度占主图宽度的比例(0.0 - 1.0)
|
||||
* 默认 0.45(与现有水印实现一致)
|
||||
*/
|
||||
private Double widthRatio = 0.45;
|
||||
|
||||
/**
|
||||
* 叠加图片高度占主图高度的比例(0.0 - 1.0)
|
||||
* 默认 0.45
|
||||
*/
|
||||
private Double heightRatio = 0.45;
|
||||
|
||||
/**
|
||||
* 叠加图片的适配模式
|
||||
* 默认 COVER(与现有水印实现一致)
|
||||
*/
|
||||
private String imageFitMode = "COVER";
|
||||
|
||||
/**
|
||||
* 叠加图片的圆角半径
|
||||
* 默认 -1 表示自动计算为圆形(min(width, height) / 2)
|
||||
*/
|
||||
private Integer borderRadius = -1;
|
||||
|
||||
/**
|
||||
* 叠加图片的水平对齐方式
|
||||
* CENTER - 居中(默认)
|
||||
* LEFT - 左对齐
|
||||
* RIGHT - 右对齐
|
||||
*/
|
||||
private String horizontalAlign = "CENTER";
|
||||
|
||||
/**
|
||||
* 叠加图片的垂直对齐方式
|
||||
* CENTER - 居中(默认)
|
||||
* TOP - 顶部对齐
|
||||
* BOTTOM - 底部对齐
|
||||
*/
|
||||
private String verticalAlign = "CENTER";
|
||||
|
||||
/**
|
||||
* 水平偏移量(像素),正值向右,负值向左
|
||||
*/
|
||||
private Integer offsetX = 0;
|
||||
|
||||
/**
|
||||
* 垂直偏移量(像素),正值向下,负值向上
|
||||
*/
|
||||
private Integer offsetY = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() {
|
||||
// 校验圆角半径
|
||||
if (borderRadius != null && borderRadius < 0) {
|
||||
throw new IllegalArgumentException("圆角半径不能为负数: " + borderRadius);
|
||||
}
|
||||
|
||||
// 校验图片适配模式
|
||||
if (StrUtil.isNotBlank(imageFitMode)) {
|
||||
String mode = imageFitMode.toUpperCase();
|
||||
if (!"CONTAIN".equals(mode) &&
|
||||
!"COVER".equals(mode) &&
|
||||
!"FILL".equals(mode) &&
|
||||
!"SCALE_DOWN".equals(mode)) {
|
||||
throw new IllegalArgumentException("图片适配模式只能是CONTAIN、COVER、FILL或SCALE_DOWN: " + imageFitMode);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验图片URL(注意:现在可以通过 dynamicData 动态填充,所以允许为空)
|
||||
if (StrUtil.isNotBlank(defaultImageUrl)) {
|
||||
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验叠加图片配置
|
||||
if (overlayImage != null) {
|
||||
validateOverlayImage(overlayImage);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateOverlayImage(OverlayImageConfig overlay) {
|
||||
// 校验比例范围
|
||||
if (overlay.getWidthRatio() != null && (overlay.getWidthRatio() <= 0 || overlay.getWidthRatio() > 1)) {
|
||||
throw new IllegalArgumentException("叠加图片宽度比例必须在 0-1 之间: " + overlay.getWidthRatio());
|
||||
}
|
||||
if (overlay.getHeightRatio() != null && (overlay.getHeightRatio() <= 0 || overlay.getHeightRatio() > 1)) {
|
||||
throw new IllegalArgumentException("叠加图片高度比例必须在 0-1 之间: " + overlay.getHeightRatio());
|
||||
}
|
||||
|
||||
// 校验对齐方式
|
||||
if (StrUtil.isNotBlank(overlay.getHorizontalAlign())) {
|
||||
String align = overlay.getHorizontalAlign().toUpperCase();
|
||||
if (!"CENTER".equals(align) && !"LEFT".equals(align) && !"RIGHT".equals(align)) {
|
||||
throw new IllegalArgumentException("水平对齐方式只能是CENTER、LEFT或RIGHT: " + overlay.getHorizontalAlign());
|
||||
}
|
||||
}
|
||||
if (StrUtil.isNotBlank(overlay.getVerticalAlign())) {
|
||||
String align = overlay.getVerticalAlign().toUpperCase();
|
||||
if (!"CENTER".equals(align) && !"TOP".equals(align) && !"BOTTOM".equals(align)) {
|
||||
throw new IllegalArgumentException("垂直对齐方式只能是CENTER、TOP或BOTTOM: " + overlay.getVerticalAlign());
|
||||
}
|
||||
}
|
||||
|
||||
// 校验叠加图片URL
|
||||
if (StrUtil.isNotBlank(overlay.getDefaultImageUrl())) {
|
||||
if (!overlay.getDefaultImageUrl().startsWith("http://") && !overlay.getDefaultImageUrl().startsWith("https://")) {
|
||||
throw new IllegalArgumentException("叠加图片URL必须以http://或https://开头: " + overlay.getDefaultImageUrl());
|
||||
}
|
||||
}
|
||||
|
||||
// 校验适配模式
|
||||
if (StrUtil.isNotBlank(overlay.getImageFitMode())) {
|
||||
String mode = overlay.getImageFitMode().toUpperCase();
|
||||
if (!"CONTAIN".equals(mode) && !"COVER".equals(mode) && !"FILL".equals(mode) && !"SCALE_DOWN".equals(mode)) {
|
||||
throw new IllegalArgumentException("叠加图片适配模式只能是CONTAIN、COVER、FILL或SCALE_DOWN: " + overlay.getImageFitMode());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigSchema() {
|
||||
return "{\n" +
|
||||
" \"defaultImageUrl\": \"https://example.com/image.jpg\",\n" +
|
||||
" \"imageFitMode\": \"CONTAIN|COVER|FILL|SCALE_DOWN\",\n" +
|
||||
" \"borderRadius\": 0,\n" +
|
||||
" \"overlayImage\": {\n" +
|
||||
" \"imageKey\": \"faceAvatar\",\n" +
|
||||
" \"defaultImageUrl\": \"https://example.com/default-avatar.png\",\n" +
|
||||
" \"widthRatio\": 0.45,\n" +
|
||||
" \"heightRatio\": 0.45,\n" +
|
||||
" \"imageFitMode\": \"COVER\",\n" +
|
||||
" \"borderRadius\": -1,\n" +
|
||||
" \"horizontalAlign\": \"CENTER\",\n" +
|
||||
" \"verticalAlign\": \"CENTER\",\n" +
|
||||
" \"offsetX\": 0,\n" +
|
||||
" \"offsetY\": 0\n" +
|
||||
" }\n" +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.config;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.ycwl.basic.puzzle.element.base.ElementConfig;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 文字元素配置
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Data
|
||||
public class TextConfig implements ElementConfig {
|
||||
|
||||
/**
|
||||
* 默认文本内容
|
||||
*/
|
||||
private String defaultText;
|
||||
|
||||
/**
|
||||
* 字体名称(如"微软雅黑"、"PingFang SC")
|
||||
*/
|
||||
private String fontFamily = "微软雅黑";
|
||||
|
||||
/**
|
||||
* 字号(像素,范围10-200)
|
||||
*/
|
||||
private Integer fontSize = 14;
|
||||
|
||||
/**
|
||||
* 字体颜色(hex格式,如#000000)
|
||||
*/
|
||||
private String fontColor = "#000000";
|
||||
|
||||
/**
|
||||
* 字重:NORMAL-正常 BOLD-粗体
|
||||
*/
|
||||
private String fontWeight = "NORMAL";
|
||||
|
||||
/**
|
||||
* 字体样式:NORMAL-正常 ITALIC-斜体
|
||||
*/
|
||||
private String fontStyle = "NORMAL";
|
||||
|
||||
/**
|
||||
* 对齐方式:LEFT-左对齐 CENTER-居中 RIGHT-右对齐
|
||||
*/
|
||||
private String textAlign = "LEFT";
|
||||
|
||||
/**
|
||||
* 行高倍数(如1.5表示1.5倍行距)
|
||||
*/
|
||||
private BigDecimal lineHeight = new BigDecimal("1.5");
|
||||
|
||||
/**
|
||||
* 最大行数(超出后截断,NULL表示不限制)
|
||||
*/
|
||||
private Integer maxLines;
|
||||
|
||||
/**
|
||||
* 文本装饰:NONE-无 UNDERLINE-下划线 LINE_THROUGH-删除线
|
||||
*/
|
||||
private String textDecoration = "NONE";
|
||||
|
||||
@Override
|
||||
public void validate() {
|
||||
// 校验字号范围
|
||||
if (fontSize != null && (fontSize < 10 || fontSize > 200)) {
|
||||
throw new IllegalArgumentException("字号必须在10-200之间: " + fontSize);
|
||||
}
|
||||
|
||||
// 校验行高
|
||||
if (lineHeight != null && lineHeight.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException("行高必须大于0: " + lineHeight);
|
||||
}
|
||||
|
||||
// 校验最大行数
|
||||
if (maxLines != null && maxLines <= 0) {
|
||||
throw new IllegalArgumentException("最大行数必须大于0: " + maxLines);
|
||||
}
|
||||
|
||||
// 校验字重
|
||||
if (StrUtil.isNotBlank(fontWeight)) {
|
||||
if (!"NORMAL".equalsIgnoreCase(fontWeight) && !"BOLD".equalsIgnoreCase(fontWeight)) {
|
||||
throw new IllegalArgumentException("字重只能是NORMAL或BOLD: " + fontWeight);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验字体样式
|
||||
if (StrUtil.isNotBlank(fontStyle)) {
|
||||
if (!"NORMAL".equalsIgnoreCase(fontStyle) && !"ITALIC".equalsIgnoreCase(fontStyle)) {
|
||||
throw new IllegalArgumentException("字体样式只能是NORMAL或ITALIC: " + fontStyle);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验对齐方式
|
||||
if (StrUtil.isNotBlank(textAlign)) {
|
||||
if (!"LEFT".equalsIgnoreCase(textAlign) &&
|
||||
!"CENTER".equalsIgnoreCase(textAlign) &&
|
||||
!"RIGHT".equalsIgnoreCase(textAlign)) {
|
||||
throw new IllegalArgumentException("对齐方式只能是LEFT、CENTER或RIGHT: " + textAlign);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验文本装饰
|
||||
if (StrUtil.isNotBlank(textDecoration)) {
|
||||
if (!"NONE".equalsIgnoreCase(textDecoration) &&
|
||||
!"UNDERLINE".equalsIgnoreCase(textDecoration) &&
|
||||
!"LINE_THROUGH".equalsIgnoreCase(textDecoration)) {
|
||||
throw new IllegalArgumentException("文本装饰只能是NONE、UNDERLINE或LINE_THROUGH: " + textDecoration);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验颜色格式
|
||||
if (StrUtil.isNotBlank(fontColor)) {
|
||||
String hex = fontColor.startsWith("#") ? fontColor.substring(1) : fontColor;
|
||||
if (hex.length() != 6 || !hex.matches("[0-9A-Fa-f]{6}")) {
|
||||
throw new IllegalArgumentException("颜色格式必须是hex格式(如#FFFFFF): " + fontColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigSchema() {
|
||||
return "{\n" +
|
||||
" \"defaultText\": \"默认文本\",\n" +
|
||||
" \"fontFamily\": \"微软雅黑\",\n" +
|
||||
" \"fontSize\": 14,\n" +
|
||||
" \"fontColor\": \"#000000\",\n" +
|
||||
" \"fontWeight\": \"NORMAL|BOLD\",\n" +
|
||||
" \"fontStyle\": \"NORMAL|ITALIC\",\n" +
|
||||
" \"textAlign\": \"LEFT|CENTER|RIGHT\",\n" +
|
||||
" \"lineHeight\": 1.5,\n" +
|
||||
" \"maxLines\": null,\n" +
|
||||
" \"textDecoration\": \"NONE|UNDERLINE|LINE_THROUGH\"\n" +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.exception;
|
||||
|
||||
/**
|
||||
* 元素验证异常
|
||||
* 当元素配置不合法时抛出
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
public class ElementValidationException extends RuntimeException {
|
||||
|
||||
private final String elementKey;
|
||||
private final String elementType;
|
||||
|
||||
public ElementValidationException(String message) {
|
||||
super(message);
|
||||
this.elementKey = null;
|
||||
this.elementType = null;
|
||||
}
|
||||
|
||||
public ElementValidationException(String elementType, String elementKey, String message) {
|
||||
super(String.format("[%s:%s] %s", elementType, elementKey, message));
|
||||
this.elementKey = elementKey;
|
||||
this.elementType = elementType;
|
||||
}
|
||||
|
||||
public ElementValidationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.elementKey = null;
|
||||
this.elementType = null;
|
||||
}
|
||||
|
||||
public String getElementKey() {
|
||||
return elementKey;
|
||||
}
|
||||
|
||||
public String getElementType() {
|
||||
return elementType;
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
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;
|
||||
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.geom.Ellipse2D;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 图片元素实现
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
public class ImageElement extends BaseElement {
|
||||
|
||||
private static final int DOWNLOAD_TIMEOUT_MS = 5000;
|
||||
|
||||
private ImageConfig imageConfig;
|
||||
|
||||
@Override
|
||||
public void loadConfig(String configJson) {
|
||||
this.imageConfig = parseConfig(configJson, ImageConfig.class);
|
||||
this.config = imageConfig;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws ElementValidationException {
|
||||
try {
|
||||
if (imageConfig == null) {
|
||||
throw new ElementValidationException(
|
||||
elementType.getCode(),
|
||||
elementKey,
|
||||
"图片配置不能为空"
|
||||
);
|
||||
}
|
||||
imageConfig.validate();
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ElementValidationException(
|
||||
elementType.getCode(),
|
||||
elementKey,
|
||||
"配置验证失败: " + e.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(RenderContext context) {
|
||||
Graphics2D g2d = context.getGraphics();
|
||||
|
||||
// 获取图片URL(优先使用动态数据)
|
||||
String imageUrl = context.getDynamicData(elementKey, imageConfig.getDefaultImageUrl());
|
||||
if (StrUtil.isBlank(imageUrl)) {
|
||||
log.warn("图片元素没有图片URL: elementKey={}", elementKey);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 下载图片
|
||||
BufferedImage image = downloadImage(imageUrl);
|
||||
if (image == null) {
|
||||
log.error("图片下载失败: imageUrl={}", imageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 应用透明度
|
||||
Composite originalComposite = applyOpacity(g2d);
|
||||
|
||||
// 缩放图片(根据适配模式)
|
||||
BufferedImage scaledImage = scaleImage(image);
|
||||
|
||||
// 绘制图片(支持圆角)
|
||||
if (imageConfig.getBorderRadius() != null && imageConfig.getBorderRadius() > 0) {
|
||||
drawRoundedImage(g2d, scaledImage);
|
||||
} else {
|
||||
// 直接绘制
|
||||
g2d.drawImage(scaledImage, position.getX(), position.getY(), null);
|
||||
}
|
||||
|
||||
// 恢复透明度
|
||||
restoreOpacity(g2d, originalComposite);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("图片元素渲染失败: elementKey={}, imageUrl={}", elementKey, imageUrl, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigSchema() {
|
||||
return imageConfig != null ? imageConfig.getConfigSchema() : "{}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载图片
|
||||
*
|
||||
* @param imageUrl 图片URL或本地文件路径
|
||||
* @return BufferedImage对象
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩放图片(根据适配模式)
|
||||
*
|
||||
* @param source 原始图片
|
||||
* @return 缩放后的图片
|
||||
*/
|
||||
private BufferedImage scaleImage(BufferedImage source) {
|
||||
int targetWidth = position.getWidth();
|
||||
int targetHeight = position.getHeight();
|
||||
String fitMode = StrUtil.isNotBlank(imageConfig.getImageFitMode())
|
||||
? imageConfig.getImageFitMode().toUpperCase()
|
||||
: "FILL";
|
||||
|
||||
switch (fitMode) {
|
||||
case "COVER":
|
||||
// 等比缩放填充(可能裁剪)- 使用较大的比例
|
||||
return scaleImageKeepRatio(source, targetWidth, targetHeight, true);
|
||||
|
||||
case "CONTAIN":
|
||||
// 等比缩放适应(可能留白)- 使用较小的比例
|
||||
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
|
||||
|
||||
case "SCALE_DOWN":
|
||||
// 缩小适应(不放大)
|
||||
if (source.getWidth() <= targetWidth && source.getHeight() <= targetHeight) {
|
||||
return source; // 原图已小于目标,不处理
|
||||
}
|
||||
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
|
||||
|
||||
case "FILL":
|
||||
default:
|
||||
// 拉伸填充到目标尺寸(不保持宽高比,可能变形)
|
||||
BufferedImage scaled = new BufferedImage(
|
||||
targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = scaled.createGraphics();
|
||||
enableHighQualityRendering(g);
|
||||
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
|
||||
g.dispose();
|
||||
return scaled;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等比缩放图片(保持宽高比)
|
||||
*
|
||||
* @param source 原始图片
|
||||
* @param targetWidth 目标宽度
|
||||
* @param targetHeight 目标高度
|
||||
* @param cover true-COVER模式,false-CONTAIN模式
|
||||
* @return 缩放后的图片
|
||||
*/
|
||||
private BufferedImage scaleImageKeepRatio(BufferedImage source,
|
||||
int targetWidth, int targetHeight,
|
||||
boolean cover) {
|
||||
int sourceWidth = source.getWidth();
|
||||
int sourceHeight = source.getHeight();
|
||||
|
||||
double widthRatio = (double) targetWidth / sourceWidth;
|
||||
double heightRatio = (double) targetHeight / sourceHeight;
|
||||
|
||||
// cover模式使用较大比例(填充),contain模式使用较小比例(适应)
|
||||
double ratio = cover
|
||||
? Math.max(widthRatio, heightRatio)
|
||||
: Math.min(widthRatio, heightRatio);
|
||||
|
||||
int scaledWidth = (int) (sourceWidth * ratio);
|
||||
int scaledHeight = (int) (sourceHeight * ratio);
|
||||
|
||||
// 创建目标尺寸的画布
|
||||
BufferedImage result = new BufferedImage(
|
||||
targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = result.createGraphics();
|
||||
enableHighQualityRendering(g);
|
||||
|
||||
// 居中绘制缩放后的图片
|
||||
int x = (targetWidth - scaledWidth) / 2;
|
||||
int y = (targetHeight - scaledHeight) / 2;
|
||||
g.drawImage(source, x, y, scaledWidth, scaledHeight, null);
|
||||
g.dispose();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制圆角图片
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
* @param image 图片
|
||||
*/
|
||||
private void drawRoundedImage(Graphics2D g2d, BufferedImage image) {
|
||||
int width = position.getWidth();
|
||||
int height = position.getHeight();
|
||||
int radius = imageConfig.getBorderRadius();
|
||||
|
||||
// 创建圆角遮罩
|
||||
BufferedImage rounded = new BufferedImage(
|
||||
width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = rounded.createGraphics();
|
||||
enableHighQualityRendering(g);
|
||||
|
||||
// 判断是否需要绘制圆形(当圆角半径>=最小边长的一半时)
|
||||
boolean isCircle = (radius * 2 >= Math.min(width, height));
|
||||
|
||||
if (isCircle) {
|
||||
// 绘制圆形遮罩
|
||||
g.setColor(Color.WHITE);
|
||||
g.fill(new Ellipse2D.Float(0, 0, width, height));
|
||||
} else {
|
||||
// 绘制圆角矩形遮罩
|
||||
g.setColor(Color.WHITE);
|
||||
g.fill(new RoundRectangle2D.Float(0, 0, width, height, radius * 2, radius * 2));
|
||||
}
|
||||
|
||||
// 应用遮罩
|
||||
g.setComposite(AlphaComposite.SrcAtop);
|
||||
g.drawImage(image, 0, 0, width, height, null);
|
||||
g.dispose();
|
||||
|
||||
// 绘制到主画布
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||
import com.ycwl.basic.puzzle.element.config.TextConfig;
|
||||
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
|
||||
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.font.LineMetrics;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
|
||||
/**
|
||||
* 文字元素实现
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
public class TextElement extends BaseElement {
|
||||
|
||||
private TextConfig textConfig;
|
||||
|
||||
@Override
|
||||
public void loadConfig(String configJson) {
|
||||
this.textConfig = parseConfig(configJson, TextConfig.class);
|
||||
this.config = textConfig;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws ElementValidationException {
|
||||
try {
|
||||
if (textConfig == null) {
|
||||
throw new ElementValidationException(
|
||||
elementType.getCode(),
|
||||
elementKey,
|
||||
"文字配置不能为空"
|
||||
);
|
||||
}
|
||||
textConfig.validate();
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ElementValidationException(
|
||||
elementType.getCode(),
|
||||
elementKey,
|
||||
"配置验证失败: " + e.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(RenderContext context) {
|
||||
Graphics2D g2d = context.getGraphics();
|
||||
|
||||
// 获取文本内容(优先使用动态数据)
|
||||
String text = context.getDynamicData(elementKey, textConfig.getDefaultText());
|
||||
if (StrUtil.isBlank(text)) {
|
||||
log.debug("文字元素没有文本内容: elementKey={}", elementKey);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置字体
|
||||
Font font = createFont();
|
||||
g2d.setFont(font);
|
||||
|
||||
// 设置颜色
|
||||
g2d.setColor(parseColor(textConfig.getFontColor()));
|
||||
|
||||
// 应用透明度
|
||||
Composite originalComposite = applyOpacity(g2d);
|
||||
|
||||
// 应用旋转
|
||||
if (position.hasRotation()) {
|
||||
applyRotation(g2d);
|
||||
}
|
||||
|
||||
// 绘制文本
|
||||
drawText(g2d, text);
|
||||
|
||||
// 恢复透明度
|
||||
restoreOpacity(g2d, originalComposite);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("文字元素渲染失败: elementKey={}, text={}", elementKey, text, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigSchema() {
|
||||
return textConfig != null ? textConfig.getConfigSchema() : "{}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建字体
|
||||
*
|
||||
* @return Font对象
|
||||
*/
|
||||
private Font createFont() {
|
||||
int fontStyle = Font.PLAIN;
|
||||
|
||||
// 处理字重(BOLD)
|
||||
if ("BOLD".equalsIgnoreCase(textConfig.getFontWeight())) {
|
||||
fontStyle |= Font.BOLD;
|
||||
}
|
||||
|
||||
// 处理字体样式(ITALIC)
|
||||
if ("ITALIC".equalsIgnoreCase(textConfig.getFontStyle())) {
|
||||
fontStyle |= Font.ITALIC;
|
||||
}
|
||||
|
||||
return new Font(
|
||||
textConfig.getFontFamily(),
|
||||
fontStyle,
|
||||
textConfig.getFontSize()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制文本(支持多行、对齐、行高、最大行数)
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
* @param text 文本内容
|
||||
*/
|
||||
private void drawText(Graphics2D g2d, String text) {
|
||||
FontMetrics fm = g2d.getFontMetrics();
|
||||
|
||||
// 计算行高
|
||||
float lineHeightMultiplier = textConfig.getLineHeight() != null
|
||||
? textConfig.getLineHeight().floatValue()
|
||||
: 1.5f;
|
||||
int lineHeight = (int) (fm.getHeight() * lineHeightMultiplier);
|
||||
|
||||
// 分行
|
||||
String[] lines = text.split("\\n");
|
||||
Integer maxLines = textConfig.getMaxLines();
|
||||
int actualLines = maxLines != null ? Math.min(lines.length, maxLines) : lines.length;
|
||||
|
||||
// 获取对齐方式
|
||||
String textAlign = StrUtil.isNotBlank(textConfig.getTextAlign())
|
||||
? textConfig.getTextAlign().toUpperCase()
|
||||
: "LEFT";
|
||||
|
||||
// 计算总文本高度并实现垂直居中
|
||||
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++) {
|
||||
String line = lines[i];
|
||||
|
||||
// 计算X坐标(根据对齐方式)
|
||||
int x = calculateTextX(line, fm, textAlign);
|
||||
|
||||
// 绘制文本
|
||||
g2d.drawString(line, x, y);
|
||||
|
||||
// 绘制文本装饰(下划线、删除线)
|
||||
if (StrUtil.isNotBlank(textConfig.getTextDecoration())) {
|
||||
drawTextDecoration(g2d, line, x, y, fm);
|
||||
}
|
||||
|
||||
// 移动到下一行
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文本X坐标(根据对齐方式)
|
||||
*
|
||||
* @param text 文本
|
||||
* @param fm 字体度量
|
||||
* @param textAlign 对齐方式
|
||||
* @return X坐标
|
||||
*/
|
||||
private int calculateTextX(String text, FontMetrics fm, String textAlign) {
|
||||
int textWidth = fm.stringWidth(text);
|
||||
|
||||
switch (textAlign) {
|
||||
case "CENTER":
|
||||
return position.getX() + (position.getWidth() - textWidth) / 2;
|
||||
case "RIGHT":
|
||||
return position.getX() + position.getWidth() - textWidth;
|
||||
case "LEFT":
|
||||
default:
|
||||
return position.getX();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制文本装饰(下划线、删除线)
|
||||
*
|
||||
* @param g2d Graphics2D对象
|
||||
* @param text 文本
|
||||
* @param x 文本X坐标
|
||||
* @param y 文本Y坐标
|
||||
* @param fm 字体度量
|
||||
*/
|
||||
private void drawTextDecoration(Graphics2D g2d, String text, int x, int y, FontMetrics fm) {
|
||||
String decoration = textConfig.getTextDecoration().toUpperCase();
|
||||
int textWidth = fm.stringWidth(text);
|
||||
|
||||
switch (decoration) {
|
||||
case "UNDERLINE":
|
||||
// 下划线(在文本下方)
|
||||
int underlineY = y + fm.getDescent() / 2;
|
||||
g2d.drawLine(x, underlineY, x + textWidth, underlineY);
|
||||
break;
|
||||
|
||||
case "LINE_THROUGH":
|
||||
// 删除线(在文本中间)
|
||||
int lineThroughY = y - fm.getAscent() / 2;
|
||||
g2d.drawLine(x, lineThroughY, x + textWidth, lineThroughY);
|
||||
break;
|
||||
|
||||
case "NONE":
|
||||
default:
|
||||
// 无装饰
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.element.renderer;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.awt.*;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 渲染上下文
|
||||
* 封装渲染时需要的所有上下文信息
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Data
|
||||
public class RenderContext {
|
||||
|
||||
/**
|
||||
* 图形上下文
|
||||
*/
|
||||
private Graphics2D graphics;
|
||||
|
||||
/**
|
||||
* 动态数据(key=elementKey, value=实际值)
|
||||
*/
|
||||
private Map<String, String> dynamicData;
|
||||
|
||||
/**
|
||||
* 画布宽度
|
||||
*/
|
||||
private Integer canvasWidth;
|
||||
|
||||
/**
|
||||
* 画布高度
|
||||
*/
|
||||
private Integer canvasHeight;
|
||||
|
||||
/**
|
||||
* 是否启用抗锯齿
|
||||
*/
|
||||
private boolean antiAliasing = true;
|
||||
|
||||
/**
|
||||
* 是否启用高质量渲染
|
||||
*/
|
||||
private boolean highQuality = true;
|
||||
|
||||
public RenderContext(Graphics2D graphics, Map<String, String> dynamicData) {
|
||||
this.graphics = graphics;
|
||||
this.dynamicData = dynamicData;
|
||||
}
|
||||
|
||||
public RenderContext(Graphics2D graphics, Map<String, String> dynamicData,
|
||||
Integer canvasWidth, Integer canvasHeight) {
|
||||
this.graphics = graphics;
|
||||
this.dynamicData = dynamicData;
|
||||
this.canvasWidth = canvasWidth;
|
||||
this.canvasHeight = canvasHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动态数据(带默认值)
|
||||
*
|
||||
* @param key 数据key
|
||||
* @param defaultValue 默认值
|
||||
* @return 数据值
|
||||
*/
|
||||
public String getDynamicData(String key, String defaultValue) {
|
||||
if (dynamicData == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
return dynamicData.getOrDefault(key, defaultValue);
|
||||
}
|
||||
}
|
||||
@@ -46,4 +46,9 @@ public interface MemberPuzzleMapper {
|
||||
* 根据人脸ID和记录ID查询
|
||||
*/
|
||||
MemberPuzzleEntity getByFaceAndRecord(@Param("faceId") Long faceId, @Param("recordId") Long recordId);
|
||||
|
||||
/**
|
||||
* 查询指定时间范围内生成且未购买的免费拼图记录
|
||||
*/
|
||||
List<MemberPuzzleEntity> listFreeUnpurchased(@Param("startTime") java.util.Date startTime, @Param("endTime") java.util.Date endTime);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.ycwl.basic.puzzle.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.ycwl.basic.biz.FaceStatusManager;
|
||||
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
||||
@@ -16,23 +15,16 @@ import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
|
||||
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.service.pc.processor.PuzzleRelationProcessor;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
import com.ycwl.basic.utils.WxMpUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
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;
|
||||
@@ -52,11 +44,9 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
|
||||
private final PuzzleRepository puzzleRepository;
|
||||
private final PuzzleGenerationRecordMapper recordMapper;
|
||||
private final PuzzleImageRenderer imageRenderer;
|
||||
private final PuzzleElementFillEngine fillEngine;
|
||||
private final ScenicRepository scenicRepository;
|
||||
private final PuzzleDuplicationDetector duplicationDetector;
|
||||
private final PrinterService printerService;
|
||||
private final PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService;
|
||||
private final FaceStatusManager faceStatusManager;
|
||||
private final PuzzleRelationProcessor puzzleRelationProcessor;
|
||||
@@ -64,21 +54,17 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
public PuzzleGenerateServiceImpl(
|
||||
PuzzleRepository puzzleRepository,
|
||||
PuzzleGenerationRecordMapper recordMapper,
|
||||
@Lazy PuzzleImageRenderer imageRenderer,
|
||||
@Lazy PuzzleElementFillEngine fillEngine,
|
||||
@Lazy ScenicRepository scenicRepository,
|
||||
@Lazy PuzzleDuplicationDetector duplicationDetector,
|
||||
@Lazy PrinterService printerService,
|
||||
PuzzleEdgeRenderTaskService puzzleEdgeRenderTaskService,
|
||||
@Lazy FaceStatusManager faceStatusManager,
|
||||
@Lazy PuzzleRelationProcessor puzzleRelationProcessor) {
|
||||
this.puzzleRepository = puzzleRepository;
|
||||
this.recordMapper = recordMapper;
|
||||
this.imageRenderer = imageRenderer;
|
||||
this.fillEngine = fillEngine;
|
||||
this.scenicRepository = scenicRepository;
|
||||
this.duplicationDetector = duplicationDetector;
|
||||
this.printerService = printerService;
|
||||
this.puzzleEdgeRenderTaskService = puzzleEdgeRenderTaskService;
|
||||
this.faceStatusManager = faceStatusManager;
|
||||
this.puzzleRelationProcessor = puzzleRelationProcessor;
|
||||
@@ -340,84 +326,6 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
return record.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心生成逻辑(同步执行)
|
||||
*/
|
||||
private PuzzleGenerateResponse doGenerate(PuzzleGenerateRequest request) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
log.info("开始生成拼图: templateCode={}, userId={}, faceId={}",
|
||||
request.getTemplateCode(), request.getUserId(), request.getFaceId());
|
||||
|
||||
// 参数校验
|
||||
validateRequest(request);
|
||||
|
||||
// 1. 查询模板和元素(使用缓存)
|
||||
PuzzleTemplateEntity template = puzzleRepository.getTemplateByCode(request.getTemplateCode());
|
||||
if (template == null) {
|
||||
throw new IllegalArgumentException("模板不存在: " + request.getTemplateCode());
|
||||
}
|
||||
|
||||
if (template.getStatus() != 1) {
|
||||
throw new IllegalArgumentException("模板已禁用: " + request.getTemplateCode());
|
||||
}
|
||||
|
||||
// 2. 校验景区隔离
|
||||
Long resolvedScenicId = resolveScenicId(template, request.getScenicId());
|
||||
|
||||
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
|
||||
if (elements.isEmpty()) {
|
||||
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
|
||||
}
|
||||
|
||||
// 3. 按z-index排序元素
|
||||
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
|
||||
Comparator.nullsFirst(Comparator.naturalOrder())));
|
||||
|
||||
// 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);
|
||||
|
||||
// 清除生成记录缓存(新记录插入后列表和数量都会变化)
|
||||
puzzleRepository.clearRecordCacheByFace(request.getFaceId());
|
||||
|
||||
// 9. 执行核心生成逻辑
|
||||
return doGenerateInternal(request, template, resolvedScenicId, record, startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求参数
|
||||
*/
|
||||
@@ -427,105 +335,6 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心生成逻辑(内部方法,同步/异步共用)
|
||||
* 注意:此方法会在调用线程中执行渲染和上传操作
|
||||
*
|
||||
* @param request 生成请求
|
||||
* @param template 模板
|
||||
* @param resolvedScenicId 景区ID
|
||||
* @param record 生成记录(已插入数据库)
|
||||
* @return 生成结果(异步模式下不关心返回值)
|
||||
*/
|
||||
private PuzzleGenerateResponse doGenerateInternal(PuzzleGenerateRequest request,
|
||||
PuzzleTemplateEntity template,
|
||||
Long resolvedScenicId,
|
||||
PuzzleGenerationRecordEntity record) {
|
||||
return doGenerateInternal(request, template, resolvedScenicId, record, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心生成逻辑(内部方法,同步/异步共用)
|
||||
*/
|
||||
private PuzzleGenerateResponse doGenerateInternal(PuzzleGenerateRequest request,
|
||||
PuzzleTemplateEntity template,
|
||||
Long resolvedScenicId,
|
||||
PuzzleGenerationRecordEntity record,
|
||||
long startTime) {
|
||||
List<PuzzleElementEntity> elements = puzzleRepository.getElementsByTemplateId(template.getId());
|
||||
if (elements.isEmpty()) {
|
||||
throw new IllegalArgumentException("模板没有配置元素: " + request.getTemplateCode());
|
||||
}
|
||||
|
||||
// 按z-index排序元素
|
||||
elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex,
|
||||
Comparator.nullsFirst(Comparator.naturalOrder())));
|
||||
|
||||
// 准备dynamicData
|
||||
Map<String, String> finalDynamicData = buildDynamicData(template, request, resolvedScenicId, elements);
|
||||
|
||||
try {
|
||||
// 渲染图片
|
||||
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
|
||||
|
||||
// 上传图片到OSS
|
||||
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
|
||||
log.info("图片上传成功: url={}", imageUrl);
|
||||
|
||||
// 更新记录为成功
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
long fileSize = estimateFileSize(resultImage, request.getOutputFormat());
|
||||
recordMapper.updateSuccess(
|
||||
record.getId(),
|
||||
imageUrl,
|
||||
fileSize,
|
||||
resultImage.getWidth(),
|
||||
resultImage.getHeight(),
|
||||
(int) duration
|
||||
);
|
||||
|
||||
// 清除生成记录缓存(状态已更新)
|
||||
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
|
||||
|
||||
log.info("拼图生成成功: recordId={}, imageUrl={}, duration={}ms",
|
||||
record.getId(), imageUrl, duration);
|
||||
|
||||
// 检查是否自动添加到打印队列
|
||||
if (template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
|
||||
try {
|
||||
Integer printRecordId = printerService.addUserPhotoFromPuzzle(
|
||||
request.getUserId(),
|
||||
resolvedScenicId,
|
||||
request.getFaceId(),
|
||||
imageUrl,
|
||||
record.getId() // 拼图记录ID,用于关联 puzzle_generation_record 表
|
||||
);
|
||||
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
|
||||
} catch (Exception e) {
|
||||
log.error("自动添加到打印队列失败: recordId={}", record.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
return PuzzleGenerateResponse.success(
|
||||
imageUrl,
|
||||
fileSize,
|
||||
resultImage.getWidth(),
|
||||
resultImage.getHeight(),
|
||||
(int) duration,
|
||||
record.getId(),
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("拼图生成失败: templateCode={}", request.getTemplateCode(), e);
|
||||
recordMapper.updateFail(record.getId(), e.getMessage());
|
||||
// 清除生成记录缓存(状态已更新)
|
||||
puzzleRepository.clearRecordCache(record.getId(), request.getFaceId());
|
||||
throw new RuntimeException("图片生成失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建生成记录
|
||||
*/
|
||||
@@ -550,53 +359,6 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片到OSS
|
||||
*/
|
||||
private String uploadImage(BufferedImage image, String templateCode, String format, Integer quality) throws IOException {
|
||||
// 确定格式
|
||||
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
|
||||
if (!"PNG".equals(outputFormat) && !"JPEG".equals(outputFormat) && !"JPG".equals(outputFormat)) {
|
||||
outputFormat = "PNG";
|
||||
}
|
||||
|
||||
// 转换为字节数组
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, outputFormat, baos);
|
||||
byte[] imageBytes = baos.toByteArray();
|
||||
|
||||
// 生成文件名
|
||||
String fileName = String.format("%s.%s",
|
||||
UUID.randomUUID().toString().replace("-", ""),
|
||||
outputFormat.toLowerCase()
|
||||
);
|
||||
|
||||
// 使用项目现有的存储工厂上传(转换为InputStream)
|
||||
try {
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(imageBytes);
|
||||
String contentType = "PNG".equals(outputFormat) ? "image/png" : "image/jpeg";
|
||||
return StorageFactory.use().uploadFile(contentType, inputStream, "puzzle", templateCode, fileName);
|
||||
} catch (Exception e) {
|
||||
log.error("上传图片失败: fileName={}", fileName, e);
|
||||
throw new IOException("图片上传失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算文件大小(字节)
|
||||
*/
|
||||
private long estimateFileSize(BufferedImage image, String format) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
String outputFormat = StrUtil.isNotBlank(format) ? format.toUpperCase() : "PNG";
|
||||
ImageIO.write(image, outputFormat, baos);
|
||||
return baos.size();
|
||||
} catch (IOException e) {
|
||||
log.warn("估算文件大小失败", e);
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建dynamicData(合并自动填充和手动数据)
|
||||
* 优先级: 手动传入的数据 > 自动填充的数据
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
package com.ycwl.basic.puzzle.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import com.ycwl.basic.puzzle.element.base.BaseElement;
|
||||
import com.ycwl.basic.puzzle.element.base.ElementFactory;
|
||||
import com.ycwl.basic.puzzle.element.renderer.RenderContext;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 拼图图片渲染引擎(重构版)
|
||||
* 核心功能:将模板和元素渲染成最终图片
|
||||
*
|
||||
* 重构说明:
|
||||
* - 使用ElementFactory创建Element实例
|
||||
* - 元素渲染逻辑委托给Element自己实现
|
||||
* - 删除drawImageElement和drawTextElement方法
|
||||
* - 保留背景绘制和工具方法
|
||||
*
|
||||
* @author Claude
|
||||
* @since 2025-01-18
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PuzzleImageRenderer {
|
||||
|
||||
/**
|
||||
* 渲染拼图图片(重构版)
|
||||
*
|
||||
* @param template 模板配置
|
||||
* @param elements 元素列表(已按z-index排序)
|
||||
* @param dynamicData 动态数据(key=elementKey, value=实际值)
|
||||
* @return 渲染后的图片
|
||||
*/
|
||||
public BufferedImage render(PuzzleTemplateEntity template,
|
||||
List<PuzzleElementEntity> elements,
|
||||
Map<String, String> dynamicData) {
|
||||
log.info("开始渲染拼图: templateId={}, elementCount={}", template.getId(), elements.size());
|
||||
|
||||
// 1. 创建画布
|
||||
BufferedImage canvas = new BufferedImage(
|
||||
template.getCanvasWidth(),
|
||||
template.getCanvasHeight(),
|
||||
BufferedImage.TYPE_INT_ARGB // 使用ARGB支持透明度
|
||||
);
|
||||
|
||||
Graphics2D g2d = canvas.createGraphics();
|
||||
|
||||
try {
|
||||
// 2. 开启抗锯齿和优化渲染质量
|
||||
enableHighQualityRendering(g2d);
|
||||
|
||||
// 3. 绘制背景
|
||||
drawBackground(g2d, template);
|
||||
|
||||
// 4. 创建渲染上下文
|
||||
RenderContext context = new RenderContext(
|
||||
g2d,
|
||||
dynamicData,
|
||||
template.getCanvasWidth(),
|
||||
template.getCanvasHeight()
|
||||
);
|
||||
|
||||
// 5. 使用ElementFactory创建Element实例并渲染
|
||||
for (PuzzleElementEntity entity : elements) {
|
||||
try {
|
||||
// 使用工厂创建Element实例(自动加载配置和验证)
|
||||
BaseElement element = ElementFactory.create(entity);
|
||||
|
||||
// 委托给Element自己渲染
|
||||
element.render(context);
|
||||
|
||||
log.debug("元素渲染成功: type={}, key={}", element.getElementType().getCode(), element.getElementKey());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("元素渲染失败: elementId={}, elementKey={}, error={}",
|
||||
entity.getId(), entity.getElementKey(), e.getMessage(), e);
|
||||
// 继续绘制其他元素,不中断整个渲染流程
|
||||
}
|
||||
}
|
||||
|
||||
log.info("拼图渲染完成: templateId={}, 成功渲染元素数={}", template.getId(), elements.size());
|
||||
return canvas;
|
||||
|
||||
} finally {
|
||||
g2d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启高质量渲染
|
||||
*/
|
||||
private void enableHighQualityRendering(Graphics2D g2d) {
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制背景
|
||||
*/
|
||||
private void drawBackground(Graphics2D g2d, PuzzleTemplateEntity template) {
|
||||
if (template.getBackgroundType() == 0) {
|
||||
// 纯色背景
|
||||
String bgColor = StrUtil.isNotBlank(template.getBackgroundColor())
|
||||
? template.getBackgroundColor() : "#FFFFFF";
|
||||
g2d.setColor(parseColor(bgColor));
|
||||
g2d.fillRect(0, 0, template.getCanvasWidth(), template.getCanvasHeight());
|
||||
} else if (template.getBackgroundType() == 1 && StrUtil.isNotBlank(template.getBackgroundImage())) {
|
||||
// 图片背景
|
||||
try {
|
||||
BufferedImage bgImage = downloadImage(template.getBackgroundImage());
|
||||
Image scaledBg = bgImage.getScaledInstance(template.getCanvasWidth(), template.getCanvasHeight(), Image.SCALE_SMOOTH);
|
||||
g2d.drawImage(scaledBg, 0, 0, null);
|
||||
} catch (Exception e) {
|
||||
log.error("绘制背景图片失败: {}", template.getBackgroundImage(), e);
|
||||
// 降级为白色背景
|
||||
g2d.setColor(Color.WHITE);
|
||||
g2d.fillRect(0, 0, template.getCanvasWidth(), template.getCanvasHeight());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载图片(工具方法,也可被外部使用)
|
||||
*/
|
||||
public BufferedImage downloadImage(String imageUrl) throws IOException {
|
||||
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
|
||||
// 网络图片
|
||||
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
|
||||
return ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||
} else {
|
||||
// 本地文件
|
||||
return ImageIO.read(new File(imageUrl));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析颜色(工具方法,也可被外部使用)
|
||||
*/
|
||||
public Color parseColor(String colorStr) {
|
||||
try {
|
||||
if (colorStr.startsWith("#")) {
|
||||
return Color.decode(colorStr);
|
||||
} else if (colorStr.startsWith("rgb(")) {
|
||||
// 简单解析 rgb(r,g,b)
|
||||
String rgb = colorStr.substring(4, colorStr.length() - 1);
|
||||
String[] parts = rgb.split(",");
|
||||
return new Color(
|
||||
Integer.parseInt(parts[0].trim()),
|
||||
Integer.parseInt(parts[1].trim()),
|
||||
Integer.parseInt(parts[2].trim())
|
||||
);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("解析颜色失败: {}, 使用黑色", colorStr);
|
||||
}
|
||||
return Color.BLACK;
|
||||
}
|
||||
}
|
||||
@@ -428,6 +428,8 @@ public class WechatSubscribeNotifyConfigRepository {
|
||||
if (cfg == null || !Objects.equals(cfg.getEnabled(), 1)) {
|
||||
continue;
|
||||
}
|
||||
// 复制去重配置到模板实体中
|
||||
cfg.setDedupSeconds(mapping.getDedupSeconds());
|
||||
result.add(cfg);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.ycwl.basic.service.mobile;
|
||||
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface AppCouponRecordService {
|
||||
|
||||
List<CouponRecordEntity> queryByMemberIdAndFaceId(Long memberId, Long faceId);
|
||||
|
||||
CouponRecordEntity queryByMemberIdAndFaceIdAndType(Long memberId, Long faceId, Integer type);
|
||||
|
||||
CouponEntity claimCoupon(Long memberId, Long faceId, Integer type);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public interface AppMemberService {
|
||||
* @param userInfoUpdateDTO
|
||||
* @return
|
||||
*/
|
||||
ApiResponse<?> update(WeChatUserInfoUpdateDTO userInfoUpdateDTO);
|
||||
ApiResponse<?> update(Long memberId, WeChatUserInfoUpdateDTO userInfoUpdateDTO);
|
||||
|
||||
/**
|
||||
* 同意用户协议
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package com.ycwl.basic.service.mobile.impl;
|
||||
|
||||
import com.ycwl.basic.mapper.CouponMapper;
|
||||
import com.ycwl.basic.mapper.CouponRecordMapper;
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.service.mobile.AppCouponRecordService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AppCouponRecordServiceImpl implements AppCouponRecordService {
|
||||
|
||||
@Autowired
|
||||
private CouponRecordMapper couponRecordMapper;
|
||||
|
||||
@Autowired
|
||||
private CouponMapper couponMapper;
|
||||
@Autowired
|
||||
private FaceRepository faceRepository;
|
||||
|
||||
@Override
|
||||
public List<CouponRecordEntity> queryByMemberIdAndFaceId(Long memberId, Long faceId) {
|
||||
return couponRecordMapper.queryByMemberIdAndFaceId(memberId, faceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CouponRecordEntity queryByMemberIdAndFaceIdAndType(Long memberId, Long faceId, Integer type) {
|
||||
return couponRecordMapper.queryByMemberIdAndFaceIdAndType(memberId, faceId, type);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CouponEntity claimCoupon(Long memberId, Long faceId, Integer type) {
|
||||
// 检查是否已经领取过该类型的优惠券
|
||||
CouponRecordEntity existingRecord = couponRecordMapper.queryByMemberIdAndFaceIdAndType(memberId, faceId, type);
|
||||
if (existingRecord != null) {
|
||||
throw new RuntimeException("该用户已经领取过此类型的优惠券");
|
||||
}
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
throw new RuntimeException("人脸数据不存在");
|
||||
}
|
||||
// 查找可用的优惠券
|
||||
Long scenicId = face.getScenicId();
|
||||
CouponEntity coupon = couponMapper.selectByScenicIdAndTypeAndStatus(scenicId, type, 1);
|
||||
|
||||
if (coupon == null) {
|
||||
throw new RuntimeException("未找到可领取的优惠券");
|
||||
}
|
||||
|
||||
// 创建优惠券记录
|
||||
CouponRecordEntity record = new CouponRecordEntity();
|
||||
record.setCouponId(coupon.getId());
|
||||
record.setMemberId(memberId);
|
||||
record.setFaceId(faceId);
|
||||
record.setStatus(0); // 有效状态
|
||||
record.setCreateTime(new Date());
|
||||
|
||||
couponRecordMapper.insert(record);
|
||||
return coupon;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import com.ycwl.basic.service.mobile.AppMemberService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||
import com.ycwl.basic.utils.SnowFlakeUtil;
|
||||
import com.ycwl.basic.utils.WxMpUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -138,9 +139,21 @@ public class AppMemberServiceImpl implements AppMemberService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<?> update(WeChatUserInfoUpdateDTO userInfoUpdateDTO) {
|
||||
public ApiResponse<?> update(Long userId, WeChatUserInfoUpdateDTO userInfoUpdateDTO) {
|
||||
if (StringUtils.isNotBlank(userInfoUpdateDTO.getNickname())) {
|
||||
MemberRespVO member = memberMapper.getById(userId);
|
||||
if (member != null && member.getScenicId() != null) {
|
||||
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(member.getScenicId());
|
||||
if (scenicMpConfig != null) {
|
||||
boolean checkResult = WxMpUtil.msgSecCheck(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), userInfoUpdateDTO.getNickname(), member.getOpenId(), 1);
|
||||
if (!checkResult) {
|
||||
throw new AppException(BizCodeEnum.PARAM_ERROR.getCode(), "昵称包含违规内容,请修改");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MemberEntity memberEntity = new MemberEntity();
|
||||
memberEntity.setId(Long.parseLong(BaseContextHandler.getUserId()));
|
||||
memberEntity.setId(userId);
|
||||
memberEntity.setNickname(userInfoUpdateDTO.getNickname());
|
||||
memberEntity.setAvatarUrl(userInfoUpdateDTO.getAvatarUrl());
|
||||
memberEntity.setAgreement(userInfoUpdateDTO.getAgreement());
|
||||
|
||||
@@ -26,7 +26,6 @@ import com.ycwl.basic.mapper.*;
|
||||
import com.ycwl.basic.model.mobile.goods.*;
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||
import com.ycwl.basic.model.mobile.order.PriceObj;
|
||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
||||
@@ -711,14 +710,13 @@ public class GoodsServiceImpl implements GoodsService {
|
||||
|
||||
FacePieceUpdateStatus updateStatus = faceStatusManager.getFacePieceUpdateStatus(video.getFaceId(), video.getTemplateId());
|
||||
if (updateStatus == FacePieceUpdateStatus.NO_NEW_PIECES) {
|
||||
log.info("无新片段: faceId={}, templateId={}", video.getFaceId(), video.getTemplateId());
|
||||
result.setCanUpdate(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
Long taskId = video.getTaskId();
|
||||
if (taskId == null) {
|
||||
log.error("视频没有关联任务: videoId={}", videoId);
|
||||
log.warn("视频没有关联任务: videoId={}", videoId);
|
||||
result.setCanUpdate(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.ycwl.basic.utils.NotificationAuthUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -23,6 +24,8 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -47,15 +50,18 @@ public class WechatSubscribeNotifyTriggerService {
|
||||
private final WechatSubscribeSendLogMapper sendLogMapper;
|
||||
private final NotificationAuthUtils notificationAuthUtils;
|
||||
private final ZtMessageProducerService ztMessageProducerService;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public WechatSubscribeNotifyTriggerService(WechatSubscribeNotifyConfigService configService,
|
||||
WechatSubscribeSendLogMapper sendLogMapper,
|
||||
NotificationAuthUtils notificationAuthUtils,
|
||||
ZtMessageProducerService ztMessageProducerService) {
|
||||
ZtMessageProducerService ztMessageProducerService,
|
||||
StringRedisTemplate redisTemplate) {
|
||||
this.configService = configService;
|
||||
this.sendLogMapper = sendLogMapper;
|
||||
this.notificationAuthUtils = notificationAuthUtils;
|
||||
this.ztMessageProducerService = ztMessageProducerService;
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,8 +97,32 @@ public class WechatSubscribeNotifyTriggerService {
|
||||
continue;
|
||||
}
|
||||
|
||||
String idempotencyKey = buildIdempotencyKey(eventKey, cfg.getTemplateKey(), request);
|
||||
WechatSubscribeSendLogEntity sendLog = buildInitLog(idempotencyKey, eventKey, cfg, request);
|
||||
// 计算基础幂等键
|
||||
String baseHash = buildIdempotencyKey(eventKey, cfg.getTemplateKey(), request);
|
||||
String dbIdempotencyKey = baseHash;
|
||||
Integer dedupSeconds = cfg.getDedupSeconds();
|
||||
|
||||
if (dedupSeconds != null && dedupSeconds != 0) {
|
||||
// 非永久去重场景
|
||||
if (dedupSeconds < 0) {
|
||||
// 不去重:强制生成新的幂等键
|
||||
dbIdempotencyKey = baseHash + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
} else {
|
||||
// 窗口期去重:依赖 Redis 检查
|
||||
String redisKey = "wechat:subscribe:dedup:" + baseHash;
|
||||
Boolean success = redisTemplate.opsForValue().setIfAbsent(redisKey, "1", dedupSeconds, TimeUnit.SECONDS);
|
||||
if (Boolean.FALSE.equals(success)) {
|
||||
// 窗口期内已发送
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
// 允许发送,但需要新的 DB 键以记录日志(因为表中 idempotency_key 唯一)
|
||||
dbIdempotencyKey = baseHash + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
}
|
||||
}
|
||||
// else: dedupSeconds == 0 或 null,使用 baseHash 作为 DB 键,利用 DB 唯一索引实现永久去重
|
||||
|
||||
WechatSubscribeSendLogEntity sendLog = buildInitLog(dbIdempotencyKey, eventKey, cfg, request);
|
||||
if (!tryInsertSendLog(sendLog)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.ycwl.basic.service.pc;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.model.pc.couponRecord.req.CouponRecordPageQueryReq;
|
||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordPageResp;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
|
||||
public interface CouponRecordService {
|
||||
ApiResponse<PageInfo<CouponRecordPageResp>> pageQuery(CouponRecordPageQueryReq query);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.ycwl.basic.service.pc;
|
||||
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.coupon.req.CouponQueryReq;
|
||||
import com.ycwl.basic.model.pc.coupon.resp.CouponRespVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CouponService {
|
||||
Integer add(CouponEntity coupon);
|
||||
Boolean update(CouponEntity coupon);
|
||||
Boolean delete(Integer id);
|
||||
CouponEntity getById(Integer id);
|
||||
List<CouponRespVO> list(CouponQueryReq query);
|
||||
|
||||
Boolean updateStatus(Integer id);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.ycwl.basic.service.pc.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.mapper.CouponRecordMapper;
|
||||
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
|
||||
import com.ycwl.basic.model.pc.couponRecord.req.CouponRecordPageQueryReq;
|
||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordPageResp;
|
||||
import com.ycwl.basic.service.pc.CouponRecordService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class CouponRecordServiceImpl extends ServiceImpl<CouponRecordMapper, CouponRecordEntity> implements CouponRecordService {
|
||||
|
||||
@Autowired
|
||||
private CouponRecordMapper couponRecordMapper;
|
||||
|
||||
@Override
|
||||
public ApiResponse<PageInfo<CouponRecordPageResp>> pageQuery(CouponRecordPageQueryReq query) {
|
||||
PageHelper.startPage(query.getPageNum(), query.getPageSize());
|
||||
List<CouponRecordPageResp> list = couponRecordMapper.selectByPageQuery(query);
|
||||
PageInfo<CouponRecordPageResp> pageInfo = new PageInfo<>(list);
|
||||
return ApiResponse.success(pageInfo);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.ycwl.basic.service.pc.impl;
|
||||
import com.ycwl.basic.mapper.CouponMapper;
|
||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||
import com.ycwl.basic.model.pc.coupon.req.CouponQueryReq;
|
||||
import com.ycwl.basic.model.pc.coupon.resp.CouponRespVO;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.service.pc.CouponService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class CouponServiceImpl implements CouponService {
|
||||
|
||||
@Autowired
|
||||
private CouponMapper couponMapper;
|
||||
@Autowired
|
||||
private ScenicRepository scenicRepository;
|
||||
|
||||
@Override
|
||||
public Integer add(CouponEntity coupon) {
|
||||
return couponMapper.insert(coupon);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean update(CouponEntity coupon) {
|
||||
return couponMapper.updateById(coupon) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean delete(Integer id) {
|
||||
return couponMapper.deleteById(id) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CouponEntity getById(Integer id) {
|
||||
return couponMapper.selectById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CouponRespVO> list(CouponQueryReq query) {
|
||||
List<CouponRespVO> list = couponMapper.selectByQuery(query);
|
||||
|
||||
// 批量获取景区名称
|
||||
List<Long> scenicIds = list.stream()
|
||||
.map(CouponRespVO::getScenicId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<Long, String> scenicNames = scenicRepository.batchGetScenicNames(scenicIds);
|
||||
|
||||
// 设置景区名称
|
||||
list.forEach(item -> {
|
||||
if (item.getScenicId() != null) {
|
||||
item.setScenicName(scenicNames.get(item.getScenicId()));
|
||||
}
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean updateStatus(Integer id) {
|
||||
return couponMapper.updateStatus(id) > 0;
|
||||
}
|
||||
}
|
||||
@@ -47,13 +47,11 @@ 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.constant.FreeStatus;
|
||||
import com.ycwl.basic.model.pc.puzzle.entity.MemberPuzzleEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||
import com.ycwl.basic.puzzle.mapper.MemberPuzzleMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||
import com.ycwl.basic.puzzle.repository.PuzzleRepository;
|
||||
@@ -201,12 +199,12 @@ public class FaceServiceImpl implements FaceService {
|
||||
@Autowired
|
||||
private PuzzleGenerationRecordMapper puzzleGenerationRecordMapper;
|
||||
@Autowired
|
||||
private IPriceCalculationService iPriceCalculationService;
|
||||
@Autowired
|
||||
private PuzzleTemplateMapper puzzleTemplateMapper;
|
||||
@Autowired
|
||||
private PuzzleRepository puzzleRepository;
|
||||
@Autowired
|
||||
private MemberPuzzleMapper memberPuzzleMapper;
|
||||
@Autowired
|
||||
private FaceDetectLogAiCamService faceDetectLogAiCamService;
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
@@ -566,21 +564,16 @@ public class FaceServiceImpl implements FaceService {
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
// 从 member_puzzle 关联记录读取免费状态
|
||||
if (optionalRecord.isPresent()) {
|
||||
MemberPuzzleEntity memberPuzzle = memberPuzzleMapper.getByFaceAndRecord(faceId, optionalRecord.get().getId());
|
||||
if (memberPuzzle != null && FreeStatus.isFree(memberPuzzle.getIsFree())) {
|
||||
sfpContent.setFreeCount(1);
|
||||
} else {
|
||||
sfpContent.setFreeCount(0);
|
||||
}
|
||||
} else {
|
||||
sfpContent.setFreeCount(1);
|
||||
sfpContent.setFreeCount(0);
|
||||
}
|
||||
contentList.add(1, sfpContent);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||
import com.ycwl.basic.model.mobile.order.OrderAppPageReq;
|
||||
import com.ycwl.basic.model.mobile.order.PriceObj;
|
||||
import com.ycwl.basic.model.mobile.order.RefundOrderReq;
|
||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.pc.member.resp.MemberRespVO;
|
||||
import com.ycwl.basic.model.pc.order.entity.OrderEntity;
|
||||
|
||||
@@ -9,6 +9,10 @@ import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
|
||||
import com.ycwl.basic.integration.common.manager.RenderWorkerConfigManager;
|
||||
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||
import com.ycwl.basic.integration.message.service.ZtMessageProducerService;
|
||||
import com.ycwl.basic.integration.render.dto.job.CreatePreviewRequest;
|
||||
import com.ycwl.basic.integration.render.dto.job.CreatePreviewResponse;
|
||||
import com.ycwl.basic.integration.render.dto.job.MaterialDTO;
|
||||
import com.ycwl.basic.integration.render.service.RenderJobIntegrationService;
|
||||
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||
import com.ycwl.basic.repository.SourceRepository;
|
||||
import com.ycwl.basic.utils.JacksonUtil;
|
||||
@@ -115,13 +119,11 @@ public class TaskTaskServiceImpl implements TaskService {
|
||||
@Autowired
|
||||
private MemberRelationRepository memberRelationRepository;
|
||||
@Autowired
|
||||
private ZtMessageProducerService ztMessageProducerService;
|
||||
@Autowired
|
||||
private NotificationAuthUtils notificationAuthUtils;
|
||||
@Autowired
|
||||
private WechatSubscribeNotifyTriggerService notifyTriggerService;
|
||||
@Autowired
|
||||
private FaceStatusManager faceStatusManager;
|
||||
@Autowired
|
||||
private RenderJobIntegrationService renderJobIntegrationService;
|
||||
|
||||
private RenderWorkerEntity getWorker(@NonNull WorkerAuthReqVo req) {
|
||||
String accessKey = req.getAccessKey();
|
||||
@@ -436,6 +438,18 @@ public class TaskTaskServiceImpl implements TaskService {
|
||||
if (!templateTaskList.isEmpty()) {
|
||||
taskEntity = templateTaskList.getFirst();
|
||||
isReuseOldTask = true;
|
||||
if (automatic && !forceCreate) {
|
||||
boolean autoReplaceVlog = scenicConfig.getBoolean("auto_replace_vlog", true);
|
||||
|
||||
if (!autoReplaceVlog) {
|
||||
VideoEntity video = videoRepository.getVideoByTaskId(taskEntity.getId());
|
||||
if (video != null) {
|
||||
log.info("自动创建任务:跳过(auto_replace_vlog=false), faceId:{}, templateId:{}, existingTaskId:{}, videoId:{}",
|
||||
faceId, templateId, taskEntity.getId(), video.getId());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("已有旧生成的视频:{}", taskEntity);
|
||||
MemberVideoEntity taskVideoRelation = videoMapper.queryRelationByMemberTask(face.getMemberId(), taskEntity.getId());
|
||||
if (taskVideoRelation != null) {
|
||||
@@ -462,9 +476,13 @@ public class TaskTaskServiceImpl implements TaskService {
|
||||
} else {
|
||||
taskMapper.add(taskEntity);
|
||||
}
|
||||
// 灰度测试:创建渲染预览任务(异步,不影响主流程)
|
||||
tryCreateRenderPreviewJobAsync(taskEntity.getId(), templateId, face.getScenicId(), faceId, face.getMemberId(), sourcesMap);
|
||||
memberVideoEntity.setTaskId(taskEntity.getId());
|
||||
} else {
|
||||
TaskRespVO existingTask = list.getFirst();
|
||||
|
||||
|
||||
log.info("重复task! faceId:{},templateId:{},taskId:{}", faceId, templateId, existingTask.getId());
|
||||
videoTaskRepository.clearTaskCache(existingTask.getId());
|
||||
|
||||
@@ -667,4 +685,55 @@ public class TaskTaskServiceImpl implements TaskService {
|
||||
public TaskRespVO taskInfo(Long taskId) {
|
||||
return taskMapper.getById(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 灰度测试:异步创建渲染预览任务
|
||||
* 不管zt-render-worker服务返回什么或者报错,都不影响现有流程
|
||||
*/
|
||||
private void tryCreateRenderPreviewJobAsync(Long taskId, Long templateId, Long scenicId, Long faceId, Long memberId, Map<String, List<SourceEntity>> sourcesMap) {
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try {
|
||||
log.info("[灰度测试] 开始创建渲染预览任务, taskId: {}, templateId: {}, scenicId: {}, faceId: {}",
|
||||
taskId, templateId, scenicId, faceId);
|
||||
|
||||
CreatePreviewRequest request = new CreatePreviewRequest();
|
||||
request.setTemplateId(templateId);
|
||||
request.setScenicId(scenicId);
|
||||
request.setFaceId(faceId);
|
||||
request.setMemberId(memberId);
|
||||
|
||||
// 转换素材映射
|
||||
Map<String, List<MaterialDTO>> materialsBySlot = new java.util.HashMap<>();
|
||||
if (sourcesMap != null && !sourcesMap.isEmpty()) {
|
||||
sourcesMap.forEach((slotKey, sources) -> {
|
||||
List<MaterialDTO> materials = sources.stream()
|
||||
.map(source -> {
|
||||
MaterialDTO material = new MaterialDTO();
|
||||
// 优先使用videoUrl,其次使用url
|
||||
if (StringUtils.isNotBlank(source.getVideoUrl())) {
|
||||
material.setUrl(source.getVideoUrl());
|
||||
material.setType("video");
|
||||
} else {
|
||||
material.setUrl(source.getUrl());
|
||||
material.setType(source.getType() != null && source.getType() == 2 ? "image" : "video");
|
||||
}
|
||||
material.setFacePos(source.getPosJson());
|
||||
return material;
|
||||
})
|
||||
.toList();
|
||||
materialsBySlot.put(slotKey, materials);
|
||||
});
|
||||
}
|
||||
request.setMaterialsBySlot(materialsBySlot);
|
||||
|
||||
CreatePreviewResponse response = renderJobIntegrationService.createPreview(request);
|
||||
log.info("[灰度测试] 渲染预览任务创建成功, taskId: {}, renderJobId: {}, playUrl: {}",
|
||||
taskId, response.getJobId(), response.getPlayUrl());
|
||||
} catch (Exception e) {
|
||||
// 灰度测试:不管返回什么或者报错,都不影响现有流程
|
||||
log.warn("[灰度测试] 渲染预览任务创建失败,不影响主流程, taskId: {}, templateId: {}, error: {}",
|
||||
taskId, templateId, e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user